From 0cd69753529ca8ded7d1bf9e9487dfd26d5abbc4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 20 Jun 2026 14:20:09 +0200 Subject: [PATCH] fix(prompt-probe): clean direct prompt child trees --- scripts/anthropic-prompt-probe.ts | 38 ++++++-- test/scripts/dev-tooling-safety.test.ts | 113 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/scripts/anthropic-prompt-probe.ts b/scripts/anthropic-prompt-probe.ts index 05500d27cfc..e51bde33471 100644 --- a/scripts/anthropic-prompt-probe.ts +++ b/scripts/anthropic-prompt-probe.ts @@ -474,21 +474,45 @@ async function runDirectPrompt(prompt: string): Promise { ANTHROPIC_API_KEY: "", ANTHROPIC_API_KEY_OLD: "", }, + detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); child.stdout.on("data", (chunk) => stdout.push(String(chunk))); child.stderr.on("data", (chunk) => stderr.push(String(chunk))); - const exit = await withTimeout( - new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { child.once("error", reject); child.once("exit", (code, signal) => resolve({ code, signal })); - }), - TIMEOUT_MS, - () => { - child.kill("SIGKILL"); - return { code: null, signal: "SIGKILL" as NodeJS.Signals }; }, ); + const stopDirectChild = async (signal: NodeJS.Signals = "SIGKILL") => { + signalGatewayPromptChildTree(child, signal); + await waitForGatewayPromptChildTreeExit( + child, + exitPromise.then(() => undefined), + 1_500, + ); + }; + const removeParentSignalHandlers = installGatewayPromptParentSignalHandlers( + child, + stopDirectChild, + ); + let timeoutTimer: ReturnType | undefined; + const exit = await Promise.race([ + exitPromise, + new Promise<{ code: null; signal: NodeJS.Signals }>((resolve) => { + timeoutTimer = setTimeout(() => { + void stopDirectChild("SIGKILL").finally(() => { + resolve({ code: null, signal: "SIGKILL" }); + }); + }, TIMEOUT_MS); + }), + ]).finally(() => { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + } + removeParentSignalHandlers(); + }); const joinedStdout = stdout.join(""); const joinedStderr = stderr.join(""); return { diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 72b2550aca4..3054e34dea8 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -46,6 +46,31 @@ function isProcessAlive(pid: number): boolean { } } +async function writeFakePromptCli(root: string, descendantPidPath: string): Promise { + const fakeCli = path.join(root, "fake-prompt-cli.mjs"); + const descendantScript = [ + "process.on('SIGINT', () => {});", + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + ].join(""); + await fs.writeFile( + fakeCli, + [ + "#!/usr/bin/env node", + "import childProcess from 'node:child_process';", + "import fs from 'node:fs';", + "const descendant = childProcess.spawn(process.execPath, [", + " '--input-type=module',", + ` '--eval', ${JSON.stringify(descendantScript)},`, + "], { stdio: 'ignore' });", + `fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`, + "setInterval(() => {}, 1000);", + ].join("\n"), + { mode: 0o755 }, + ); + return fakeCli; +} + async function waitForChildExit( child: ReturnType, timeoutMs = 8_000, @@ -646,6 +671,94 @@ describe("script-specific dev tooling hardening", () => { await fs.rm(keepRoot, { force: true, recursive: true }); }); + it.runIf(process.platform !== "win32")( + "cleans Anthropic direct prompt descendants after timeout", + async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-tree-")); + tempDirs.push(tempRoot); + const descendantPidPath = path.join(tempRoot, "descendant.pid"); + let descendantPid = 0; + const fakeClaudeBin = await writeFakePromptCli(tempRoot, descendantPidPath); + const probe = spawn( + process.execPath, + ["--import", "tsx", "scripts/anthropic-prompt-probe.ts"], + { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_BIN: fakeClaudeBin, + OPENCLAW_PROMPT_TEXT: "timeout cleanup proof", + OPENCLAW_PROMPT_TIMEOUT_MS: "1000", + OPENCLAW_PROMPT_TRANSPORT: "direct", + }, + stdio: "ignore", + }, + ); + + try { + await waitForCondition(() => existsSync(descendantPidPath)); + descendantPid = Number.parseInt(await fs.readFile(descendantPidPath, "utf8"), 10); + expect(Number.isInteger(descendantPid)).toBe(true); + expect(isProcessAlive(descendantPid)).toBe(true); + + await expect(waitForChildExit(probe)).resolves.toEqual({ status: 0, signal: null }); + await waitForCondition(() => !isProcessAlive(descendantPid)); + } finally { + if (probe.pid && isProcessAlive(probe.pid)) { + process.kill(probe.pid, "SIGKILL"); + } + if (descendantPid && isProcessAlive(descendantPid)) { + process.kill(descendantPid, "SIGKILL"); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "cleans Anthropic direct prompt descendants on parent signal", + async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-parent-signal-")); + tempDirs.push(tempRoot); + const descendantPidPath = path.join(tempRoot, "descendant.pid"); + let descendantPid = 0; + const fakeClaudeBin = await writeFakePromptCli(tempRoot, descendantPidPath); + const probe = spawn( + process.execPath, + ["--import", "tsx", "scripts/anthropic-prompt-probe.ts"], + { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_BIN: fakeClaudeBin, + OPENCLAW_PROMPT_TEXT: "parent signal cleanup proof", + OPENCLAW_PROMPT_TIMEOUT_MS: "10000", + OPENCLAW_PROMPT_TRANSPORT: "direct", + }, + stdio: "ignore", + }, + ); + + try { + await waitForCondition(() => existsSync(descendantPidPath)); + descendantPid = Number.parseInt(await fs.readFile(descendantPidPath, "utf8"), 10); + expect(Number.isInteger(descendantPid)).toBe(true); + expect(isProcessAlive(descendantPid)).toBe(true); + + const probeExit = waitForChildExit(probe); + process.kill(probe.pid!, "SIGTERM"); + await expect(probeExit).resolves.toEqual({ status: 143, signal: null }); + await waitForCondition(() => !isProcessAlive(descendantPid)); + } finally { + if (probe.pid && isProcessAlive(probe.pid)) { + process.kill(probe.pid, "SIGKILL"); + } + if (descendantPid && isProcessAlive(descendantPid)) { + process.kill(descendantPid, "SIGKILL"); + } + } + }, + ); + it("waits for the Anthropic prompt gateway child after SIGKILL cleanup", async () => { const events = new EventEmitter(); const signals: NodeJS.Signals[] = [];