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.
This commit is contained in:
214
ts/smartexit.classes.lifecycle.ts
Normal file
214
ts/smartexit.classes.lifecycle.ts
Normal file
@@ -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<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 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user