fix(boundary): clean active check child trees

This commit is contained in:
Vincent Koc
2026-06-20 14:51:48 +02:00
parent 5c8fa5da5c
commit e2c567538d
2 changed files with 170 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
// Run Additional Boundary Checks tests cover run additional boundary checks script behavior.
import { spawnSync } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -47,6 +47,13 @@ function isProcessAlive(pid: number): boolean {
}
}
function isProcessZombie(pid: number): boolean {
const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], {
encoding: "utf8",
});
return result.status === 0 && result.stdout.trim().startsWith("Z");
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
@@ -75,6 +82,32 @@ async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
throw new Error(`process still alive: ${pid}`);
}
async function waitForNotRunning(pid: number, timeoutMs: number): Promise<void> {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!isProcessAlive(pid) || isProcessZombie(pid)) {
return;
}
await sleep(25);
}
throw new Error(`process still running: ${pid}`);
}
async function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs: number,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("child did not close before timeout"));
}, timeoutMs);
child.once("close", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
describe("run-additional-boundary-checks", () => {
it("runs prompt snapshot drift checks in CI", () => {
expect(BOUNDARY_CHECKS[0]).toEqual({
@@ -284,4 +317,84 @@ describe("run-additional-boundary-checks", () => {
}
},
);
it.skipIf(process.platform === "win32")(
"cleans active check descendants on parent signal",
async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-signal-"));
const readyPath = path.join(tempDir, "ready");
const childPidPath = path.join(tempDir, "child.pid");
let childPid = 0;
let runner: ReturnType<typeof spawn> | undefined;
try {
const childScript = [
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("");
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
"fs.writeFileSync(process.env.OPENCLAW_TEST_CHILD_PID, String(child.pid));",
"fs.writeFileSync(process.env.OPENCLAW_TEST_READY, 'ready');",
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("");
const runnerScript = `
import { runChecks } from ${JSON.stringify(
new URL("../../scripts/run-additional-boundary-checks.mjs", import.meta.url).href,
)};
await runChecks(
[{
label: "parent-signal",
command: process.execPath,
args: ["-e", ${JSON.stringify(parentScript)}],
}],
{
checkTimeoutMs: 30000,
concurrency: 1,
cwd: process.cwd(),
env: process.env,
output: { write() { return true; } },
outputMaxBytes: 4096,
},
);
`;
runner = spawn(process.execPath, ["--input-type=module", "--eval", runnerScript], {
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_TEST_CHILD_PID: childPidPath,
OPENCLAW_TEST_READY: readyPath,
},
stdio: ["ignore", "ignore", "pipe"],
});
await waitForFile(readyPath, 2000);
childPid = Number(fs.readFileSync(childPidPath, "utf8"));
expect(Number.isInteger(childPid)).toBe(true);
expect(isProcessAlive(childPid)).toBe(true);
runner.kill("SIGTERM");
await sleep(50);
runner.kill("SIGTERM");
await expect(waitForChildClose(runner, 10_000)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitForNotRunning(childPid, 2000);
} finally {
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
if (runner?.pid && isProcessAlive(runner.pid)) {
runner.kill("SIGKILL");
}
fs.rmSync(tempDir, { force: true, recursive: true });
}
},
);
});