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 {