diff --git a/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts b/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts index 59b72e7b1c3..537bd734bcc 100644 --- a/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts +++ b/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts @@ -455,6 +455,26 @@ describe('PollTriggerTaskHandler', () => { expect(vi.getTimerCount()).toBe(0); }); + test("counts the node's synchronous setup against the timeout", async () => { + // The deadline is armed before poll() is called, so setup that runs before + // the node returns its promise still lands in the timeout branch. + triggersAndPollers.runPollFunction.mockImplementation(async () => { + vi.advanceTimersByTime(pollTimeoutMs); // setup burns the whole budget + return await new Promise(() => {}); + }); + + const executing = handler.execute(buildTask(), report); + // No further time is granted: a deadline armed only after the setup would + // leave the tick hanging here instead of timing out. + await vi.advanceTimersByTimeAsync(0); + + expect(eventService.emit).toHaveBeenCalledWith('poll-tick-timed-out', { + nodeType: triggerNode.type, + }); + await expect(executing).resolves.toBeDefined(); + expect(vi.getTimerCount()).toBe(0); + }); + test('discards the data of an abandoned poll that resolves after the timeout', async () => { let resolvePoll: (data: INodeExecutionData[][]) => void = () => {}; triggersAndPollers.runPollFunction.mockReturnValue( diff --git a/packages/cli/src/utils/__tests__/race-timeout.test.ts b/packages/cli/src/utils/__tests__/race-timeout.test.ts index 5719c789662..18b42212e70 100644 --- a/packages/cli/src/utils/__tests__/race-timeout.test.ts +++ b/packages/cli/src/utils/__tests__/race-timeout.test.ts @@ -71,6 +71,9 @@ describe('raceTimeout', () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); const raced = raceTimeout(new Promise(() => {}), 1000, options); const timer = setTimeoutSpy.mock.results[0].value as NodeJS.Timeout; + // Restored while the fake timers are still installed, so the spy cannot + // outlive the test. + setTimeoutSpy.mockRestore(); return { raced, timer }; }; diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts index 148eef9b9c4..b62bd4af304 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts @@ -612,6 +612,35 @@ describe('WorkflowPublicationOutboxConsumer', () => { expect(reporter.report).not.toHaveBeenCalled(); }); + test('keeps both deadlines referenced so a shutdown still reaches the abandon path', async () => { + // At shutdown the poll timer is already cleared, so an unref'd deadline + // could let the process exit before the abort and the reporting below run. + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const deadlineFor = (delayMs: number) => { + const index = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === delayMs); + expect(index).toBeGreaterThanOrEqual(0); + return setTimeoutSpy.mock.results[index].value as NodeJS.Timeout; + }; + + const stuck = makeRecord({ id: 1 }); + outboxRepository.claimNextPendingRecord.mockResolvedValueOnce(stuck).mockResolvedValue(null); + applier.apply.mockImplementationOnce(async () => await new Promise(() => {})); + consumer.startPolling(); + + const drain = consumer.drainPending(); + await vi.advanceTimersByTimeAsync(0); + expect(deadlineFor(ABORT_AFTER_MS).hasRef()).toBe(true); + + await vi.advanceTimersByTimeAsync(ABORT_AFTER_MS); + expect(deadlineFor(ABANDON_GRACE_MS).hasRef()).toBe(true); + + // Restored while the fake timers are still installed, so the spy cannot + // outlive the test. + setTimeoutSpy.mockRestore(); + await vi.advanceTimersByTimeAsync(ABANDON_GRACE_MS); + await drain; + }); + test('scales the abandon grace down for short leases so abandonment stays within the lease', async () => { // Lease 16s: abort fires at 11.2s, grace is capped at 4s (a quarter of // the lease) instead of the fixed 10s, so abandonment lands at 15.2s —