5 Commits

Author SHA1 Message Date
76225c6b9f fix(lifecycle): use process group kill (-pid) in handleExit safety net
With detached:true children, the synchronous exit handler must kill
the entire process group, not just the direct PID.
2026-03-04 00:48:38 +00:00
a623ac5fe4 v2.0.1 2026-03-03 23:56:58 +00:00
34b9aa4463 fix(core): track PIDs independently to survive removeProcess() race during shutdown
The direct child process may die from terminal SIGINT before ProcessLifecycle
runs shutdown, causing removeProcess() to clear it. Now killAll() uses a
persistent trackedPids Set that is never cleared by removeProcess(), ensuring
grandchild process trees are always killed.
2026-03-03 23:56:52 +00:00
da24218bef v2.0.0 2026-03-03 23:40:24 +00:00
862c67edbb 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.
2026-03-03 23:40:05 +00:00
5 changed files with 384 additions and 178 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartexit",
"version": "1.1.1",
"version": "2.0.2",
"private": false,
"description": "A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.",
"main": "dist_ts/index.js",
@@ -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"
},

View File

@@ -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<plugins.childProcess.ChildProcess>();
public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise<any>>();
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<any>) {
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';

View File

@@ -0,0 +1,217 @@
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<ILifecycleOptions> = {
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<any> = new Set();
private static exitHandlerInstalled = false;
private options: Required<ILifecycleOptions>;
private shuttingDown = false;
private signalCount = 0;
private constructor(options: Required<ILifecycleOptions>) {
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<void> {
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<void>((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`);
}
}
}

View File

@@ -0,0 +1,161 @@
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<plugins.childProcess.ChildProcess>();
public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise<any>>();
/** PIDs tracked independently — survives removeProcess() so shutdown can still kill the tree. */
public trackedPids = new Set<number>();
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);
if (childProcessArg.pid) {
this.trackedPids.add(childProcessArg.pid);
}
}
/** Register an async cleanup function to run on shutdown. */
public addCleanupFunction(cleanupFunctionArg: () => Promise<any>) {
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 ALL tracked process trees by PID.
// We use trackedPids (not processesToEnd) because the process object may have
// been removed by smartshell's exit handler before shutdown runs.
// We do NOT check .killed — the direct child may be dead but grandchildren alive.
for (const pid of this.trackedPids) {
try {
await SmartExit.killTreeByPid(pid, 'SIGTERM');
processesKilled++;
} catch {
// SIGTERM failed — force kill
try {
await SmartExit.killTreeByPid(pid, 'SIGKILL');
processesKilled++;
} catch {
// Process tree already dead — fine
}
}
}
this.trackedPids.clear();
return { processesKilled, cleanupFunctionsRan };
}
/** Remove this instance from the global ProcessLifecycle registry. */
public deregister(): void {
ProcessLifecycle.deregisterInstance(this);
}
}

View File

@@ -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';