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 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-08 09:48:08 +00:00
parent f1e2322f63
commit ca4dc42631
3 changed files with 52 additions and 0 deletions

View File

@ -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(

View File

@ -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 };
};

View File

@ -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 —