import type { ChildProcess } from "node:child_process"; import { logInternalError } from "../../utils/internal-error.ts"; interface PostExitStdioGuardOptions { idleMs: number; hardMs: number; } export interface ChildWithPipedStdio { stdout: ChildProcess["stdout"]; stderr: ChildProcess["stderr"]; on: ChildProcess["on"]; /** Set by Node after the child exits. Used to detect a post-exit attach. */ exitCode?: number | null; signalCode?: NodeJS.Signals | null; } export interface ChildWithKill { kill(signal?: NodeJS.Signals | number): boolean; } export function trySignalChild(child: ChildWithKill, signal: NodeJS.Signals): boolean { try { return child.kill(signal); } catch { return false; } } export function attachPostExitStdioGuard(child: ChildWithPipedStdio, options: PostExitStdioGuardOptions): () => void { const { idleMs, hardMs } = options; let exited = false; let stdoutEnded = false; let stderrEnded = false; let idleTimer: ReturnType | undefined; let hardTimer: ReturnType | undefined; const destroyUnendedStdio = (): void => { if (!stdoutEnded) { try { child.stdout?.destroy(); } catch (error) { logInternalError("post-exit-stdio-guard.stdout-destroy", error, undefined, "debug"); } } if (!stderrEnded) { try { child.stderr?.destroy(); } catch (error) { logInternalError("post-exit-stdio-guard.stderr-destroy", error, undefined, "debug"); } } }; const clearTimers = (): void => { if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined; } if (hardTimer) { clearTimeout(hardTimer); hardTimer = undefined; } }; const armIdleTimer = () => { if (!exited) return; if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(destroyUnendedStdio, idleMs); idleTimer.unref(); }; child.stdout?.on("data", armIdleTimer); child.stderr?.on("data", armIdleTimer); child.stdout?.on("end", () => { stdoutEnded = true; if (stdoutEnded && stderrEnded) clearTimers(); }); child.stderr?.on("end", () => { stderrEnded = true; if (stdoutEnded && stderrEnded) clearTimers(); }); const armHardTimer = (): void => { if (hardTimer) return; hardTimer = setTimeout(destroyUnendedStdio, hardMs); hardTimer.unref(); }; const onExit = (): void => { exited = true; armIdleTimer(); armHardTimer(); }; // The guard is normally attached from INSIDE the child's 'exit' handler // (child-pi.ts), i.e. AFTER the child has already exited. A listener // registered with child.on('exit') during the in-flight 'exit' dispatch // will NOT fire (Node clones the listener array before iterating), so // relying on it alone makes this guard a no-op and can hang the run if a // descendant process holds the stdio pipes open. When a prior exit is // detectable (exitCode/signalCode already set by Node), arm immediately. if (child.exitCode != null || child.signalCode != null) { onExit(); } child.on("exit", onExit); child.on("close", clearTimers); child.on("error", clearTimers); return clearTimers; }