diff --git a/src/process/index.ts b/src/process/index.ts new file mode 100644 index 0000000..4f8497e --- /dev/null +++ b/src/process/index.ts @@ -0,0 +1,9 @@ +/** + * Process Module + * + * Exports for process management functionality. + */ + +export { ProcessManager } from './manager.js'; +export { ProcessRegistry } from './registry.js'; +export type { ProcessInfo, ProcessStatus, SpawnOptions } from './types.js'; diff --git a/src/process/manager.ts b/src/process/manager.ts new file mode 100644 index 0000000..647fa7f --- /dev/null +++ b/src/process/manager.ts @@ -0,0 +1,268 @@ +/** + * Process Manager + * + * Manages spawning, stopping, and lifecycle of child processes. + * Uses execa for process spawning with detached mode support. + */ + +import { execa, type ResultPromise } from 'execa'; +import type { ProcessInfo, SpawnOptions } from './types.js'; +import type { ProcessRegistry } from './registry.js'; + +/** Stop timeout in milliseconds before sending SIGKILL */ +const STOP_TIMEOUT_MS = 5000; + +/** + * Internal tracking for spawned process handles. + * Maps process ID to execa subprocess for control operations. + */ +interface ProcessHandle { + subprocess: ResultPromise; + options: SpawnOptions; +} + +/** + * Manager for spawning, tracking, and controlling child processes. + */ +export class ProcessManager { + private handles: Map = new Map(); + + /** + * Create a new ProcessManager. + * @param registry - Registry for tracking process metadata + */ + constructor(private registry: ProcessRegistry) {} + + /** + * Spawn a new child process. + * @param options - Spawn configuration + * @returns Process info for the spawned process + * @throws If process fails to start + */ + async spawn(options: SpawnOptions): Promise { + const { id, command, args = [], cwd, env } = options; + + // Check if process with this ID already exists + const existing = this.registry.get(id); + if (existing && existing.status === 'running') { + throw new Error(`Process with id '${id}' is already running`); + } + + // Spawn the process in detached mode + const subprocess = execa(command, args, { + cwd, + env: env ? { ...process.env, ...env } : undefined, + detached: true, + stdio: 'ignore', // Don't inherit stdio for background processes + }); + + // Ensure we have a PID + const pid = subprocess.pid; + if (pid === undefined) { + throw new Error(`Failed to get PID for process '${id}'`); + } + + // Create process info + const info: ProcessInfo = { + id, + pid, + command, + args, + startedAt: new Date(), + status: 'running', + }; + + // Store handle for later control + this.handles.set(id, { subprocess, options }); + + // Register in registry + this.registry.register(info); + + // Set up exit handler to update status + subprocess.on('exit', (code, signal) => { + const status = code === 0 ? 'stopped' : 'crashed'; + this.registry.updateStatus(id, status); + this.handles.delete(id); + }); + + // Suppress unhandled rejection when process is killed + // This is expected behavior - we're intentionally killing processes + subprocess.catch(() => { + // Intentionally ignored - we handle exit via the 'exit' event + }); + + // Unref the subprocess so it doesn't keep the parent alive + subprocess.unref(); + + return info; + } + + /** + * Stop a running process. + * Sends SIGTERM first, then SIGKILL after timeout if needed. + * @param id - Process ID to stop + * @throws If process not found + */ + async stop(id: string): Promise { + const handle = this.handles.get(id); + const info = this.registry.get(id); + + if (!info) { + throw new Error(`Process with id '${id}' not found`); + } + + if (info.status !== 'running') { + // Already stopped, just clean up + this.handles.delete(id); + return; + } + + if (!handle) { + // Process exists in registry but we don't have a handle + // Try to kill by PID directly + try { + process.kill(info.pid, 'SIGTERM'); + await this.waitForExit(info.pid, STOP_TIMEOUT_MS); + } catch { + // Process might already be dead + } + this.registry.updateStatus(id, 'stopped'); + return; + } + + // Send SIGTERM + handle.subprocess.kill('SIGTERM'); + + // Wait for graceful shutdown + const exited = await this.waitForProcessExit(handle.subprocess, STOP_TIMEOUT_MS); + + if (!exited) { + // Force kill with SIGKILL + handle.subprocess.kill('SIGKILL'); + await this.waitForProcessExit(handle.subprocess, 1000).catch(() => {}); + } + + // Update status (exit handler should have done this, but ensure it) + this.registry.updateStatus(id, 'stopped'); + this.handles.delete(id); + } + + /** + * Stop all running processes. + */ + async stopAll(): Promise { + const processes = this.registry.getAll().filter(p => p.status === 'running'); + await Promise.all(processes.map(p => this.stop(p.id).catch(() => {}))); + } + + /** + * Restart a process with the same configuration. + * @param id - Process ID to restart + * @returns New process info + * @throws If process not found or original config unavailable + */ + async restart(id: string): Promise { + const handle = this.handles.get(id); + const info = this.registry.get(id); + + if (!info) { + throw new Error(`Process with id '${id}' not found`); + } + + // Get original spawn options + let options: SpawnOptions; + if (handle) { + options = handle.options; + } else { + // Reconstruct options from process info + options = { + id, + command: info.command, + args: info.args, + }; + } + + // Stop if running + if (info.status === 'running') { + await this.stop(id); + } + + // Unregister old process + this.registry.unregister(id); + + // Spawn with same options + return this.spawn(options); + } + + /** + * Check if a process is currently running. + * @param id - Process ID to check + * @returns true if process exists and is running + */ + isRunning(id: string): boolean { + const info = this.registry.get(id); + if (!info || info.status !== 'running') { + return false; + } + + // Double-check by probing the actual process + try { + process.kill(info.pid, 0); + return true; + } catch { + // Process is dead, update registry + this.registry.updateStatus(id, 'crashed'); + this.handles.delete(id); + return false; + } + } + + /** + * Wait for a subprocess to exit within a timeout. + * @returns true if process exited, false if timeout + */ + private waitForProcessExit(subprocess: ResultPromise, timeoutMs: number): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => { + resolve(false); + }, timeoutMs); + + // Use both then and catch to handle success and kill scenarios + subprocess + .then(() => { + clearTimeout(timeout); + resolve(true); + }) + .catch(() => { + // Process was killed (expected) - this is success for our purposes + clearTimeout(timeout); + resolve(true); + }); + }); + } + + /** + * Wait for a PID to exit within a timeout. + * @returns true if process exited, false if timeout + */ + private waitForExit(pid: number, timeoutMs: number): Promise { + return new Promise(resolve => { + const start = Date.now(); + const check = () => { + try { + process.kill(pid, 0); + // Still alive + if (Date.now() - start >= timeoutMs) { + resolve(false); + } else { + setTimeout(check, 100); + } + } catch { + // Dead + resolve(true); + } + }; + check(); + }); + } +}