import { closeSync, fsyncSync, openSync, readFileSync, rmSync, writeSync } from "node:fs"; import { win32 } from "node:path"; import { spawnSyncHidden } from "../utils/child-process.js"; import { getProcessStartId } from "./session-lease.js"; export const ORPHAN_PROCESS_JOURNAL_ENV = "PRIME_AGENT_INTERNAL_ORPHAN_PROCESS_JOURNAL"; interface OrphanProcessRecord { version: 1; pid: number; ownerPid: number; /** Set on records written by a kernel (e.g. bash() children) so the host can reap per kernel. */ kernelPid?: number; processStartId?: string; active: boolean; recordedAt: string; } export interface ActiveOrphanProcess { pid: number; kernelPid?: number; /** Missing on identity-free records: old journals or host writes whose start-id query failed (kernels no longer write pid-only records). */ processStartId?: string; } export function recordOrphanProcessState(pid: number, active: boolean): void { const path = process.env[ORPHAN_PROCESS_JOURNAL_ENV]; if (!path || !Number.isInteger(pid) || pid <= 0) { return; } const processStartId = active ? getProcessStartId(pid) : undefined; const record: OrphanProcessRecord = { version: 1, pid, ownerPid: process.pid, ...(processStartId ? { processStartId } : {}), active, recordedAt: new Date().toISOString(), }; try { const descriptor = openSync(path, "a", 0o600); try { writeSync(descriptor, `${JSON.stringify(record)}\n`); fsyncSync(descriptor); } finally { closeSync(descriptor); } } catch { // Process tracking must not make a successfully spawned command fail. } } export function readActiveOrphanProcesses(path: string, ownerPid: number): ActiveOrphanProcess[] { let contents: string; try { contents = readFileSync(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; } throw error; } const latest = new Map(); for (const line of contents.split("\n")) { if (!line) { continue; } try { const record = JSON.parse(line) as Partial; if ( record.version === 1 && Number.isInteger(record.pid) && (record.pid ?? 0) > 0 && record.ownerPid === ownerPid && typeof record.active === "boolean" && typeof record.recordedAt === "string" ) { latest.set(record.pid!, record as OrphanProcessRecord); } } catch { // A crash can truncate only the final append. } } // Pid-only actives (no processStartId) still surface from old journals or // host writes whose start-id query failed; reapers decide per-platform. return [...latest.values()] .filter( (record) => record.active && (record.processStartId === undefined || typeof record.processStartId === "string"), ) .map((record) => ({ pid: record.pid, ...(Number.isInteger(record.kernelPid) ? { kernelPid: record.kernelPid } : {}), ...(typeof record.processStartId === "string" ? { processStartId: record.processStartId } : {}), })); } export function isOrphanProcessIdentityCurrent(orphan: ActiveOrphanProcess): boolean { // Pid-only records can never claim identity (undefined === undefined must not match). return orphan.processStartId !== undefined && getProcessStartId(orphan.pid) === orphan.processStartId; } /** * Identity-free records cannot prove the pid still names the journaled process. * On win32 the kernel's kill-on-close job already reaped its tree when it died, * so a bare-pid taskkill only risks killing a reused pid. POSIX keeps the * best-effort kill (group-scoped, and the spawn gate makes pid-only actives * host-written rarities there). */ export function shouldReapOrphanProcess(orphan: ActiveOrphanProcess): boolean { if (orphan.processStartId === undefined) { return process.platform !== "win32"; } return isOrphanProcessIdentityCurrent(orphan); } export function clearOrphanProcessJournal(path: string): void { rmSync(path, { force: true }); } // Kills still-active bash() children journaled by the given kernel pid; sibling kernels' records are untouched. export function reapKernelOrphanProcesses(kernelPid: number): void { const path = process.env[ORPHAN_PROCESS_JOURNAL_ENV]; if (!path || !Number.isInteger(kernelPid) || kernelPid <= 0) { return; } let orphans: ActiveOrphanProcess[]; try { orphans = readActiveOrphanProcesses(path, process.pid); } catch { return; } for (const orphan of orphans) { if (orphan.kernelPid !== kernelPid || orphan.pid === kernelPid) { continue; } if (!shouldReapOrphanProcess(orphan)) { continue; } // Inactive only after a delivered signal; a stale record is neutralized by the startId check. if (killOrphanProcess(orphan.pid)) { recordOrphanProcessState(orphan.pid, false); } } } // Hardened cross-platform tree kill for journaled orphans: absolute System32 // taskkill /T on win32 (a bare name could resolve a planted CWD taskkill.exe), // process-group then pid SIGKILL elsewhere. export function killOrphanProcess(pid: number): boolean { if (process.platform === "win32") { // In-kernel bash() kill paths use taskkill /T; the reaper must kill the same tree, not just the shell pid. const result = spawnSyncHidden( win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", timeout: 10_000, env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" }, }, ); return result.status === 0; } try { process.kill(-pid, "SIGKILL"); return true; } catch { try { process.kill(pid, "SIGKILL"); return true; } catch { // The orphan may already have exited. } } return false; }