From e71fdc7804d22b15fc1cb706abf8c7523d2e382b Mon Sep 17 00:00:00 2001 From: minimax Date: Thu, 8 Oct 2026 12:09:24 +0800 Subject: [PATCH] fix: schedule exec timeouts above the Node timer limit in chunks Node clamps setTimeout delays above 2147483647 ms to 1 ms, so accepted durations such as --timeout 600h cancelled a run almost immediately while the forwarded execution deadline pointed weeks into the future. Schedule the coordinator timeout as consecutive bounded chunks that preserve the requested total delay, keeping parser acceptance of any positive safe-integer duration. --- .../tui/src/application/run-coordinator.ts | 47 ++++++++++++++++--- .../tui/test/unit/headless-invocation.test.ts | 2 + .../tui/test/unit/run-coordinator.test.ts | 28 +++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index a510365d0..bb026e2c5 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -30,6 +30,8 @@ import { export type TuiRunRuntime = TuiConversationPort & Partial>; +const MAX_TIMER_DELAY_MS = 2_147_483_647; + export interface TuiRunRequest { turnId: string; session: Promise; @@ -181,7 +183,7 @@ export class TuiRunCoordinator { const activeRun = createActiveRun(request.turnId); this.activeRun = activeRun; const startedAtMs = this.nowMs(); - let timeout: ReturnType | undefined; + let timeout: TimeoutChunks | undefined; let session: TuiSession | undefined; let accepted = false; let status: TuiTurnRunStatus = 'succeeded'; @@ -204,10 +206,12 @@ export class TuiRunCoordinator { let executionDeadlineAtMs: number | undefined; if (request.policy?.timeoutMs !== undefined) { executionDeadlineAtMs = this.nowMs() + request.policy.timeoutMs; - timeout = setTimeout(() => { - void this.stopActive(activeRun, 'timeout').catch(() => undefined); - }, request.policy.timeoutMs); - timeout.unref?.(); + timeout = scheduleTimeoutChunks( + () => { + void this.stopActive(activeRun, 'timeout').catch(() => undefined); + }, + request.policy.timeoutMs, + ); } const source = this.sendWithQueueRecovery( @@ -329,7 +333,7 @@ export class TuiRunCoordinator { failure = execError(error); } } finally { - if (timeout) clearTimeout(timeout); + timeout?.clear(); if (this.activeRun === activeRun) this.activeRun = undefined; } @@ -625,6 +629,37 @@ async function waitForSession( type BoundedSettlement = { settled: true; value: T } | { settled: false }; +interface TimeoutChunks { + clear(): void; +} + +/** Node clamps setTimeout delays above 2^31 - 1 ms to 1 ms, so longer + * timeouts are scheduled as consecutive bounded chunks that preserve the + * requested total delay. */ +function scheduleTimeoutChunks(onExpired: () => void, delayMs: number): TimeoutChunks { + let remainingMs = delayMs; + let timer: ReturnType | undefined; + let cleared = false; + const scheduleNext = () => { + if (cleared) return; + if (remainingMs <= 0) { + onExpired(); + return; + } + const chunkMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS); + remainingMs -= chunkMs; + timer = setTimeout(scheduleNext, chunkMs); + timer.unref?.(); + }; + scheduleNext(); + return { + clear() { + cleared = true; + if (timer) clearTimeout(timer); + }, + }; +} + async function settleWithin(task: Promise, timeoutMs: number): Promise> { if (timeoutMs <= 0) return { settled: false }; let timer: ReturnType | undefined; diff --git a/packages/tui/test/unit/headless-invocation.test.ts b/packages/tui/test/unit/headless-invocation.test.ts index f0808fdc6..3f804959c 100644 --- a/packages/tui/test/unit/headless-invocation.test.ts +++ b/packages/tui/test/unit/headless-invocation.test.ts @@ -395,6 +395,8 @@ describe('headless invocation', () => { ['30s', 30_000], ['2m', 120_000], ['1H', 3_600_000], + ['600h', 2_160_000_000], + ['2147483648ms', 2_147_483_648], ] as const)('parses timeout %s as %i milliseconds', async (timeout, timeoutMs) => { const cwd = await workspace(); await expect( diff --git a/packages/tui/test/unit/run-coordinator.test.ts b/packages/tui/test/unit/run-coordinator.test.ts index 91eafa1e8..86931251c 100644 --- a/packages/tui/test/unit/run-coordinator.test.ts +++ b/packages/tui/test/unit/run-coordinator.test.ts @@ -237,6 +237,34 @@ describe('TuiRunCoordinator', () => { ); }); + it('fires timeouts above the Node timer limit only at the requested deadline', async () => { + vi.useFakeTimers(); + try { + const timeoutRuntime = createRuntime(async function* pendingTimeout(_req, signal) { + yield* []; + await new Promise((resolve) => + signal?.addEventListener('abort', () => resolve(), { once: true }), + ); + }); + // 200 ms beyond Node's 32-bit timer limit; a plain setTimeout would + // clamp this to 1 ms and cancel the run almost immediately. + const timed = new TuiRunCoordinator(timeoutRuntime, { nowMs: () => 1_000 }).execute({ + ...request('turn-timeout-overflow'), + policy: { timeoutMs: 2_147_483_847 }, + }); + expect(timeoutRuntime.abortSession).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2_147_483_647); + expect(timeoutRuntime.abortSession).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + await expect(timed).resolves.toMatchObject({ status: 'timeout' }); + expect(timeoutRuntime.abortSession).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'timeout' }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('reports a Runtime questionnaire as awaiting continuation without aborting it', async () => { const runtime = createRuntime(async function* () { yield {