From ca4dc4263161b5dade240f611e367fa2da6cfbfc Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" Date: Tue, 8 Sep 2026 09:48:08 +0000 Subject: [PATCH] test(core): Lock each call site's timeout deadline at its own call site The two call sites want different deadlines, and the shared helper alone cannot prove which one each site asked for. Add one test for each: - The poll handler counts a node's synchronous setup against the poll timeout, so the deadline must be armed before poll() runs. - The publication consumer keeps its abort and abandon deadlines ref'd, so a shutdown still reaches the abort and the abandon reporting. Also restore the setTimeout spy in the helper test while the fake timers are still installed, so it cannot leak into a later test. Co-Authored-By: Claude Opus 5 --- .../poll-trigger-task-handler.test.ts | 20 +++++++++++++ .../src/utils/__tests__/race-timeout.test.ts | 3 ++ ...rkflow-publication-outbox-consumer.test.ts | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+) 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 —