test(perf): tighten process test timeouts and fs setup

This commit is contained in:
Peter Steinberger
2026-03-02 11:16:24 +00:00
parent 4dcb16d696
commit bff785aecc
3 changed files with 47 additions and 114 deletions

View File

@@ -32,9 +32,7 @@ let fixtureRoot = "";
let fixtureCount = 0; let fixtureCount = 0;
async function makeStorePath() { async function makeStorePath() {
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`); const storePath = path.join(fixtureRoot, `case-${fixtureCount++}.jobs.json`);
await fs.mkdir(dir, { recursive: true });
const storePath = path.join(dir, "jobs.json");
return { return {
storePath, storePath,
}; };
@@ -1523,7 +1521,7 @@ describe("Cron issue regressions", () => {
// Keep this short for suite speed while still separating expected timeout // Keep this short for suite speed while still separating expected timeout
// from the 1/3-regression timeout. // from the 1/3-regression timeout.
const timeoutSeconds = 0.06; const timeoutSeconds = 0.03;
const cronJob = createIsolatedRegressionJob({ const cronJob = createIsolatedRegressionJob({
id: "timeout-fraction-29774", id: "timeout-fraction-29774",
name: "timeout fraction regression", name: "timeout fraction regression",
@@ -1579,7 +1577,7 @@ describe("Cron issue regressions", () => {
// The abort must not fire at the old ~1/3 regression value. // The abort must not fire at the old ~1/3 regression value.
// Keep the lower bound conservative for loaded CI runners. // Keep the lower bound conservative for loaded CI runners.
const elapsedMs = (abortWallMs ?? Date.now()) - wallStart; const elapsedMs = (abortWallMs ?? Date.now()) - wallStart;
expect(elapsedMs).toBeGreaterThanOrEqual(timeoutSeconds * 1000 * 0.6); expect(elapsedMs).toBeGreaterThanOrEqual(timeoutSeconds * 1000 * 0.55);
const job = state.store?.jobs.find((entry) => entry.id === "timeout-fraction-29774"); const job = state.store?.jobs.find((entry) => entry.id === "timeout-fraction-29774");
expect(job?.state.lastStatus).toBe("error"); expect(job?.state.lastStatus).toBe("error");

View File

@@ -1,52 +1,11 @@
import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import path from "node:path"; import { EventEmitter } from "node:events";
import process from "node:process"; import process from "node:process";
import { afterEach, describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js"; import { withEnvAsync } from "../test-utils/env.js";
import { attachChildProcessBridge } from "./child-process-bridge.js"; import { attachChildProcessBridge } from "./child-process-bridge.js";
import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js";
const CHILD_READY_TIMEOUT_MS = 2_000;
const CHILD_EXIT_TIMEOUT_MS = 2_000;
function waitForLine(
stream: NodeJS.ReadableStream,
timeoutMs = CHILD_READY_TIMEOUT_MS,
): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = "";
const timeout = setTimeout(() => {
cleanup();
reject(new Error("timeout waiting for line"));
}, timeoutMs);
const onData = (chunk: Buffer | string): void => {
buffer += chunk.toString();
const idx = buffer.indexOf("\n");
if (idx >= 0) {
const line = buffer.slice(0, idx).trim();
cleanup();
resolve(line);
}
};
const onError = (err: unknown): void => {
cleanup();
reject(err);
};
const cleanup = (): void => {
clearTimeout(timeout);
stream.off("data", onData);
stream.off("error", onError);
};
stream.on("data", onData);
stream.on("error", onError);
});
}
describe("runCommandWithTimeout", () => { describe("runCommandWithTimeout", () => {
it("never enables shell execution (Windows cmd.exe injection hardening)", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
expect( expect(
@@ -66,7 +25,7 @@ describe("runCommandWithTimeout", () => {
'process.stdout.write((process.env.OPENCLAW_BASE_ENV ?? "") + "|" + (process.env.OPENCLAW_TEST_ENV ?? ""))', 'process.stdout.write((process.env.OPENCLAW_BASE_ENV ?? "") + "|" + (process.env.OPENCLAW_TEST_ENV ?? ""))',
], ],
{ {
timeoutMs: 1_000, timeoutMs: 400,
env: { OPENCLAW_TEST_ENV: "ok" }, env: { OPENCLAW_TEST_ENV: "ok" },
}, },
); );
@@ -79,10 +38,10 @@ describe("runCommandWithTimeout", () => {
it("kills command when no output timeout elapses", async () => { it("kills command when no output timeout elapses", async () => {
const result = await runCommandWithTimeout( const result = await runCommandWithTimeout(
[process.execPath, "-e", "setTimeout(() => {}, 60)"], [process.execPath, "-e", "setTimeout(() => {}, 30)"],
{ {
timeoutMs: 500, timeoutMs: 220,
noOutputTimeoutMs: 12, noOutputTimeoutMs: 8,
}, },
); );
@@ -105,13 +64,13 @@ describe("runCommandWithTimeout", () => {
"clearInterval(ticker);", "clearInterval(ticker);",
"process.exit(0);", "process.exit(0);",
"}", "}",
"}, 10);", "}, 6);",
].join(" "), ].join(" "),
], ],
{ {
timeoutMs: 2_000, timeoutMs: 600,
// Keep a healthy margin above the emit interval while avoiding long idle waits. // Keep a healthy margin above the emit interval while avoiding long idle waits.
noOutputTimeoutMs: 70, noOutputTimeoutMs: 60,
}, },
); );
@@ -123,9 +82,9 @@ describe("runCommandWithTimeout", () => {
it("reports global timeout termination when overall timeout elapses", async () => { it("reports global timeout termination when overall timeout elapses", async () => {
const result = await runCommandWithTimeout( const result = await runCommandWithTimeout(
[process.execPath, "-e", "setTimeout(() => {}, 40)"], [process.execPath, "-e", "setTimeout(() => {}, 20)"],
{ {
timeoutMs: 15, timeoutMs: 10,
}, },
); );
@@ -145,62 +104,38 @@ describe("runCommandWithTimeout", () => {
}); });
describe("attachChildProcessBridge", () => { describe("attachChildProcessBridge", () => {
const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; function createFakeChild() {
const detachments: Array<() => void> = []; const emitter = new EventEmitter() as EventEmitter & ChildProcess;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
afterEach(() => { emitter.kill = kill as ChildProcess["kill"];
for (const detach of detachments) { return { child: emitter, kill };
try { }
detach();
} catch {
// ignore
}
}
detachments.length = 0;
for (const child of children) {
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}
children.length = 0;
});
it("forwards SIGTERM to the wrapped child", async () => {
const childPath = path.resolve(process.cwd(), "test/fixtures/child-process-bridge/child.js");
it("forwards SIGTERM to the wrapped child and detaches on exit", () => {
const beforeSigterm = new Set(process.listeners("SIGTERM")); const beforeSigterm = new Set(process.listeners("SIGTERM"));
const child = spawn(process.execPath, [childPath], { const { child, kill } = createFakeChild();
stdio: ["ignore", "pipe", "inherit"], const observedSignals: NodeJS.Signals[] = [];
env: process.env,
const { detach } = attachChildProcessBridge(child, {
signals: ["SIGTERM"],
onSignal: (signal) => observedSignals.push(signal),
}); });
const { detach } = attachChildProcessBridge(child);
detachments.push(detach);
children.push(child);
const afterSigterm = process.listeners("SIGTERM"); const afterSigterm = process.listeners("SIGTERM");
const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener)); const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener));
if (!child.stdout) {
throw new Error("expected stdout");
}
const ready = await waitForLine(child.stdout);
expect(ready).toBe("ready");
if (!addedSigterm) { if (!addedSigterm) {
throw new Error("expected SIGTERM listener"); throw new Error("expected SIGTERM listener");
} }
addedSigterm("SIGTERM");
await new Promise<void>((resolve, reject) => { addedSigterm();
const timeout = setTimeout( expect(observedSignals).toEqual(["SIGTERM"]);
() => reject(new Error("timeout waiting for child exit")), expect(kill).toHaveBeenCalledWith("SIGTERM");
CHILD_EXIT_TIMEOUT_MS,
); child.emit("exit");
child.once("exit", () => { expect(process.listeners("SIGTERM")).toHaveLength(beforeSigterm.size);
clearTimeout(timeout);
resolve(); // Detached already via exit; should remain a safe no-op.
}); detach();
});
}); });
}); });

View File

@@ -4,7 +4,7 @@ import { createProcessSupervisor } from "./supervisor.js";
type ProcessSupervisor = ReturnType<typeof createProcessSupervisor>; type ProcessSupervisor = ReturnType<typeof createProcessSupervisor>;
type SpawnOptions = Parameters<ProcessSupervisor["spawn"]>[0]; type SpawnOptions = Parameters<ProcessSupervisor["spawn"]>[0];
type ChildSpawnOptions = Omit<Extract<SpawnOptions, { mode: "child" }>, "backendId" | "mode">; type ChildSpawnOptions = Omit<Extract<SpawnOptions, { mode: "child" }>, "backendId" | "mode">;
const OUTPUT_DELAY_MS = 8; const OUTPUT_DELAY_MS = 6;
async function spawnChild(supervisor: ProcessSupervisor, options: ChildSpawnOptions) { async function spawnChild(supervisor: ProcessSupervisor, options: ChildSpawnOptions) {
return supervisor.spawn({ return supervisor.spawn({
@@ -25,7 +25,7 @@ describe("process supervisor", () => {
"-e", "-e",
`setTimeout(() => process.stdout.write("ok"), ${OUTPUT_DELAY_MS})`, `setTimeout(() => process.stdout.write("ok"), ${OUTPUT_DELAY_MS})`,
], ],
timeoutMs: 2_000, timeoutMs: 1_000,
stdinMode: "pipe-closed", stdinMode: "pipe-closed",
}); });
const exit = await run.wait(); const exit = await run.wait();
@@ -38,9 +38,9 @@ describe("process supervisor", () => {
const supervisor = createProcessSupervisor(); const supervisor = createProcessSupervisor();
const run = await spawnChild(supervisor, { const run = await spawnChild(supervisor, {
sessionId: "s1", sessionId: "s1",
argv: [process.execPath, "-e", "setTimeout(() => {}, 24)"], argv: [process.execPath, "-e", "setTimeout(() => {}, 18)"],
timeoutMs: 500, timeoutMs: 300,
noOutputTimeoutMs: 8, noOutputTimeoutMs: 6,
stdinMode: "pipe-closed", stdinMode: "pipe-closed",
}); });
const exit = await run.wait(); const exit = await run.wait();
@@ -54,8 +54,8 @@ describe("process supervisor", () => {
const first = await spawnChild(supervisor, { const first = await spawnChild(supervisor, {
sessionId: "s1", sessionId: "s1",
scopeKey: "scope:a", scopeKey: "scope:a",
argv: [process.execPath, "-e", "setTimeout(() => {}, 500)"], argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"],
timeoutMs: 2_000, timeoutMs: 1_000,
stdinMode: "pipe-open", stdinMode: "pipe-open",
}); });
@@ -69,7 +69,7 @@ describe("process supervisor", () => {
"-e", "-e",
`setTimeout(() => process.stdout.write("new"), ${OUTPUT_DELAY_MS})`, `setTimeout(() => process.stdout.write("new"), ${OUTPUT_DELAY_MS})`,
], ],
timeoutMs: 2_000, timeoutMs: 1_000,
stdinMode: "pipe-closed", stdinMode: "pipe-closed",
}); });
@@ -104,7 +104,7 @@ describe("process supervisor", () => {
"-e", "-e",
`setTimeout(() => process.stdout.write("streamed"), ${OUTPUT_DELAY_MS})`, `setTimeout(() => process.stdout.write("streamed"), ${OUTPUT_DELAY_MS})`,
], ],
timeoutMs: 2_000, timeoutMs: 1_000,
stdinMode: "pipe-closed", stdinMode: "pipe-closed",
captureOutput: false, captureOutput: false,
onStdout: (chunk) => { onStdout: (chunk) => {