From 3bb93b16126e41a4873c0a18c3289e0f228ea1b5 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 18 Apr 2026 14:38:55 +0200 Subject: [PATCH] Cherry-pick process lifecycle fixes for multi-day autonomous operation - shell: add trackDetachedChildPid / untrackDetachedChildPid / killTrackedDetachedChildren (#9b7948c) - bash: track/untrack detached child PIDs so they are killed on shutdown - interactive-mode: register SIGTERM/SIGHUP handlers for clean shutdown (#5d440b0); kill tracked bash children on shutdown - rpc-mode: register SIGTERM/SIGHUP handlers, refactor to forceShutdown() that deduplicates shutdown path (#5d440b0); kill tracked bash children - print-mode: register SIGTERM/SIGHUP handlers for graceful exit Co-Authored-By: Claude Sonnet 4.6 --- .../pi-coding-agent/src/core/tools/bash.ts | 5 ++- .../src/modes/interactive/interactive-mode.ts | 24 ++++++++++++++ .../pi-coding-agent/src/modes/print-mode.ts | 26 +++++++++++++++- .../pi-coding-agent/src/modes/rpc/rpc-mode.ts | 31 ++++++++++++++++--- packages/pi-coding-agent/src/utils/shell.ts | 17 ++++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/pi-coding-agent/src/core/tools/bash.ts b/packages/pi-coding-agent/src/core/tools/bash.ts index 996aa46df..13d73fa83 100644 --- a/packages/pi-coding-agent/src/core/tools/bash.ts +++ b/packages/pi-coding-agent/src/core/tools/bash.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import type { AgentTool } from "@singularity-forge/pi-agent-core"; import { type Static, Type } from "@sinclair/typebox"; import { spawn } from "child_process"; -import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand } from "../../utils/shell.js"; +import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand, trackDetachedChildPid, untrackDetachedChildPid } from "../../utils/shell.js"; import { type BashInterceptorRule, compileInterceptor, DEFAULT_BASH_INTERCEPTOR_RULES } from "./bash-interceptor.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; import type { ArtifactManager } from "../artifact-manager.js"; @@ -168,6 +168,7 @@ const defaultBashOperations: BashOperations = { env: env ?? getShellEnv(), stdio: ["ignore", "pipe", "pipe"], }); + if (child.pid) trackDetachedChildPid(child.pid); let timedOut = false; @@ -192,6 +193,7 @@ const defaultBashOperations: BashOperations = { // Handle shell spawn errors child.on("error", (err) => { + if (child.pid) untrackDetachedChildPid(child.pid); if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); reject(err); @@ -214,6 +216,7 @@ const defaultBashOperations: BashOperations = { // Handle process exit child.on("close", (code) => { + if (child.pid) untrackDetachedChildPid(child.pid); restoreWindowsVTInput(); if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 540543144..c6bebc813 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -102,6 +102,7 @@ import { handleModelCommand as handleModelCommandController, updateAvailableProviderCount as updateAvailableProviderCountController, } from "./controllers/model-controller.js"; +import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -211,6 +212,8 @@ export class InteractiveMode { // Agent subscription unsubscribe function private unsubscribe?: () => void; + private signalCleanupHandlers: Array<() => void> = []; + // Branch change listener unsubscribe function private _branchChangeUnsub?: () => void; @@ -412,6 +415,8 @@ export class InteractiveMode { async init(): Promise { if (this.isInitialized) return; + this.registerSignalHandlers(); + // Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); @@ -2388,6 +2393,22 @@ export class InteractiveMode { */ private isShuttingDown = false; + private registerSignalHandlers(): void { + this.unregisterSignalHandlers(); + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") signals.push("SIGHUP"); + for (const signal of signals) { + const handler = () => { void this.shutdown(); }; + process.on(signal, handler); + this.signalCleanupHandlers.push(() => process.off(signal, handler)); + } + } + + private unregisterSignalHandlers(): void { + for (const cleanup of this.signalCleanupHandlers) cleanup(); + this.signalCleanupHandlers = []; + } + private async shutdown(): Promise { const shutdownBehavior = this.options.shutdownBehavior ?? "exit_process"; if (shutdownBehavior === "ignore") { @@ -2397,6 +2418,8 @@ export class InteractiveMode { if (this.isShuttingDown) return; this.isShuttingDown = true; + this.unregisterSignalHandlers(); + killTrackedDetachedChildren(); // Flush any queued settings writes before shutdown await this.settingsManager.flush(); @@ -3988,6 +4011,7 @@ export class InteractiveMode { } stop(): void { + this.unregisterSignalHandlers(); if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; diff --git a/packages/pi-coding-agent/src/modes/print-mode.ts b/packages/pi-coding-agent/src/modes/print-mode.ts index 60a2937e4..41cc40e39 100644 --- a/packages/pi-coding-agent/src/modes/print-mode.ts +++ b/packages/pi-coding-agent/src/modes/print-mode.ts @@ -53,6 +53,29 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti }); let exitCode = 0; + let disposed = false; + const signalCleanupHandlers: Array<() => void> = []; + + const disposeSession = (): void => { + if (disposed) return; + disposed = true; + unsubscribe(); + }; + + const registerSignalHandlers = (): void => { + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") signals.push("SIGHUP"); + for (const signal of signals) { + const handler = () => { + disposeSession(); + process.exit(signal === "SIGHUP" ? 129 : 143); + }; + process.on(signal, handler); + signalCleanupHandlers.push(() => process.off(signal, handler)); + } + }; + + registerSignalHandlers(); try { // Send initial message with attachments @@ -97,7 +120,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti }); }); } finally { - unsubscribe(); + for (const cleanup of signalCleanupHandlers) cleanup(); + disposeSession(); } if (exitCode !== 0) { diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts index 0b544ac4e..f4a8f2160 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts @@ -13,6 +13,7 @@ import * as crypto from "node:crypto"; import type { AgentSession } from "../../core/agent-session.js"; +import { killTrackedDetachedChildren } from "../../utils/shell.js"; import type { ExtensionUIContext, ExtensionUIDialogOptions, @@ -84,6 +85,8 @@ export async function runRpcMode(session: AgentSession): Promise { // Shutdown request flag let shutdownRequested = false; + let shuttingDown = false; + const signalCleanupHandlers: Array<() => void> = []; // v2 protocol version detection state let protocolVersion: 1 | 2 = 1; @@ -822,19 +825,35 @@ export async function runRpcMode(session: AgentSession): Promise { */ let detachInput = () => {}; - async function checkShutdownRequested(): Promise { - if (!shutdownRequested) return; - + async function forceShutdown(exitCode = 0): Promise { + if (shuttingDown) process.exit(exitCode); + shuttingDown = true; + killTrackedDetachedChildren(); + for (const cleanup of signalCleanupHandlers) cleanup(); const currentRunner = session.extensionRunner; if (currentRunner?.hasHandlers("session_shutdown")) { await currentRunner.emit({ type: "session_shutdown" }); } - unsubscribe(); embeddedInteractiveMode?.stop(); detachInput(); process.stdin.pause(); - process.exit(0); + process.exit(exitCode); + } + + const registerSignalHandlers = (): void => { + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") signals.push("SIGHUP"); + for (const signal of signals) { + const handler = () => { void forceShutdown(signal === "SIGHUP" ? 129 : 143); }; + process.on(signal, handler); + signalCleanupHandlers.push(() => process.off(signal, handler)); + } + }; + + async function checkShutdownRequested(): Promise { + if (!shutdownRequested) return; + await forceShutdown(0); } const handleInputLine = async (line: string) => { @@ -889,6 +908,8 @@ export async function runRpcMode(session: AgentSession): Promise { } }; + registerSignalHandlers(); + detachInput = attachJsonlLineReader(process.stdin, (line) => { void handleInputLine(line); }); diff --git a/packages/pi-coding-agent/src/utils/shell.ts b/packages/pi-coding-agent/src/utils/shell.ts index 86708125f..78c8b6a9d 100644 --- a/packages/pi-coding-agent/src/utils/shell.ts +++ b/packages/pi-coding-agent/src/utils/shell.ts @@ -183,6 +183,23 @@ export function sanitizeBinaryOutput(str: string): string { .join(""); } +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) */