From 862c67edbb39890f28d209a51e883aed4fbd873d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 3 Mar 2026 23:40:05 +0000 Subject: [PATCH] BREAKING CHANGE(lifecycle): redesign SmartExit with ProcessLifecycle singleton SmartExit constructor no longer installs signal handlers. Applications must call ProcessLifecycle.install() explicitly. Split into SmartExit (instance process tracking) and ProcessLifecycle (global signal coordination). Remove @push.rocks/smartdelay dependency. --- package.json | 1 - ts/index.ts | 178 +------------------------ ts/smartexit.classes.lifecycle.ts | 214 ++++++++++++++++++++++++++++++ ts/smartexit.classes.smartexit.ts | 154 +++++++++++++++++++++ ts/smartexit.plugins.ts | 3 +- 5 files changed, 373 insertions(+), 177 deletions(-) create mode 100644 ts/smartexit.classes.lifecycle.ts create mode 100644 ts/smartexit.classes.smartexit.ts diff --git a/package.json b/package.json index d1608f3..c657253 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ }, "dependencies": { "@push.rocks/lik": "^6.2.2", - "@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartpromise": "^4.2.3", "tree-kill": "^1.2.2" }, diff --git a/ts/index.ts b/ts/index.ts index c6d0183..18123af 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,174 +1,4 @@ -import * as plugins from './smartexit.plugins.js'; - -export interface ISmartExitOptions { - silent?: boolean; // Completely disable logging -} - -export type TProcessSignal = - | 'SIGHUP' // Hangup detected on controlling terminal or death of controlling process - | 'SIGINT' // Interrupt from keyboard - | 'SIGQUIT' // Quit from keyboard - | 'SIGILL' // Illegal Instruction - | 'SIGTRAP' // Trace/breakpoint trap - | 'SIGABRT' // Abort signal from abort(3) - | 'SIGIOT' // IOT trap. A synonym for SIGABRT - | 'SIGBUS' // Bus error (bad memory access) - | 'SIGFPE' // Floating-point exception - | 'SIGKILL' // Kill signal - | 'SIGUSR1' // User-defined signal 1 - | 'SIGSEGV' // Invalid memory reference - | 'SIGUSR2' // User-defined signal 2 - | 'SIGPIPE' // Broken pipe: write to pipe with no readers - | 'SIGALRM' // Timer signal from alarm(2) - | 'SIGTERM' // Termination signal - | 'SIGCHLD' // Child stopped or terminated - | 'SIGCONT' // Continue if stopped - | 'SIGSTOP' // Stop process - | 'SIGTSTP' // Stop typed at terminal - | 'SIGTTIN' // Terminal input for background process - | 'SIGTTOU' // Terminal output for background process - | 'SIGURG' // Urgent condition on socket - | 'SIGXCPU' // CPU time limit exceeded - | 'SIGXFSZ' // File size limit exceeded - | 'SIGVTALRM' // Virtual alarm clock - | 'SIGPROF' // Profiling timer expired - | 'SIGWINCH' // Window resize signal - | 'SIGPOLL' // Pollable event (Sys V). Synonym for SIGIO - | 'SIGIO' // I/O now possible (4.2BSD) - | 'SIGPWR' // Power failure (System V) - | 'SIGINFO' // Information request (some systems) - | 'SIGLOST' // Resource lost (unused on most UNIX systems) - | 'SIGSYS' // Bad system call (unused on most UNIX systems) - | 'SIGUNUSED'; // Synonym for SIGSYS - -export class SmartExit { - public static async killTreeByPid(pidArg: number, signalArg: TProcessSignal = 'SIGKILL') { - const done = plugins.smartpromise.defer(); - plugins.treeKill.default(pidArg, signalArg, (err) => { - if (err) { - done.reject(err); - } else { - done.resolve(); - } - }); - await done.promise; - } - - // Instance - public processesToEnd = new plugins.lik.ObjectMap(); - public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise>(); - private options: ISmartExitOptions; - - /** - * Internal logging helper that respects silent option - */ - private log(message: string, isError = false): void { - if (this.options.silent) { - return; - } - const prefix = '[smartexit]'; - if (isError) { - console.error(`${prefix} ${message}`); - } else { - console.log(`${prefix} ${message}`); - } - } - - /** - * adds a process to be exited - * @param childProcessArg - */ - public addProcess(childProcessArg: plugins.childProcess.ChildProcess) { - this.processesToEnd.add(childProcessArg); - } - - public addCleanupFunction(cleanupFunctionArg: () => Promise) { - this.cleanupFunctions.add(cleanupFunctionArg); - } - - /** - * removes a process to be exited - */ - public removeProcess(childProcessArg: plugins.childProcess.ChildProcess) { - this.processesToEnd.remove(childProcessArg); - } - - public async killAll(): Promise<{ processesKilled: number; cleanupFunctionsRan: number }> { - const processes = this.processesToEnd.getArray(); - const cleanupFuncs = this.cleanupFunctions.getArray(); - let processesKilled = 0; - let cleanupFunctionsRan = 0; - - // Kill child process trees - if (processes.length > 0) { - for (const childProcessArg of processes) { - const pid = childProcessArg.pid; - if (pid) { - try { - // Use tree-kill to kill the entire process tree, not just the direct child - await SmartExit.killTreeByPid(pid, 'SIGTERM'); - } catch (err) { - // SIGTERM failed — force kill the tree - try { - await SmartExit.killTreeByPid(pid, 'SIGKILL'); - } catch { - // Process may already be dead, ignore - } - } - processesKilled++; - } - } - } - - // Run cleanup functions - if (cleanupFuncs.length > 0) { - for (const cleanupFunction of cleanupFuncs) { - await cleanupFunction(); - cleanupFunctionsRan++; - } - } - - return { processesKilled, cleanupFunctionsRan }; - } - - constructor(optionsArg: ISmartExitOptions = {}) { - this.options = optionsArg; - - // Last-resort synchronous cleanup on exit — 'exit' event cannot await async work. - // By this point, SIGINT handler should have already called killAll(). - // This is a safety net for any processes still alive. - process.on('exit', (code) => { - const processes = this.processesToEnd.getArray(); - let killed = 0; - for (const childProcessArg of processes) { - const pid = childProcessArg.pid; - if (pid && !childProcessArg.killed) { - try { - process.kill(pid, 'SIGKILL'); - killed++; - } catch { - // Process may already be dead, ignore - } - } - } - if (killed > 0) { - this.log(`Exit handler: force-killed ${killed} remaining child processes (exit code: ${code})`, code !== 0); - } - }); - - // catch ctrl+c event and exit normally - process.on('SIGINT', async () => { - const { processesKilled, cleanupFunctionsRan } = await this.killAll(); - this.log(`Shutdown complete: killed ${processesKilled} child processes, ran ${cleanupFunctionsRan} cleanup functions`); - process.exit(0); - }); - - // catch uncaught exceptions, trace, then exit normally - process.on('uncaughtException', async (err) => { - this.log(`Uncaught exception: ${err.message}`, true); - const { processesKilled, cleanupFunctionsRan } = await this.killAll(); - this.log(`Shutdown complete: killed ${processesKilled} child processes, ran ${cleanupFunctionsRan} cleanup functions (exit code: 1)`, true); - process.exit(1); - }); - } -} +export { SmartExit } from './smartexit.classes.smartexit.js'; +export type { ISmartExitOptions, TProcessSignal } from './smartexit.classes.smartexit.js'; +export { ProcessLifecycle } from './smartexit.classes.lifecycle.js'; +export type { ILifecycleOptions } from './smartexit.classes.lifecycle.js'; diff --git a/ts/smartexit.classes.lifecycle.ts b/ts/smartexit.classes.lifecycle.ts new file mode 100644 index 0000000..eb38521 --- /dev/null +++ b/ts/smartexit.classes.lifecycle.ts @@ -0,0 +1,214 @@ +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 child processes. */ + private handleExit(): void { + const instances = ProcessLifecycle.getInstances(); + let killed = 0; + + for (const instance of instances) { + const processes = instance.processesToEnd.getArray(); + for (const child of processes) { + const pid = child.pid; + if (pid && !child.killed) { + try { + process.kill(pid, 'SIGKILL'); + killed++; + } catch { + // Process may already be dead + } + } + } + } + + if (killed > 0 && !this.options.silent) { + console.error(`[smartexit] Exit handler: force-killed ${killed} remaining child processes`); + } + } +} diff --git a/ts/smartexit.classes.smartexit.ts b/ts/smartexit.classes.smartexit.ts new file mode 100644 index 0000000..31f705b --- /dev/null +++ b/ts/smartexit.classes.smartexit.ts @@ -0,0 +1,154 @@ +import * as plugins from './smartexit.plugins.js'; +import { ProcessLifecycle } from './smartexit.classes.lifecycle.js'; + +export interface ISmartExitOptions { + /** Completely disable logging for this instance. */ + silent?: boolean; +} + +export type TProcessSignal = + | 'SIGHUP' + | 'SIGINT' + | 'SIGQUIT' + | 'SIGILL' + | 'SIGTRAP' + | 'SIGABRT' + | 'SIGIOT' + | 'SIGBUS' + | 'SIGFPE' + | 'SIGKILL' + | 'SIGUSR1' + | 'SIGSEGV' + | 'SIGUSR2' + | 'SIGPIPE' + | 'SIGALRM' + | 'SIGTERM' + | 'SIGCHLD' + | 'SIGCONT' + | 'SIGSTOP' + | 'SIGTSTP' + | 'SIGTTIN' + | 'SIGTTOU' + | 'SIGURG' + | 'SIGXCPU' + | 'SIGXFSZ' + | 'SIGVTALRM' + | 'SIGPROF' + | 'SIGWINCH' + | 'SIGPOLL' + | 'SIGIO' + | 'SIGPWR' + | 'SIGINFO' + | 'SIGLOST' + | 'SIGSYS' + | 'SIGUNUSED'; + +/** + * SmartExit — process and cleanup tracker. + * + * Lightweight: the constructor does NOT register signal handlers. + * Each instance auto-registers with ProcessLifecycle so that global + * shutdown (triggered by ProcessLifecycle.install()) kills all tracked processes. + * + * Libraries should create instances freely. Only the application entry point + * should call `ProcessLifecycle.install()`. + */ +export class SmartExit { + /** Kill an entire process tree by PID. */ + public static async killTreeByPid(pidArg: number, signalArg: TProcessSignal = 'SIGKILL') { + const done = plugins.smartpromise.defer(); + plugins.treeKill.default(pidArg, signalArg, (err) => { + if (err) { + done.reject(err); + } else { + done.resolve(); + } + }); + await done.promise; + } + + // Instance state + public processesToEnd = new plugins.lik.ObjectMap(); + public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise>(); + private options: ISmartExitOptions; + + private log(message: string, isError = false): void { + if (this.options.silent) { + return; + } + const prefix = '[smartexit]'; + if (isError) { + console.error(`${prefix} ${message}`); + } else { + console.log(`${prefix} ${message}`); + } + } + + constructor(optionsArg: ISmartExitOptions = {}) { + this.options = optionsArg; + // Auto-register with the global ProcessLifecycle registry + ProcessLifecycle.registerInstance(this); + } + + /** Register a child process for cleanup on shutdown. */ + public addProcess(childProcessArg: plugins.childProcess.ChildProcess) { + this.processesToEnd.add(childProcessArg); + } + + /** Register an async cleanup function to run on shutdown. */ + public addCleanupFunction(cleanupFunctionArg: () => Promise) { + this.cleanupFunctions.add(cleanupFunctionArg); + } + + /** Unregister a child process. */ + public removeProcess(childProcessArg: plugins.childProcess.ChildProcess) { + this.processesToEnd.remove(childProcessArg); + } + + /** + * Run cleanup functions, then kill all tracked process trees. + * Called by ProcessLifecycle during global shutdown. + * Can also be called manually. + */ + public async killAll(): Promise<{ processesKilled: number; cleanupFunctionsRan: number }> { + const processes = this.processesToEnd.getArray(); + const cleanupFuncs = this.cleanupFunctions.getArray(); + let processesKilled = 0; + let cleanupFunctionsRan = 0; + + // Phase 1: Run cleanup functions (processes still alive) + for (const cleanupFunction of cleanupFuncs) { + try { + await cleanupFunction(); + cleanupFunctionsRan++; + } catch (err) { + this.log(`Cleanup function failed: ${err}`, true); + } + } + + // Phase 2: Kill child process trees + for (const childProcessArg of processes) { + const pid = childProcessArg.pid; + if (pid && !childProcessArg.killed) { + try { + await SmartExit.killTreeByPid(pid, 'SIGTERM'); + } catch { + // SIGTERM failed — force kill + try { + await SmartExit.killTreeByPid(pid, 'SIGKILL'); + } catch { + // Process may already be dead + } + } + processesKilled++; + } + } + + return { processesKilled, cleanupFunctionsRan }; + } + + /** Remove this instance from the global ProcessLifecycle registry. */ + public deregister(): void { + ProcessLifecycle.deregisterInstance(this); + } +} diff --git a/ts/smartexit.plugins.ts b/ts/smartexit.plugins.ts index d604845..3cea941 100644 --- a/ts/smartexit.plugins.ts +++ b/ts/smartexit.plugins.ts @@ -5,10 +5,9 @@ export { childProcess }; // pushrocks scope import * as lik from '@push.rocks/lik'; -import * as smartdelay from '@push.rocks/smartdelay'; import * as smartpromise from '@push.rocks/smartpromise'; -export { lik, smartdelay, smartpromise }; +export { lik, smartpromise }; // third party scope import * as treeKill from 'tree-kill';