Files
smartexit/ts/smartexit.classes.lifecycle.ts

215 lines
6.3 KiB
TypeScript
Raw Normal View History

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`);
}
}
}