From cba1ccce6df2e2f360c6841720fdc174f2dc96db Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 12 Mar 2026 14:41:33 -0700 Subject: [PATCH 1/3] Add killTree to recursively kill child processes --- src/run.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/run.ts b/src/run.ts index a66968d..d0f9beb 100644 --- a/src/run.ts +++ b/src/run.ts @@ -28,6 +28,32 @@ type RunOptions = { onCommand?: (cmd: Command) => void } +function getDescendants(pid: number): number[] { + try { + const result = Bun.spawnSync(["ps", "-o", "pid=", "--ppid", String(pid)]) + const output = new TextDecoder().decode(result.stdout) + const children = output.trim().split("\n").filter(Boolean).map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)) + const all: number[] = [] + for (const child of children) { + all.push(...getDescendants(child)) + all.push(child) + } + return all + } catch { + return [] + } +} + +function killTree(pid: number): void { + const descendants = getDescendants(pid) + // Kill process group first + try { process.kill(-pid, "SIGKILL") } catch {} + // Then kill any descendants that escaped the process group + for (const d of descendants) { + try { process.kill(d, "SIGKILL") } catch {} + } +} + const SENTINEL_PREFIX = "__SHOUT_SENTINEL_" function buildScript(commands: Command[], sentinel: string): string { @@ -190,7 +216,7 @@ export async function runFile( } } finally { if (proc.pid) { - try { process.kill(-proc.pid, "SIGKILL") } catch {} + killTree(proc.pid) } } } From 9b739fd8e4d0f61ed2675f51f10398b7c534c648 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 12 Mar 2026 14:47:13 -0700 Subject: [PATCH 2/3] Simplify killTree with single ps call --- src/run.ts | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/run.ts b/src/run.ts index d0f9beb..b5fbcc6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -28,30 +28,27 @@ type RunOptions = { onCommand?: (cmd: Command) => void } -function getDescendants(pid: number): number[] { - try { - const result = Bun.spawnSync(["ps", "-o", "pid=", "--ppid", String(pid)]) - const output = new TextDecoder().decode(result.stdout) - const children = output.trim().split("\n").filter(Boolean).map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)) - const all: number[] = [] - for (const child of children) { - all.push(...getDescendants(child)) - all.push(child) - } - return all - } catch { - return [] - } -} - function killTree(pid: number): void { - const descendants = getDescendants(pid) - // Kill process group first + // Find any processes that escaped the process group (e.g. via setsid) + // using a single cross-platform ps call, then kill them before the group. + try { + const result = Bun.spawnSync(["ps", "-eo", "pid,pgid"]) + const output = result.stdout.toString() + const pgid = String(pid) + const escapees: number[] = [] + for (const line of output.split("\n")) { + const parts = line.trim().split(/\s+/) + if (parts[1] === pgid) { + const p = parseInt(parts[0]!, 10) + if (!isNaN(p) && p !== pid) escapees.push(p) + } + } + for (const p of escapees) { + try { process.kill(p, "SIGKILL") } catch {} + } + } catch {} + // Kill the process group try { process.kill(-pid, "SIGKILL") } catch {} - // Then kill any descendants that escaped the process group - for (const d of descendants) { - try { process.kill(d, "SIGKILL") } catch {} - } } const SENTINEL_PREFIX = "__SHOUT_SENTINEL_" From 9d13dfa5e75c705f4c0cf9f24b8fc7b8b1c4abad Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 12 Mar 2026 15:14:21 -0700 Subject: [PATCH 3/3] kill escaped procs inline, guard pid > 1 --- src/run.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/run.ts b/src/run.ts index b5fbcc6..666cd78 100644 --- a/src/run.ts +++ b/src/run.ts @@ -29,23 +29,22 @@ type RunOptions = { } function killTree(pid: number): void { - // Find any processes that escaped the process group (e.g. via setsid) - // using a single cross-platform ps call, then kill them before the group. + // Find any processes that escaped the process group (e.g. via setsid). + // This assumes pid === pgid, which holds because the child is spawned + // with detached: true (making it a process group leader). try { const result = Bun.spawnSync(["ps", "-eo", "pid,pgid"]) const output = result.stdout.toString() const pgid = String(pid) - const escapees: number[] = [] for (const line of output.split("\n")) { const parts = line.trim().split(/\s+/) if (parts[1] === pgid) { const p = parseInt(parts[0]!, 10) - if (!isNaN(p) && p !== pid) escapees.push(p) + if (!isNaN(p) && p !== pid && p > 1) { + try { process.kill(p, "SIGKILL") } catch {} + } } } - for (const p of escapees) { - try { process.kill(p, "SIGKILL") } catch {} - } } catch {} // Kill the process group try { process.kill(-pid, "SIGKILL") } catch {}