Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions packages/tui/src/application/run-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
export type TuiRunRuntime = TuiConversationPort &
Partial<Pick<TuiInteractionPort, 'getPendingQuestionnaire'>>;

const MAX_TIMER_DELAY_MS = 2_147_483_647;

export interface TuiRunRequest {
turnId: string;
session: Promise<TuiSession>;
Expand Down Expand Up @@ -181,7 +183,7 @@ export class TuiRunCoordinator {
const activeRun = createActiveRun(request.turnId);
this.activeRun = activeRun;
const startedAtMs = this.nowMs();
let timeout: ReturnType<typeof setTimeout> | undefined;
let timeout: TimeoutChunks | undefined;
let session: TuiSession | undefined;
let accepted = false;
let status: TuiTurnRunStatus = 'succeeded';
Expand All @@ -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(
Expand Down Expand Up @@ -329,7 +333,7 @@ export class TuiRunCoordinator {
failure = execError(error);
}
} finally {
if (timeout) clearTimeout(timeout);
timeout?.clear();
if (this.activeRun === activeRun) this.activeRun = undefined;
}

Expand Down Expand Up @@ -625,6 +629,37 @@ async function waitForSession(

type BoundedSettlement<T> = { 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<typeof setTimeout> | 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<T>(task: Promise<T>, timeoutMs: number): Promise<BoundedSettlement<T>> {
if (timeoutMs <= 0) return { settled: false };
let timer: ReturnType<typeof setTimeout> | undefined;
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/test/unit/headless-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions packages/tui/test/unit/run-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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 {
Expand Down
Loading