fix(supervisor): bound captured process output

This commit is contained in:
Vincent Koc
2026-05-28 13:43:36 +02:00
parent 9a7f808953
commit 2252cf6f03
3 changed files with 69 additions and 2 deletions

View File

@@ -290,4 +290,42 @@ describe("process supervisor", () => {
expect(streamed).toBe("streamed");
expect(exit.stdout).toBe("");
});
it("bounds retained stdout and stderr while streaming full chunks", async () => {
const adapter = createStubChildAdapter();
createChildAdapterMock.mockResolvedValue(adapter);
const supervisor = createProcessSupervisor();
let streamedStdout = "";
let streamedStderr = "";
const stdoutChunk = `${"a".repeat(300)}stdout-tail`;
const stderrChunk = `${"b".repeat(300)}stderr-tail`;
const run = await spawnChild(supervisor, {
sessionId: "s-capture-cap",
argv: createWriteStdoutArgv(stdoutChunk),
timeoutMs: 1_000,
stdinMode: "pipe-closed",
maxCapturedOutputChars: 256,
onStdout: (chunk) => {
streamedStdout += chunk;
},
onStderr: (chunk) => {
streamedStderr += chunk;
},
});
adapter.emitStdout(stdoutChunk);
adapter.emitStderr(stderrChunk);
adapter.settle(0);
const exit = await run.wait();
expect(streamedStdout).toBe(stdoutChunk);
expect(streamedStderr).toBe(stderrChunk);
expect(exit.stdout.length).toBeLessThanOrEqual(256);
expect(exit.stderr.length).toBeLessThanOrEqual(256);
expect(exit.stdout).toContain("captured stdout truncated");
expect(exit.stderr).toContain("captured stderr truncated");
expect(exit.stdout.endsWith("stdout-tail")).toBe(true);
expect(exit.stderr.endsWith("stderr-tail")).toBe(true);
});
});

View File

@@ -21,6 +21,7 @@ type ActiveRun = {
};
const GRACEFUL_CANCEL_TIMEOUT_MS = 5000;
const DEFAULT_MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
let supervisorLogRuntimePromise: Promise<SupervisorLogRuntime> | undefined;
@@ -36,6 +37,28 @@ function clampTimeout(value?: number): number | undefined {
return Math.max(1, Math.floor(value));
}
function clampCapturedOutputChars(value?: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return DEFAULT_MAX_CAPTURED_OUTPUT_CHARS;
}
return Math.max(256, Math.floor(value));
}
function appendCapturedOutput(
current: string,
chunk: string,
stream: "stdout" | "stderr",
maxChars: number,
) {
const next = current + chunk;
if (next.length <= maxChars) {
return next;
}
const marker = `[openclaw: captured ${stream} truncated to last ${maxChars} chars]\n`;
const tailChars = Math.max(0, maxChars - marker.length);
return `${marker}${next.slice(-tailChars)}`;
}
function isTimeoutReason(reason: TerminationReason) {
return reason === "overall-timeout" || reason === "no-output-timeout";
}
@@ -95,6 +118,7 @@ export function createProcessSupervisor(): ProcessSupervisor {
let noOutputTimer: NodeJS.Timeout | null = null;
let forceKillTimer: NodeJS.Timeout | null = null;
const captureOutput = input.captureOutput !== false;
const maxCapturedOutputChars = clampCapturedOutputChars(input.maxCapturedOutputChars);
const overallTimeoutMs = clampTimeout(input.timeoutMs);
const noOutputTimeoutMs = clampTimeout(input.noOutputTimeoutMs);
@@ -198,14 +222,14 @@ export function createProcessSupervisor(): ProcessSupervisor {
adapter.onStdout((chunk) => {
if (captureOutput) {
stdout += chunk;
stdout = appendCapturedOutput(stdout, chunk, "stdout", maxCapturedOutputChars);
}
input.onStdout?.(chunk);
touchOutput();
});
adapter.onStderr((chunk) => {
if (captureOutput) {
stderr += chunk;
stderr = appendCapturedOutput(stderr, chunk, "stderr", maxCapturedOutputChars);
}
input.onStderr?.(chunk);
touchOutput();

View File

@@ -81,6 +81,11 @@ type SpawnBaseInput = {
* When false, stdout/stderr are streamed via callbacks only and not retained in RunExit payload.
*/
captureOutput?: boolean;
/**
* Maximum retained stdout/stderr characters per stream when captureOutput is enabled.
* Streaming callbacks still receive full chunks.
*/
maxCapturedOutputChars?: number;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
};