From 9b7948c4c82da0ed2021a9c735cdbfbab8d7d6f2 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Apr 2026 00:12:14 +0200 Subject: [PATCH] fix(coding-agent): kill tracked detached bash children on shutdown --- packages/coding-agent/src/core/tools/bash.ts | 11 +++++++++- .../src/modes/interactive/interactive-mode.ts | 2 ++ packages/coding-agent/src/modes/print-mode.ts | 2 ++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 ++ packages/coding-agent/src/utils/shell.ts | 21 +++++++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 9512c132..0a5e0a58 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -10,7 +10,13 @@ import { keyHint } from "../../modes/interactive/components/keybinding-hints.js" import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js"; import { theme } from "../../modes/interactive/theme/theme.js"; import { waitForChildProcess } from "../../utils/child-process.js"; -import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js"; +import { + getShellConfig, + getShellEnv, + killProcessTree, + trackDetachedChildPid, + untrackDetachedChildPid, +} from "../../utils/shell.js"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; import { getTextOutput, invalidArgText, str } from "./render-utils.js"; import { wrapToolDefinition } from "./tool-definition-wrapper.js"; @@ -81,6 +87,7 @@ export function createLocalBashOperations(): BashOperations { env: env ?? getShellEnv(), stdio: ["ignore", "pipe", "pipe"], }); + if (child.pid) trackDetachedChildPid(child.pid); let timedOut = false; let timeoutHandle: NodeJS.Timeout | undefined; // Set timeout if provided. @@ -105,6 +112,7 @@ export function createLocalBashOperations(): BashOperations { // on inherited stdio handles held by detached descendants. waitForChildProcess(child) .then((code) => { + if (child.pid) untrackDetachedChildPid(child.pid); if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); if (signal?.aborted) { @@ -118,6 +126,7 @@ export function createLocalBashOperations(): BashOperations { resolve({ exitCode: code }); }) .catch((err) => { + if (child.pid) untrackDetachedChildPid(child.pid); if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); reject(err); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b15b3c16..760ea3fa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -70,6 +70,7 @@ import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/cha import { copyToClipboard } from "../../utils/clipboard.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; import { parseGitUrl } from "../../utils/git.js"; +import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { ensureTool } from "../../utils/tools-manager.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; @@ -2941,6 +2942,7 @@ export class InteractiveMode { for (const signal of signals) { const handler = () => { + killTrackedDetachedChildren(); void this.shutdown(); }; process.on(signal, handler); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index ae69dfc0..267d6846 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -9,6 +9,7 @@ import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai"; import type { AgentSessionRuntime } from "../core/agent-session-runtime.js"; import { flushRawStdout, writeRawStdout } from "../core/output-guard.js"; +import { killTrackedDetachedChildren } from "../utils/shell.js"; /** * Options for print mode. @@ -51,6 +52,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr for (const signal of signals) { const handler = () => { + killTrackedDetachedChildren(); void disposeRuntime().finally(() => { process.exit(signal === "SIGHUP" ? 129 : 143); }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 0f17515b..32e61b00 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -19,6 +19,7 @@ import type { ExtensionWidgetOptions, } from "../../core/extensions/index.js"; import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js"; +import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { type Theme, theme } from "../interactive/theme/theme.js"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; import type { @@ -346,6 +347,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + killTrackedDetachedChildren(); void shutdown(signal === "SIGHUP" ? 129 : 143); }; process.on(signal, handler); diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 8f43b0b5..9cd54597 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -172,6 +172,27 @@ export function sanitizeBinaryOutput(str: string): string { .join(""); } +/** + * Detached child processes must be tracked so they can be killed on parent + * shutdown signals (SIGHUP/SIGTERM). + */ +const trackedDetachedChildPids = new Set(); + +export function trackDetachedChildPid(pid: number): void { + trackedDetachedChildPids.add(pid); +} + +export function untrackDetachedChildPid(pid: number): void { + trackedDetachedChildPids.delete(pid); +} + +export function killTrackedDetachedChildren(): void { + for (const pid of trackedDetachedChildPids) { + killProcessTree(pid); + } + trackedDetachedChildPids.clear(); +} + /** * Kill a process and all its children (cross-platform) */