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.
211 lines
6.1 KiB
TypeScript
211 lines
6.1 KiB
TypeScript
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 PIDs. */
|
|
private handleExit(): void {
|
|
const instances = ProcessLifecycle.getInstances();
|
|
let killed = 0;
|
|
|
|
for (const instance of instances) {
|
|
for (const pid of instance.trackedPids) {
|
|
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`);
|
|
}
|
|
}
|
|
}
|