fix(prompt-probe): clean direct prompt child trees

This commit is contained in:
Vincent Koc
2026-06-20 14:20:09 +02:00
parent 5384b91866
commit 0cd6975352
2 changed files with 144 additions and 7 deletions

View File

@@ -474,21 +474,45 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
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<typeof setTimeout> | 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 {

View File

@@ -46,6 +46,31 @@ function isProcessAlive(pid: number): boolean {
}
}
async function writeFakePromptCli(root: string, descendantPidPath: string): Promise<string> {
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<typeof spawn>,
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[] = [];