/** * ProcessManager - Utilities for managing server processes * * Provides cross-platform process management including: * - PID file management * - Process termination (graceful and forced) * - Port reclamation * - Parent process detection */ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import { isPortAvailable } from './findAvailablePort.js'; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** * Check if a process is alive by sending signal 0 */ export const isProcessAlive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === 'ESRCH') { return false; } if (err.code === 'EPERM') { return true; } throw err; } }; /** * Terminate a process by PID with graceful fallback to forceful termination */ export const terminateProcessByPid = async (pid: number): Promise => { if (!Number.isInteger(pid) || pid <= 0) { return false; } if (process.platform === 'win32') { const result = spawnSync('taskkill', ['/PID', pid.toString(), '/T', '/F'], { encoding: 'utf8', windowsHide: true }); if (result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT') { console.warn('taskkill command not found. Unable to terminate cached backend process on Windows.'); return false; } if (result.status !== 0) { const message = result.stderr?.trim() || result.stdout?.trim(); if (message) { console.warn(`taskkill failed for PID ${pid}: ${message}`); } return false; } return true; } try { process.kill(pid, 'SIGTERM'); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === 'ESRCH') { return false; } throw err; } await delay(500); if (isProcessAlive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== 'ESRCH') { throw err; } } } return true; }; /** * Read PID from a file */ export const readPidFile = (pidFilePath: string): number | undefined => { try { if (!fs.existsSync(pidFilePath)) { return undefined; } const contents = fs.readFileSync(pidFilePath, 'utf8').trim(); const parsed = Number.parseInt(contents, 10); return Number.isInteger(parsed) ? parsed : undefined; } catch (error) { console.warn(`Unable to read PID file at ${pidFilePath}: ${(error as Error).message}`); return undefined; } }; /** * Write PID to a file */ export const writePidFile = (pidFilePath: string, pid: number): void => { try { fs.writeFileSync(pidFilePath, `${pid}\n`, 'utf8'); } catch (error) { console.warn(`Unable to record backend PID in ${pidFilePath}: ${(error as Error).message}`); } }; /** * Remove PID file */ export const removePidFile = (pidFilePath: string): void => { try { if (fs.existsSync(pidFilePath)) { fs.unlinkSync(pidFilePath); } } catch (error) { console.warn(`Unable to remove PID file at ${pidFilePath}: ${(error as Error).message}`); } }; /** * Parse PID list from command output */ const parsePidList = (output: string): number[] => output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => Number.parseInt(line, 10)) .filter((value) => Number.isInteger(value) && value > 1); /** * Get PIDs listening on a port (Unix) */ const getListeningPidsUnix = (port: number): number[] => { const lsofResult = spawnSync('lsof', ['-ti', `tcp:${port}`], { encoding: 'utf8' }); if (lsofResult.error && (lsofResult.error as NodeJS.ErrnoException).code === 'ENOENT') { console.warn('lsof command not found. Falling back to fuser for port cleanup.'); } const lsofOutput = (lsofResult.stdout ?? '').trim(); if (lsofOutput.length > 0) { return parsePidList(lsofOutput); } const fuserResult = spawnSync('fuser', ['-n', 'tcp', String(port)], { encoding: 'utf8' }); const combined = `${fuserResult.stdout ?? ''} ${fuserResult.stderr ?? ''}`.trim(); const matches = combined.match(/\d+/g); if (!matches) { return []; } return matches .map((token) => Number.parseInt(token, 10)) .filter((value) => Number.isInteger(value) && value > 1 && value !== port); }; /** * Get PIDs listening on a port (Windows) */ const getListeningPidsWindows = (port: number): number[] => { const netstatResult = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' }); if (netstatResult.error) { const message = netstatResult.error instanceof Error ? netstatResult.error.message : String(netstatResult.error); console.warn(`Unable to inspect netstat output: ${message}`); return []; } const lines = (netstatResult.stdout ?? '').split(/\r?\n/); const regex = new RegExp(`:${port}\\b`); const pids = new Set(); for (const rawLine of lines) { const line = rawLine.trim(); if (!line || !line.toUpperCase().includes('LISTENING')) { continue; } if (!regex.test(line)) { continue; } const tokens = line.split(/\s+/); const pidToken = tokens[tokens.length - 1]; const pid = Number.parseInt(pidToken, 10); if (Number.isInteger(pid) && pid > 1) { pids.add(pid); } } return Array.from(pids); }; /** * Get PIDs listening on a port (cross-platform) */ export const getListeningPids = (port: number): number[] => process.platform === 'win32' ? getListeningPidsWindows(port) : getListeningPidsUnix(port); /** * Get parent process info (Unix only) */ const getParentProcessInfoUnix = (pid: number): { ppid: number | null; command: string | null } | null => { const result = spawnSync('ps', ['-o', 'ppid=', '-o', 'comm=', '-p', String(pid)], { encoding: 'utf8' }); if (result.error || result.status !== 0) { return null; } const lines = (result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean); if (!lines.length) { return null; } const lastLine = lines[lines.length - 1].trim(); const tokens = lastLine.split(/\s+/); const command = tokens.pop() ?? null; const ppidToken = tokens.pop(); const ppid = ppidToken ? Number.parseInt(ppidToken, 10) : Number.NaN; return { ppid: Number.isInteger(ppid) && ppid > 1 ? ppid : null, command }; }; /** * Get parent process info (cross-platform) */ export const getParentProcessInfo = (pid: number): { ppid: number | null; command: string | null } | null => { if (process.platform === 'win32') { return null; } return getParentProcessInfoUnix(pid); }; /** * Check if parent process should be included in termination */ const shouldIncludeParent = (command: string | null): boolean => { if (!command) { return false; } const normalized = command.toLowerCase(); return ['node', 'npm', 'tsx', 'watch', 'run-local-server'].some((token) => normalized.includes(token)); }; type ParentProcessInfo = { ppid: number | null; command: string | null } | null; /** * Collect processes to terminate, including relevant parents */ export const collectProcessesToTerminate = ( pids: number[], resolver: (pid: number) => ParentProcessInfo ): number[] => { if (pids.length === 0) { return []; } const visited = new Set(pids); const extended = new Set(pids); const queue: number[] = [...pids]; while (queue.length > 0) { const pid = queue.shift() as number; const info = resolver(pid); if (!info) { continue; } if (shouldIncludeParent(info.command)) { extended.add(pid); } const parentPid = info.ppid; if (!parentPid || visited.has(parentPid)) { continue; } visited.add(parentPid); queue.push(parentPid); } return Array.from(extended); }; /** * Expand PIDs with their parent processes (Unix only) */ const expandWithParents = (pids: number[]): number[] => { if (process.platform === 'win32') { return pids; } return collectProcessesToTerminate(pids, getParentProcessInfo); }; /** * Terminate multiple processes with the given signal */ const terminateProcesses = (pids: number[], signal: NodeJS.Signals | number): void => { for (const pid of pids) { try { process.kill(pid, signal); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn(`Unable to send ${signal} to PID ${pid}: ${message}`); } } }; /** * Reclaim a port by terminating processes using it */ export const reclaimPort = async (port: number): Promise => { if (await isPortAvailable(port)) { return; } console.log(`⚙️ Reclaiming port ${port}...`); const pids = getListeningPids(port); if (pids.length === 0) { throw new Error(`Port ${port} is busy but no owning process could be determined. Stop it manually.`); } const targets = expandWithParents(pids); terminateProcesses(targets, 'SIGTERM'); await delay(1000); if (await isPortAvailable(port)) { console.log(`✅ Port ${port} reclaimed after graceful shutdown.`); return; } console.warn(`⚠️ Port ${port} still busy after SIGTERM. Sending SIGKILL...`); terminateProcesses(targets, 'SIGKILL'); const MAX_WAIT_ATTEMPTS = 10; for (let attempt = 0; attempt < MAX_WAIT_ATTEMPTS; attempt += 1) { if (await isPortAvailable(port)) { console.log(`✅ Port ${port} reclaimed after forced termination.`); return; } await delay(300); } const remaining = getListeningPids(port); const owner = remaining.length ? ` (owner PID(s): ${remaining.join(', ')})` : ''; throw new Error(`Unable to reclaim port ${port}${owner}. Stop the conflicting process manually.`); };