fix(qa-matrix): clean up killed CLI process groups

This commit is contained in:
Vincent Koc
2026-06-20 11:50:34 +02:00
parent fe1d981a47
commit 4296ecb78c
2 changed files with 86 additions and 12 deletions

View File

@@ -378,4 +378,57 @@ describe("Matrix QA CLI runtime", () => {
await rm(root, { force: true, recursive: true });
}
});
it("kills ignored-stdio descendants after manual CLI session kill", async () => {
if (process.platform === "win32") {
return;
}
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-session-kill-ignored-stdio-"),
);
const childPidPath = path.join(root, "child.pid");
const grandchildPidPath = path.join(root, "grandchild.pid");
let childPid: number | undefined;
let grandchildPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
"grandchild.unref();",
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
const session = startMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 10_000,
});
await waitForFile(grandchildPidPath, 2_000);
await sleep(300);
session.kill();
await sleep(500);
childPid = Number(await readFile(childPidPath, "utf8"));
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
expect(isProcessRunning(grandchildPid)).toBe(false);
} finally {
for (const pid of [grandchildPid, childPid]) {
if (pid && isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
}
}
await rm(root, { force: true, recursive: true });
}
});
});

View File

@@ -148,6 +148,7 @@ export function startMatrixQaOpenClawCli(params: {
let closed = false;
let closeError: Error | undefined;
let closeResult: MatrixQaCliRunResult | undefined;
let killRequested = false;
let timedOut = false;
let forceKillTimeout: NodeJS.Timeout | undefined;
let forceSettleTimeout: NodeJS.Timeout | undefined;
@@ -187,6 +188,13 @@ export function startMatrixQaOpenClawCli(params: {
const finishTimeout = (result: MatrixQaCliRunResult) => {
finish(result, new Error(formatMatrixQaCliTimeoutError(result, params.timeoutMs)));
};
const finishResult = (result: MatrixQaCliRunResult) => {
if (result.exitCode !== 0 && params.allowNonZero !== true) {
finish(result, new Error(formatMatrixQaCliExitError(result)));
return;
}
finish(result);
};
const clearForcedTimeouts = () => {
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
@@ -197,16 +205,23 @@ export function startMatrixQaOpenClawCli(params: {
forceSettleTimeout = undefined;
}
};
const timeout = setTimeout(() => {
timedOut = true;
killMatrixQaCliChild(child, "SIGTERM");
const finishForcedCleanup = (result: MatrixQaCliRunResult) => {
if (timedOut) {
finishTimeout(result);
return;
}
finishResult(result);
};
const scheduleForcedCleanup = () => {
if (forceKillTimeout || forceSettleTimeout) {
return;
}
forceKillTimeout = setTimeout(() => {
forceKillTimeout = undefined;
killMatrixQaCliChild(child, "SIGKILL");
forceSettleTimeout = setTimeout(() => {
forceSettleTimeout = undefined;
finishTimeout(
finishForcedCleanup(
buildMatrixQaCliResult({
args: params.args,
exitCode: 1,
@@ -215,6 +230,12 @@ export function startMatrixQaOpenClawCli(params: {
);
}, MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS);
}, MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS);
};
const timeout = setTimeout(() => {
timedOut = true;
killMatrixQaCliChild(child, "SIGTERM");
scheduleForcedCleanup();
}, params.timeoutMs);
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
@@ -241,20 +262,17 @@ export function startMatrixQaOpenClawCli(params: {
exitCode: exitCode ?? 1,
output: readOutput(),
});
if (timedOut) {
if (timedOut || killRequested) {
// A closed parent is not proof that detached, ignored-stdio descendants are gone.
if (isMatrixQaCliChildProcessGroupRunning(child)) {
return;
}
clearForcedTimeouts();
finishTimeout(result);
finishForcedCleanup(result);
return;
}
clearForcedTimeouts();
if (result.exitCode !== 0 && params.allowNonZero !== true) {
finish(result, new Error(formatMatrixQaCliExitError(result)));
return;
}
finish(result);
finishResult(result);
});
return {
@@ -310,7 +328,10 @@ export function startMatrixQaOpenClawCli(params: {
},
kill: () => {
if (!closed) {
clearTimeout(timeout);
killRequested = true;
killMatrixQaCliChild(child, "SIGTERM");
scheduleForcedCleanup();
}
},
};