UNPKG

@iflow-mcp/ejmockler-brutalist

Version:

Deploy Claude, Codex & Gemini CLI agents to demolish your work before users do. Real file analysis. Brutal honesty. Now with conversation continuation & intelligent pagination.

262 lines 10.1 kB
import { spawn, execSync } from 'child_process'; import { logger } from '../logger.js'; import * as os from 'os'; /** * Cross-platform process manager that tracks all spawned processes * and ensures proper cleanup, preventing orphaned processes in tests */ export class ProcessManager { static instance; processes = new Map(); isWindows = os.platform() === 'win32'; cleanupRegistered = false; constructor() { // Register global cleanup handlers this.registerCleanupHandlers(); } static getInstance() { if (!ProcessManager.instance) { ProcessManager.instance = new ProcessManager(); } return ProcessManager.instance; } registerCleanupHandlers() { if (this.cleanupRegistered) return; const cleanup = async () => { await this.cleanup(); }; process.on('exit', cleanup); process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); process.on('uncaughtException', async (err) => { logger.error('Uncaught exception, cleaning up processes:', err); await cleanup(); }); this.cleanupRegistered = true; } /** * Spawn a managed process with automatic tracking and cleanup */ async spawn(command, args, options = {}) { return new Promise((resolve, reject) => { const startTime = Date.now(); const cwd = options.cwd || process.cwd(); // Handle shell builtins that don't exist as standalone executables const shellBuiltins = ['echo', 'cd', 'pwd', 'test', 'true', 'false']; const needsShell = shellBuiltins.includes(command); // Create new process group on POSIX for proper tree killing const spawnOptions = { cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: needsShell, // Enable shell for builtins env: options.env || process.env }; // On POSIX, create new process group for tree killing if (!this.isWindows) { spawnOptions.detached = true; } const child = spawn(command, args, spawnOptions); if (!child.pid) { reject(new Error(`Failed to spawn process: ${command}`)); return; } const managed = { pid: child.pid, command, args, process: child, stdout: '', stderr: '', killed: false, startTime }; this.processes.set(child.pid, managed); logger.debug(`ProcessManager: Spawned ${command} with PID ${child.pid}`); let timedOut = false; let timer; let killTimer; // Set up timeout with escalation if (options.timeout) { timer = setTimeout(() => { timedOut = true; logger.warn(`Process ${child.pid} timed out after ${options.timeout}ms`); this.killProcessTree(child.pid).catch(err => { logger.error(`Failed to kill timed out process ${child.pid}:`, err); }); }, options.timeout); } // Handle stdout with buffer limit child.stdout?.on('data', (data) => { const chunk = data.toString(); if (options.maxBuffer && managed.stdout.length + chunk.length > options.maxBuffer) { logger.warn(`Process ${child.pid} exceeded stdout buffer limit`); this.killProcessTree(child.pid); return; } managed.stdout += chunk; options.onProgress?.(chunk, 'stdout'); }); // Handle stderr with buffer limit child.stderr?.on('data', (data) => { const chunk = data.toString(); if (options.maxBuffer && managed.stderr.length + chunk.length > options.maxBuffer) { logger.warn(`Process ${child.pid} exceeded stderr buffer limit`); this.killProcessTree(child.pid); return; } managed.stderr += chunk; options.onProgress?.(chunk, 'stderr'); }); // Handle process exit child.on('exit', (code, signal) => { managed.killed = true; if (timer) clearTimeout(timer); if (killTimer) clearTimeout(killTimer); this.processes.delete(child.pid); logger.debug(`Process ${child.pid} exited with code ${code}, signal ${signal}`); if (timedOut) { reject(new Error(`Process timed out after ${options.timeout}ms`)); } else if (signal) { reject(new Error(`Process killed with signal ${signal}`)); } else { resolve({ stdout: managed.stdout, stderr: managed.stderr, exitCode: code }); } }); child.on('error', (error) => { managed.killed = true; if (timer) clearTimeout(timer); if (killTimer) clearTimeout(killTimer); this.processes.delete(child.pid); logger.error(`Process ${child.pid} error:`, error); reject(error); }); // Write stdin if provided if (options.input) { child.stdin?.write(options.input); child.stdin?.end(); } }); } /** * Kill a process and all its children (cross-platform) */ async killProcessTree(pid, signal = 'SIGTERM') { const managed = this.processes.get(pid); if (!managed || managed.killed) { return; } logger.info(`Killing process tree for PID ${pid}`); managed.killed = true; try { if (this.isWindows) { // Windows: Use taskkill to kill process tree try { execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }); } catch (err) { logger.warn(`Windows taskkill failed for ${pid}, trying direct kill`); managed.process.kill('SIGKILL'); } } else { // POSIX: Kill process group try { // First try SIGTERM to the process group process.kill(-pid, signal); // Give processes 5 seconds to exit gracefully await new Promise(resolve => setTimeout(resolve, 5000)); // Check if still running and escalate to SIGKILL try { process.kill(-pid, 0); // Check if process group still exists logger.warn(`Process group ${pid} still alive after SIGTERM, using SIGKILL`); process.kill(-pid, 'SIGKILL'); } catch { // Process group is gone, good } } catch (err) { // Fallback to direct process kill if group kill fails logger.warn(`Failed to kill process group -${pid}, trying direct kill`); managed.process.kill('SIGKILL'); } } } catch (error) { logger.error(`Failed to kill process tree ${pid}:`, error); throw error; } finally { this.processes.delete(pid); } } /** * Get all currently running managed processes */ getRunningProcesses() { return Array.from(this.processes.values()).filter(p => !p.killed); } /** * Clean up all tracked processes */ async cleanup() { const running = this.getRunningProcesses(); if (running.length === 0) { return; } logger.info(`ProcessManager: Cleaning up ${running.length} processes`); const killPromises = running.map(async (managed) => { try { await this.killProcessTree(managed.pid); } catch (err) { logger.error(`Failed to clean up process ${managed.pid}:`, err); } }); await Promise.all(killPromises); this.processes.clear(); } /** * Get diagnostic information about running processes */ getDiagnostics() { const running = this.getRunningProcesses(); if (running.length === 0) { return 'No running processes'; } const lines = ['Running processes:']; for (const proc of running) { const runtime = Date.now() - proc.startTime; lines.push(` PID ${proc.pid}: ${proc.command} ${proc.args.join(' ')} (running ${runtime}ms)`); if (proc.stdout) { lines.push(` Last stdout: ${proc.stdout.slice(-100)}`); } if (proc.stderr) { lines.push(` Last stderr: ${proc.stderr.slice(-100)}`); } } return lines.join('\n'); } /** * Assert no processes are leaked (for test cleanup validation) */ assertNoLeakedProcesses() { const running = this.getRunningProcesses(); if (running.length > 0) { const diagnostics = this.getDiagnostics(); throw new Error(`Test leaked ${running.length} processes:\n${diagnostics}`); } } } //# sourceMappingURL=process-manager.js.map