import { ChildProcess } from 'child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import path from 'path'; import { logger } from '../utils/logger.js'; const REAP_SESSION_SIGTERM_TIMEOUT_MS = 5_000; const REAP_SESSION_SIGKILL_TIMEOUT_MS = 1_000; const DATA_DIR = path.join(homedir(), '.claude-mem'); const DEFAULT_REGISTRY_PATH = path.join(DATA_DIR, 'supervisor.json'); export interface ManagedProcessInfo { pid: number; type: string; sessionId?: string | number; startedAt: string; } export interface ManagedProcessRecord extends ManagedProcessInfo { id: string; } interface PersistedRegistry { processes: Record; } export function isPidAlive(pid: number): boolean { if (!Number.isInteger(pid) || pid < 0) return false; if (pid === 0) return false; try { process.kill(pid, 0); return true; } catch (error: unknown) { if (error instanceof Error) { const code = (error as NodeJS.ErrnoException).code; if (code === 'EPERM') return true; logger.debug('SYSTEM', 'PID check failed', { pid, code }); return false; } logger.warn('SYSTEM', 'PID check threw non-Error', { pid, error: String(error) }); return false; } } export class ProcessRegistry { private readonly registryPath: string; private readonly entries = new Map(); private readonly runtimeProcesses = new Map(); private initialized = false; constructor(registryPath: string = DEFAULT_REGISTRY_PATH) { this.registryPath = registryPath; } initialize(): void { if (this.initialized) return; this.initialized = true; mkdirSync(path.dirname(this.registryPath), { recursive: true }); if (!existsSync(this.registryPath)) { this.persist(); return; } try { const raw = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as PersistedRegistry; const processes = raw.processes ?? {}; for (const [id, info] of Object.entries(processes)) { this.entries.set(id, info); } } catch (error: unknown) { if (error instanceof Error) { logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', { path: this.registryPath }, error); } else { logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', { path: this.registryPath, error: String(error) }); } this.entries.clear(); } const removed = this.pruneDeadEntries(); if (removed > 0) { logger.info('SYSTEM', 'Removed dead processes from supervisor registry', { removed }); } this.persist(); } register(id: string, processInfo: ManagedProcessInfo, processRef?: ChildProcess): void { this.initialize(); this.entries.set(id, processInfo); if (processRef) { this.runtimeProcesses.set(id, processRef); } this.persist(); } unregister(id: string): void { this.initialize(); this.entries.delete(id); this.runtimeProcesses.delete(id); this.persist(); } clear(): void { this.entries.clear(); this.runtimeProcesses.clear(); this.persist(); } getAll(): ManagedProcessRecord[] { this.initialize(); return Array.from(this.entries.entries()) .map(([id, info]) => ({ id, ...info })) .sort((a, b) => { const left = Date.parse(a.startedAt); const right = Date.parse(b.startedAt); return (Number.isNaN(left) ? 0 : left) - (Number.isNaN(right) ? 0 : right); }); } getBySession(sessionId: string | number): ManagedProcessRecord[] { const normalized = String(sessionId); return this.getAll().filter(record => record.sessionId !== undefined && String(record.sessionId) === normalized); } getRuntimeProcess(id: string): ChildProcess | undefined { return this.runtimeProcesses.get(id); } getByPid(pid: number): ManagedProcessRecord[] { return this.getAll().filter(record => record.pid === pid); } pruneDeadEntries(): number { this.initialize(); let removed = 0; for (const [id, info] of this.entries) { if (isPidAlive(info.pid)) continue; this.entries.delete(id); this.runtimeProcesses.delete(id); removed += 1; } if (removed > 0) { this.persist(); } return removed; } /** * Kill and unregister all processes tagged with the given sessionId. * Sends SIGTERM first, waits up to 5s, then SIGKILL for survivors. * Called when a session is deleted to prevent leaked child processes (#1351). */ async reapSession(sessionId: string | number): Promise { this.initialize(); const sessionRecords = this.getBySession(sessionId); if (sessionRecords.length === 0) { return 0; } const sessionIdNum = typeof sessionId === 'number' ? sessionId : Number(sessionId) || undefined; logger.info('SYSTEM', `Reaping ${sessionRecords.length} process(es) for session ${sessionId}`, { sessionId: sessionIdNum, pids: sessionRecords.map(r => r.pid) }); // Phase 1: SIGTERM all alive processes const aliveRecords = sessionRecords.filter(r => isPidAlive(r.pid)); for (const record of aliveRecords) { try { process.kill(record.pid, 'SIGTERM'); } catch (error: unknown) { if (error instanceof Error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ESRCH') { logger.debug('SYSTEM', `Failed to SIGTERM session process PID ${record.pid}`, { pid: record.pid }, error); } } else { logger.warn('SYSTEM', `Failed to SIGTERM session process PID ${record.pid} (non-Error)`, { pid: record.pid, error: String(error) }); } } } // Phase 2: Wait for processes to exit const deadline = Date.now() + REAP_SESSION_SIGTERM_TIMEOUT_MS; while (Date.now() < deadline) { const survivors = aliveRecords.filter(r => isPidAlive(r.pid)); if (survivors.length === 0) break; await new Promise(resolve => setTimeout(resolve, 100)); } // Phase 3: SIGKILL any survivors const survivors = aliveRecords.filter(r => isPidAlive(r.pid)); for (const record of survivors) { logger.warn('SYSTEM', `Session process PID ${record.pid} did not exit after SIGTERM, sending SIGKILL`, { pid: record.pid, sessionId: sessionIdNum }); try { process.kill(record.pid, 'SIGKILL'); } catch (error: unknown) { if (error instanceof Error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ESRCH') { logger.debug('SYSTEM', `Failed to SIGKILL session process PID ${record.pid}`, { pid: record.pid }, error); } } else { logger.warn('SYSTEM', `Failed to SIGKILL session process PID ${record.pid} (non-Error)`, { pid: record.pid, error: String(error) }); } } } // Brief wait for SIGKILL to take effect if (survivors.length > 0) { const sigkillDeadline = Date.now() + REAP_SESSION_SIGKILL_TIMEOUT_MS; while (Date.now() < sigkillDeadline) { const remaining = survivors.filter(r => isPidAlive(r.pid)); if (remaining.length === 0) break; await new Promise(resolve => setTimeout(resolve, 100)); } } // Phase 4: Unregister all session records for (const record of sessionRecords) { this.entries.delete(record.id); this.runtimeProcesses.delete(record.id); } this.persist(); logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, { sessionId: sessionIdNum, reaped: sessionRecords.length }); return sessionRecords.length; } private persist(): void { const payload: PersistedRegistry = { processes: Object.fromEntries(this.entries.entries()) }; mkdirSync(path.dirname(this.registryPath), { recursive: true }); writeFileSync(this.registryPath, JSON.stringify(payload, null, 2)); } } let registrySingleton: ProcessRegistry | null = null; export function getProcessRegistry(): ProcessRegistry { if (!registrySingleton) { registrySingleton = new ProcessRegistry(); } return registrySingleton; } export function createProcessRegistry(registryPath: string): ProcessRegistry { return new ProcessRegistry(registryPath); }