fix(bench): clean timed-out sample process groups

This commit is contained in:
Vincent Koc
2026-06-20 12:30:28 +02:00
parent a1d278b174
commit 602bc0baa9
2 changed files with 184 additions and 22 deletions

View File

@@ -105,6 +105,8 @@ type CliOptions = {
const DEFAULT_RUNS = 5;
const DEFAULT_WARMUP = 1;
const DEFAULT_TIMEOUT_MS = 30_000;
const TIMEOUT_KILL_GRACE_MS = 1_000;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const DEFAULT_ENTRY = "openclaw.mjs";
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
const VALUE_FLAGS = new Set([
@@ -708,12 +710,16 @@ async function runSample(params: {
let stderr = "";
let settled = false;
let timedOut = false;
let forceKillAt: number | null = null;
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
const maxOutputLength = 32 * 1024 * 1024;
try {
return await new Promise<Sample>((resolve) => {
const useProcessGroup = process.platform !== "win32";
const proc = spawn(process.execPath, nodeArgs, {
cwd: process.cwd(),
detached: useProcessGroup,
env: {
...process.env,
HOME: runRoot,
@@ -733,6 +739,10 @@ async function runSample(params: {
return;
}
settled = true;
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
const ms = Number(process.hrtime.bigint() - started) / 1e6;
resolve({
ms,
@@ -751,18 +761,11 @@ async function runSample(params: {
const timeout = setTimeout(() => {
timedOut = true;
try {
proc.kill("SIGTERM");
} catch {
// Best-effort timeout cleanup.
}
setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// Best-effort timeout cleanup.
}
}, 1_000).unref?.();
signalSampleProcess(proc, "SIGTERM", useProcessGroup);
forceKillAt = Date.now() + TIMEOUT_KILL_GRACE_MS;
forceKillTimer = setTimeout(() => {
signalSampleProcess(proc, "SIGKILL", useProcessGroup);
}, TIMEOUT_KILL_GRACE_MS).unref?.();
}, params.timeoutMs);
timeout.unref?.();
@@ -790,16 +793,27 @@ async function runSample(params: {
});
proc.once("close", (code, signal) => {
clearTimeout(timeout);
finish({
exitCode: code,
signal,
...(code === 0 && signal == null
? {}
: {
stdoutTail: tailLines(stdout, 20),
stderrTail: tailLines(stderr, 20),
}),
});
const complete = () =>
finish({
exitCode: code,
signal,
...(code === 0 && signal == null
? {}
: {
stdoutTail: tailLines(stdout, 20),
stderrTail: tailLines(stderr, 20),
}),
});
if (timedOut && isSampleProcessGroupAlive(proc, useProcessGroup)) {
void finishAfterTimeoutCleanup({
complete,
forceKillAt,
proc,
useProcessGroup,
});
return;
}
complete();
});
});
} finally {
@@ -807,6 +821,80 @@ async function runSample(params: {
}
}
async function finishAfterTimeoutCleanup(params: {
complete: () => void;
forceKillAt: number | null;
proc: ReturnType<typeof spawn>;
useProcessGroup: boolean;
}): Promise<void> {
const graceRemainingMs =
params.forceKillAt === null
? TIMEOUT_KILL_GRACE_MS
: Math.max(0, params.forceKillAt - Date.now());
if (graceRemainingMs > 0) {
await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, graceRemainingMs);
}
if (isSampleProcessGroupAlive(params.proc, params.useProcessGroup)) {
signalSampleProcess(params.proc, "SIGKILL", params.useProcessGroup);
}
await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, TIMEOUT_KILL_GRACE_MS);
params.complete();
}
function signalSampleProcess(
proc: ReturnType<typeof spawn>,
signal: NodeJS.Signals,
useProcessGroup: boolean,
): void {
if (!proc.pid) {
return;
}
try {
if (useProcessGroup) {
process.kill(-proc.pid, signal);
} else {
proc.kill(signal);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ESRCH" && code !== "EPERM") {
throw error;
}
}
}
function isSampleProcessGroupAlive(
proc: ReturnType<typeof spawn>,
useProcessGroup: boolean,
): boolean {
if (!useProcessGroup || !proc.pid) {
return false;
}
try {
process.kill(-proc.pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
async function waitForSampleProcessGroupExit(
proc: ReturnType<typeof spawn>,
useProcessGroup: boolean,
timeoutMs: number,
): Promise<boolean> {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!isSampleProcessGroupAlive(proc, useProcessGroup)) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
});
}
return !isSampleProcessGroupAlive(proc, useProcessGroup);
}
async function runCase(params: {
entry: string;
commandCase: CommandCase;