export interface ILifecycleOptions { /** Which signals to intercept. Default: ['SIGINT', 'SIGTERM'] */ signals?: Array<'SIGINT' | 'SIGTERM' | 'SIGHUP'>; /** Handle uncaughtException. Default: true */ uncaughtExceptions?: boolean; /** Max time in ms for graceful shutdown before force-kill. Default: 10000 */ shutdownTimeoutMs?: number; /** Disable lifecycle logging. Default: false */ silent?: boolean; } const DEFAULT_OPTIONS: Required = { signals: ['SIGINT', 'SIGTERM'], uncaughtExceptions: true, shutdownTimeoutMs: 10000, silent: false, }; /** * Global process lifecycle manager. * * Call `ProcessLifecycle.install()` ONCE from your application entry point. * Libraries should NEVER call install() — they just create SmartExit instances. * * All SmartExit instances auto-register here. On shutdown, all instances' * cleanup functions run and all tracked process trees are killed. */ export class ProcessLifecycle { // Singleton private static instance: ProcessLifecycle | null = null; // Global registry of SmartExit instances (populated by SmartExit constructor) // Using 'any' to avoid circular import — actual type is SmartExit private static readonly registry: Set = new Set(); private static exitHandlerInstalled = false; private options: Required; private shuttingDown = false; private signalCount = 0; private constructor(options: Required) { this.options = options; } /** * Install global signal handlers. * Call ONCE from the application entry point. Libraries should NOT call this. * Idempotent — subsequent calls return the existing instance. */ public static install(options: ILifecycleOptions = {}): ProcessLifecycle { if (ProcessLifecycle.instance) { return ProcessLifecycle.instance; } const merged = { ...DEFAULT_OPTIONS, ...options }; const lifecycle = new ProcessLifecycle(merged); ProcessLifecycle.instance = lifecycle; // Install signal handlers for (const signal of merged.signals) { process.on(signal, () => lifecycle.handleSignal(signal)); } // Install uncaughtException handler if (merged.uncaughtExceptions) { process.on('uncaughtException', (err) => lifecycle.handleUncaughtException(err)); } // Synchronous exit safety net (single handler covers all instances) if (!ProcessLifecycle.exitHandlerInstalled) { ProcessLifecycle.exitHandlerInstalled = true; process.on('exit', () => lifecycle.handleExit()); } return lifecycle; } /** Get the installed lifecycle, or null if not installed. */ public static getInstance(): ProcessLifecycle | null { return ProcessLifecycle.instance; } /** Check whether global handlers are installed. */ public static isInstalled(): boolean { return ProcessLifecycle.instance !== null; } // ---- Instance registry ---- /** Called by SmartExit constructor to auto-register. */ public static registerInstance(instance: any): void { ProcessLifecycle.registry.add(instance); } /** Remove an instance from the global registry. */ public static deregisterInstance(instance: any): void { ProcessLifecycle.registry.delete(instance); } /** Get all registered SmartExit instances. */ public static getInstances(): any[] { return Array.from(ProcessLifecycle.registry); } // ---- Shutdown orchestration ---- /** Whether a shutdown is currently in progress. */ public get isShuttingDown(): boolean { return this.shuttingDown; } /** * Initiate orderly shutdown of all registered instances. * Safe to call multiple times — only the first call executes. */ public async shutdown(exitCode: number = 0): Promise { if (this.shuttingDown) { return; } this.shuttingDown = true; process.exitCode = exitCode; const instances = ProcessLifecycle.getInstances(); let totalProcessesKilled = 0; let totalCleanupRan = 0; // Kill all instances in parallel, each running cleanup then tree-kill const shutdownPromises = instances.map(async (instance) => { try { const result = await instance.killAll(); totalProcessesKilled += result.processesKilled; totalCleanupRan += result.cleanupFunctionsRan; } catch (err) { if (!this.options.silent) { console.error(`[smartexit] Error during instance cleanup: ${err}`); } } }); // Race against timeout await Promise.race([ Promise.allSettled(shutdownPromises), new Promise((resolve) => setTimeout(resolve, this.options.shutdownTimeoutMs)), ]); if (!this.options.silent) { console.log( `[smartexit] Shutdown complete: ${totalProcessesKilled} processes killed, ` + `${totalCleanupRan} cleanup functions ran` ); } } // ---- Signal handlers ---- private handleSignal(signal: string): void { this.signalCount++; if (this.signalCount >= 2) { if (!this.options.silent) { console.log(`\n[smartexit] Force shutdown (received ${signal} twice)`); } process.exit(1); return; } if (!this.options.silent) { console.log(`\n[smartexit] Received ${signal}, shutting down...`); } this.shutdown(0).then(() => { process.exit(0); }); } private handleUncaughtException(err: Error): void { if (!this.options.silent) { console.error(`[smartexit] Uncaught exception: ${err.message}`); if (err.stack) { console.error(err.stack); } } this.shutdown(1).then(() => { process.exit(1); }); } /** Synchronous last-resort: SIGKILL any remaining tracked process groups. */ private handleExit(): void { const instances = ProcessLifecycle.getInstances(); let killed = 0; for (const instance of instances) { for (const pid of instance.trackedPids) { // Kill entire process group (negative PID) for detached children try { process.kill(-pid, 'SIGKILL'); killed++; } catch { // Process group may not exist, try single PID try { process.kill(pid, 'SIGKILL'); killed++; } catch { // Process already dead } } } } if (killed > 0 && !this.options.silent) { console.error(`[smartexit] Exit handler: force-killed ${killed} remaining child processes`); } } }