import { spawnSync } from "node:child_process"; import type { ProcessTreeTerminalV1 } from "../../shared/types.ts"; const DEFAULT_TERM_GRACE_MS = 3000; const DEFAULT_KILL_VERIFY_MS = 1000; const VERIFY_INTERVAL_MS = 25; type SignalResult = "sent" | "absent" | { diagnostic: string }; function diagnostic(error: unknown): string { return error instanceof Error ? error.message : String(error); } function signalProcess(id: number, signal: NodeJS.Signals): SignalResult { try { process.kill(id, signal); return "sent"; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return "absent"; return { diagnostic: diagnostic(error) }; } } function activeProcessGroupMembers(processGroupId: number): number[] | { diagnostic: string } { const result = spawnSync("ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf-8" }); if (result.error || result.status !== 0) { return { diagnostic: result.error ? diagnostic(result.error) : (result.stderr.trim() || `ps exited with ${result.status}`) }; } const members: number[] = []; for (const line of result.stdout.split("\n")) { const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line); if (!match || Number(match[2]) !== processGroupId || match[3]!.startsWith("Z")) continue; members.push(Number(match[1])); } return members; } async function waitUntilGroupTerminal( processGroupId: number, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; while (true) { const members = activeProcessGroupMembers(processGroupId); if (Array.isArray(members) && members.length === 0) return false; const remaining = deadline - Date.now(); if (remaining <= 0) { if (!Array.isArray(members)) return { state: "enumeration-failed", diagnostic: members.diagnostic }; return { state: "still-active", diagnostic: `Process group ${processGroupId} still has active members: ${members.join(", ")}.` }; } await new Promise((resolve) => setTimeout(resolve, Math.min(VERIFY_INTERVAL_MS, remaining))); } } function observed(processGroupId: number): ProcessTreeTerminalV1 { return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() }; } /** Owns one writer process group and arbitrates its cleanup exactly once. */ export interface OwnedProcessTreeController { terminate(): Promise; finishAfterWriterClose(): Promise; } export function createOwnedProcessTreeController( pid: number, options: { termGraceMs?: number; killVerifyMs?: number } = {}, ): OwnedProcessTreeController { let termination: Promise | undefined; const posixGroupOwned = process.platform !== "win32"; const target = posixGroupOwned ? -pid : pid; const terminate = (): Promise => { if (termination) return termination; termination = (async () => { if (!posixGroupOwned) { signalProcess(target, "SIGTERM"); return { state: "unknown", reason: "unsupported-platform" }; } const term = signalProcess(target, "SIGTERM"); if (term !== "sent" && term !== "absent") { return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic }; } const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS); if (termExit === false) return observed(pid); const kill = signalProcess(target, "SIGKILL"); if (kill !== "sent" && kill !== "absent") { const members = activeProcessGroupMembers(pid); if (!Array.isArray(members) || members.length > 0) { return { state: "unknown", reason: "signal-failed", diagnostic: kill.diagnostic }; } } const killExit = await waitUntilGroupTerminal(pid, options.killVerifyMs ?? DEFAULT_KILL_VERIFY_MS); if (killExit !== false) { return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic }; } return observed(pid); })(); return termination; }; return { terminate, finishAfterWriterClose: terminate }; }