Skip to content

Commit ff39b29

Browse files
committed
Fail teardown when shell children survive reap
A leftover after the two-second backstop must reject dispose so the exit 1 path can fire. Abort already SIGKILLs the process group at abort start.
1 parent 4f45001 commit ff39b29

2 files changed

Lines changed: 39 additions & 10 deletions

File tree

‎src/plugins/shell-guard-plugin.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import { realpathSync } from "node:fs";
66
import type { ToolCall, ToolResult } from "@intx/types/runtime";
7-
8-
import { spawnSync } from "node:child_process";
7+
import { EventEmitter } from "node:events";
8+
import { spawnSync, type ChildProcess } from "node:child_process";
99
import { randomUUID } from "node:crypto";
1010

1111
import { createBackgroundShellRegistry } from "../shell/background-shell.js";
@@ -14,6 +14,7 @@ import {
1414
MAX_SHELL_OUTPUT_BYTES,
1515
advertiseShellGuardTimeout,
1616
resolveShellTimeoutMs,
17+
reapLiveChildren,
1718
runGuardedShell,
1819
shellGuardPlugin,
1920
} from "./shell-guard-plugin.js";
@@ -780,4 +781,15 @@ describe("shellGuardPlugin", () => {
780781
spawnSync("pkill", ["-9", "-f", token]);
781782
}
782783
});
784+
785+
test("dispose fails when a child survives the reap window", async () => {
786+
const child = Object.assign(new EventEmitter(), {
787+
exitCode: null,
788+
signalCode: null,
789+
kill: () => true,
790+
}) as ChildProcess;
791+
await expect(reapLiveChildren(new Set([child]))).rejects.toThrow(
792+
/still live after 2000ms reap/,
793+
);
794+
}, 10_000);
783795
});

‎src/plugins/shell-guard-plugin.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,17 +217,34 @@ function waitChildClose(child: ChildProcess): Promise<void> {
217217

218218
const SHELL_GUARD_DISPOSE_REAP_MS = 2_000;
219219

220-
async function reapLiveChildren(liveChildren: Set<ChildProcess>): Promise<void> {
220+
function childStillLive(child: ChildProcess): boolean {
221+
return child.exitCode === null && child.signalCode === null;
222+
}
223+
224+
// Abort SIGKILLs the process group immediately (runGuardedShell onAbort). This
225+
// window is only a backstop for children still tracked at dispose. Leftovers
226+
// after it must fail teardown; do not stretch the process-exit 2s deadline.
227+
export async function reapLiveChildren(liveChildren: Set<ChildProcess>): Promise<void> {
221228
const remaining = [...liveChildren];
222229
for (const child of remaining) killProcessTree(child);
223230
if (remaining.length === 0) return;
224-
await Promise.race([
225-
Promise.all(remaining.map(waitChildClose)),
226-
new Promise<void>((resolve) => {
227-
const timer = setTimeout(resolve, SHELL_GUARD_DISPOSE_REAP_MS);
228-
if (typeof timer.unref === "function") timer.unref();
229-
}),
230-
]);
231+
const closed = Promise.all(remaining.map(waitChildClose));
232+
let timer: ReturnType<typeof setTimeout> | undefined;
233+
const timedOut = new Promise<"timeout">((resolve) => {
234+
timer = setTimeout(() => resolve("timeout"), SHELL_GUARD_DISPOSE_REAP_MS);
235+
});
236+
try {
237+
const winner = await Promise.race([closed.then(() => "closed" as const), timedOut]);
238+
if (winner === "closed") return;
239+
const stillLive = remaining.filter(childStillLive);
240+
if (stillLive.length > 0) {
241+
throw new Error(
242+
`${stillLive.length} shell child process${stillLive.length === 1 ? "" : "es"} still live after ${SHELL_GUARD_DISPOSE_REAP_MS}ms reap`,
243+
);
244+
}
245+
} finally {
246+
if (timer !== undefined) clearTimeout(timer);
247+
}
231248
}
232249
export async function runGuardedShell(
233250
args: RunShellArgs,

0 commit comments

Comments
 (0)