diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts index 38e89d967b7d..7bc8a27882fd 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts @@ -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 }); + } + }); }); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts index 929078e2ca21..d6b9e9978d2f 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -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(); } }, };