fix(exec): keep timeout classification deterministic

This commit is contained in:
Peter Steinberger
2026-05-31 10:28:23 +01:00
parent 2804a27ad0
commit fb3018ef31
2 changed files with 99 additions and 6 deletions

View File

@@ -1,3 +1,4 @@
import { performance } from "node:perf_hooks";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SpawnProcessAdapter } from "./types.js";
@@ -266,6 +267,58 @@ describe("process supervisor", () => {
expect(exit.timedOut).toBe(true);
});
it("classifies a natural close after a missed overall deadline as timed out", async () => {
vi.useFakeTimers();
const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000);
const adapter = createStubChildAdapter();
createChildAdapterMock.mockResolvedValue(adapter);
const supervisor = createProcessSupervisor();
const run = await spawnChild(supervisor, {
sessionId: "s-timeout-race",
argv: createSilentIdleArgv(),
timeoutMs: 10,
stdinMode: "pipe-closed",
});
const exitPromise = run.wait();
nowSpy.mockReturnValue(1_011);
adapter.settle(0);
const exit = await exitPromise;
expect(adapter.killMock).not.toHaveBeenCalled();
expect(exit.reason).toBe("overall-timeout");
expect(exit.timedOut).toBe(true);
});
it("uses the refreshed no-output deadline when a missed timer races natural close", async () => {
vi.useFakeTimers();
const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000);
const adapter = createStubChildAdapter();
createChildAdapterMock.mockResolvedValue(adapter);
const supervisor = createProcessSupervisor();
const run = await spawnChild(supervisor, {
sessionId: "s-no-output-race",
argv: createSilentIdleArgv(),
timeoutMs: 100,
noOutputTimeoutMs: 10,
stdinMode: "pipe-closed",
});
const exitPromise = run.wait();
nowSpy.mockReturnValue(1_005);
adapter.emitStdout("progress");
nowSpy.mockReturnValue(1_016);
adapter.settle(0);
const exit = await exitPromise;
expect(adapter.killMock).not.toHaveBeenCalled();
expect(exit.reason).toBe("no-output-timeout");
expect(exit.noOutputTimedOut).toBe(true);
expect(exit.timedOut).toBe(true);
});
it("can stream output without retaining it in RunExit payload", async () => {
const adapter = createStubChildAdapter();
createChildAdapterMock.mockResolvedValue(adapter);

View File

@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import { performance } from "node:perf_hooks";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { getShellConfig } from "../../agents/shell-utils.js";
import { createChildAdapter } from "./adapters/child.js";
@@ -63,6 +64,34 @@ function isTimeoutReason(reason: TerminationReason) {
return reason === "overall-timeout" || reason === "no-output-timeout";
}
function resolveElapsedTimeoutReason(params: {
nowMs: number;
overallTimeoutDeadlineMs: number | null;
noOutputTimeoutDeadlineMs: number | null;
}): TerminationReason | null {
const elapsedDeadlines: Array<{ reason: TerminationReason; deadlineMs: number }> = [];
if (params.overallTimeoutDeadlineMs !== null && params.nowMs >= params.overallTimeoutDeadlineMs) {
elapsedDeadlines.push({
reason: "overall-timeout",
deadlineMs: params.overallTimeoutDeadlineMs,
});
}
if (
params.noOutputTimeoutDeadlineMs !== null &&
params.nowMs >= params.noOutputTimeoutDeadlineMs
) {
elapsedDeadlines.push({
reason: "no-output-timeout",
deadlineMs: params.noOutputTimeoutDeadlineMs,
});
}
if (elapsedDeadlines.length === 0) {
return null;
}
elapsedDeadlines.sort((a, b) => a.deadlineMs - b.deadlineMs);
return elapsedDeadlines[0].reason;
}
export function createProcessSupervisor(): ProcessSupervisor {
const registry = createRunRegistry();
const active = new Map<string, ActiveRun>();
@@ -122,6 +151,8 @@ export function createProcessSupervisor(): ProcessSupervisor {
const overallTimeoutMs = clampTimeout(input.timeoutMs);
const noOutputTimeoutMs = clampTimeout(input.noOutputTimeoutMs);
let overallTimeoutDeadlineMs: number | null = null;
let noOutputTimeoutDeadlineMs: number | null = null;
const setForcedReason = (reason: TerminationReason) => {
if (forcedReason) {
@@ -143,6 +174,7 @@ export function createProcessSupervisor(): ProcessSupervisor {
if (!noOutputTimeoutMs || settled) {
return;
}
noOutputTimeoutDeadlineMs = performance.now() + noOutputTimeoutMs;
if (noOutputTimer) {
clearTimeout(noOutputTimer);
}
@@ -210,11 +242,13 @@ export function createProcessSupervisor(): ProcessSupervisor {
};
if (overallTimeoutMs) {
overallTimeoutDeadlineMs = performance.now() + overallTimeoutMs;
timeoutTimer = setTimeout(() => {
requestCancel("overall-timeout");
}, overallTimeoutMs);
}
if (noOutputTimeoutMs) {
noOutputTimeoutDeadlineMs = performance.now() + noOutputTimeoutMs;
noOutputTimer = setTimeout(() => {
requestCancel("no-output-timeout");
}, noOutputTimeoutMs);
@@ -237,16 +271,22 @@ export function createProcessSupervisor(): ProcessSupervisor {
const waitPromise = (async (): Promise<RunExit> => {
const result = await adapter.wait();
const deadlineReason = resolveElapsedTimeoutReason({
nowMs: performance.now(),
overallTimeoutDeadlineMs,
noOutputTimeoutDeadlineMs,
});
const terminalReason = forcedReason ?? deadlineReason;
if (settled) {
return {
reason: forcedReason ?? "exit",
reason: terminalReason ?? "exit",
exitCode: result.code,
exitSignal: result.signal,
durationMs: Date.now() - startedAtMs,
stdout,
stderr,
timedOut: isTimeoutReason(forcedReason ?? "exit"),
noOutputTimedOut: forcedReason === "no-output-timeout",
timedOut: isTimeoutReason(terminalReason ?? "exit"),
noOutputTimedOut: terminalReason === "no-output-timeout",
};
}
settled = true;
@@ -255,7 +295,7 @@ export function createProcessSupervisor(): ProcessSupervisor {
active.delete(runId);
const reason: TerminationReason =
forcedReason ?? (result.signal != null ? ("signal" as const) : ("exit" as const));
terminalReason ?? (result.signal != null ? ("signal" as const) : ("exit" as const));
const exit: RunExit = {
reason,
exitCode: result.code,
@@ -263,8 +303,8 @@ export function createProcessSupervisor(): ProcessSupervisor {
durationMs: Date.now() - startedAtMs,
stdout,
stderr,
timedOut: isTimeoutReason(forcedReason ?? reason),
noOutputTimedOut: forcedReason === "no-output-timeout",
timedOut: isTimeoutReason(terminalReason ?? reason),
noOutputTimedOut: terminalReason === "no-output-timeout",
};
registry.finalize(runId, {
reason: exit.reason,