251 lines
6.7 KiB
TypeScript
251 lines
6.7 KiB
TypeScript
/**
|
|
* Shutdown management for Node.js applications
|
|
*
|
|
* Features:
|
|
* - Automatic signal handling (SIGTERM, SIGINT, etc.)
|
|
* - Configurable shutdown timeout
|
|
* - Multiple cleanup callbacks with error handling
|
|
* - Platform-specific signal support (Windows/Unix)
|
|
*/
|
|
|
|
import type { PrioritizedShutdownCallback, ShutdownCallback, ShutdownOptions, ShutdownResult } from './types';
|
|
|
|
// Global flag that works across all processes/workers
|
|
declare global {
|
|
var __SHUTDOWN_SIGNAL_RECEIVED__: boolean | undefined;
|
|
}
|
|
|
|
export class Shutdown {
|
|
private static instance: Shutdown | null = null;
|
|
private isShuttingDown = false;
|
|
private signalReceived = false; // Track if shutdown signal was received
|
|
private shutdownTimeout = 30000; // 30 seconds default
|
|
private callbacks: PrioritizedShutdownCallback[] = [];
|
|
private signalHandlersRegistered = false;
|
|
|
|
constructor(options: ShutdownOptions = {}) {
|
|
this.shutdownTimeout = options.timeout || 30000;
|
|
|
|
if (options.autoRegister !== false) {
|
|
this.setupSignalHandlers();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create singleton instance
|
|
*/
|
|
static getInstance(options?: ShutdownOptions): Shutdown {
|
|
if (!Shutdown.instance) {
|
|
Shutdown.instance = new Shutdown(options);
|
|
}
|
|
return Shutdown.instance;
|
|
}
|
|
|
|
/**
|
|
* Reset singleton instance (mainly for testing)
|
|
*/
|
|
static reset(): void {
|
|
Shutdown.instance = null;
|
|
}
|
|
|
|
/**
|
|
* Register a cleanup callback with priority (lower numbers = higher priority)
|
|
*/
|
|
onShutdown(callback: ShutdownCallback, priority: number = 50, name?: string): void {
|
|
if (this.isShuttingDown) {
|
|
return;
|
|
}
|
|
this.callbacks.push({ callback, priority, name });
|
|
}
|
|
|
|
/**
|
|
* Register a high priority shutdown callback (for queues, critical services)
|
|
*/
|
|
onShutdownHigh(callback: ShutdownCallback, name?: string): void {
|
|
this.onShutdown(callback, 10, name);
|
|
}
|
|
|
|
/**
|
|
* Register a medium priority shutdown callback (for databases, connections)
|
|
*/
|
|
onShutdownMedium(callback: ShutdownCallback, name?: string): void {
|
|
this.onShutdown(callback, 50, name);
|
|
}
|
|
|
|
/**
|
|
* Register a low priority shutdown callback (for loggers, cleanup)
|
|
*/
|
|
onShutdownLow(callback: ShutdownCallback, name?: string): void {
|
|
this.onShutdown(callback, 90, name);
|
|
}
|
|
|
|
/**
|
|
* Set shutdown timeout in milliseconds
|
|
*/
|
|
setTimeout(timeout: number): void {
|
|
if (timeout <= 0) {
|
|
throw new Error('Shutdown timeout must be positive');
|
|
}
|
|
this.shutdownTimeout = timeout;
|
|
}
|
|
|
|
/**
|
|
* Get current shutdown state
|
|
*/
|
|
isShutdownInProgress(): boolean {
|
|
return this.isShuttingDown;
|
|
}
|
|
|
|
/**
|
|
* Check if shutdown signal was received (for quick checks in running jobs)
|
|
*/
|
|
isShutdownSignalReceived(): boolean {
|
|
return this.signalReceived || this.isShuttingDown;
|
|
}
|
|
|
|
/**
|
|
* Get number of registered callbacks
|
|
*/
|
|
getCallbackCount(): number {
|
|
return this.callbacks.length;
|
|
}
|
|
|
|
/**
|
|
* Initiate graceful shutdown
|
|
*/
|
|
async shutdown(_signal?: string): Promise<ShutdownResult> {
|
|
if (this.isShuttingDown) {
|
|
return {
|
|
success: false,
|
|
callbacksExecuted: 0,
|
|
callbacksFailed: 0,
|
|
duration: 0,
|
|
error: 'Shutdown already in progress',
|
|
};
|
|
}
|
|
|
|
this.isShuttingDown = true;
|
|
const startTime = Date.now();
|
|
|
|
const shutdownPromise = this.executeCallbacks();
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error('Shutdown timeout')), this.shutdownTimeout);
|
|
});
|
|
|
|
let result: ShutdownResult;
|
|
|
|
try {
|
|
const callbackResult = await Promise.race([shutdownPromise, timeoutPromise]);
|
|
const duration = Date.now() - startTime;
|
|
|
|
result = {
|
|
success: true,
|
|
callbacksExecuted: callbackResult.executed,
|
|
callbacksFailed: callbackResult.failed,
|
|
duration,
|
|
error: callbackResult.failed > 0 ? `${callbackResult.failed} callbacks failed` : undefined,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
result = {
|
|
success: false,
|
|
callbacksExecuted: 0,
|
|
callbacksFailed: 0,
|
|
duration,
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
|
|
// Don't call process.exit here - let the caller decide
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Initiate shutdown and exit process
|
|
*/
|
|
async shutdownAndExit(signal?: string, exitCode = 0): Promise<never> {
|
|
const result = await this.shutdown(signal);
|
|
const finalExitCode = result.success ? exitCode : 1;
|
|
|
|
process.exit(finalExitCode);
|
|
}
|
|
|
|
/**
|
|
* Execute all registered callbacks in priority order
|
|
*/
|
|
private async executeCallbacks(): Promise<{ executed: number; failed: number }> {
|
|
if (this.callbacks.length === 0) {
|
|
return { executed: 0, failed: 0 };
|
|
}
|
|
|
|
// Sort callbacks by priority (lower numbers = higher priority = execute first)
|
|
const sortedCallbacks = [...this.callbacks].sort((a, b) => a.priority - b.priority);
|
|
|
|
let executed = 0;
|
|
let failed = 0;
|
|
|
|
// Execute callbacks in order by priority
|
|
for (const { callback, name, priority } of sortedCallbacks) {
|
|
try {
|
|
await callback();
|
|
executed++;
|
|
} catch (error) {
|
|
failed++;
|
|
if (name) {
|
|
console.error(`✗ Shutdown failed: ${name} (priority: ${priority})`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { executed, failed };
|
|
}
|
|
|
|
/**
|
|
* Setup signal handlers for graceful shutdown
|
|
*/
|
|
private setupSignalHandlers(): void {
|
|
if (this.signalHandlersRegistered) {
|
|
return;
|
|
}
|
|
|
|
// Platform-specific signals
|
|
const signals: NodeJS.Signals[] =
|
|
process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGTERM', 'SIGINT', 'SIGUSR2'];
|
|
|
|
signals.forEach(signal => {
|
|
process.on(signal, () => {
|
|
// Only process if not already shutting down
|
|
if (!this.isShuttingDown) {
|
|
// Set signal flag immediately for quick checks
|
|
this.signalReceived = true;
|
|
// Also set global flag for workers/other processes
|
|
globalThis.__SHUTDOWN_SIGNAL_RECEIVED__ = true;
|
|
this.shutdownAndExit(signal).catch(() => {
|
|
process.exit(1);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle uncaught exceptions
|
|
process.on('uncaughtException', () => {
|
|
this.signalReceived = true;
|
|
this.shutdownAndExit('uncaughtException', 1).catch(() => {
|
|
process.exit(1);
|
|
});
|
|
});
|
|
|
|
// Handle unhandled promise rejections
|
|
process.on('unhandledRejection', () => {
|
|
this.signalReceived = true;
|
|
this.shutdownAndExit('unhandledRejection', 1).catch(() => {
|
|
process.exit(1);
|
|
});
|
|
});
|
|
|
|
this.signalHandlersRegistered = true;
|
|
}
|
|
}
|