diff --git a/src/cli/index.ts b/src/cli/index.ts index 841e05b..06a9c5e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { Command } from 'commander'; import { VERSION } from '../index.js'; import { CoordinationServer } from '../server/index.js'; +import { GracefulShutdown } from '../server/shutdown.js'; import { ProcessManager, ProcessRegistry } from '../process/index.js'; import { LogManager } from '../logging/index.js'; @@ -42,17 +43,9 @@ async function startServer(port?: number): Promise { process.exit(1); } - // Import shutdown handler (will be implemented in Task 2) - // For now, just handle basic signals - const handleSignal = async (signal: string) => { - console.log(`\nReceived ${signal}, shutting down...`); - await server.stop(); - await processManager.stopAll(); - process.exit(0); - }; - - process.on('SIGTERM', () => handleSignal('SIGTERM')); - process.on('SIGINT', () => handleSignal('SIGINT')); + // Install graceful shutdown handlers + const shutdown = new GracefulShutdown(server, processManager, logManager); + shutdown.install(); } /** diff --git a/src/server/shutdown.ts b/src/server/shutdown.ts new file mode 100644 index 0000000..18c1b0f --- /dev/null +++ b/src/server/shutdown.ts @@ -0,0 +1,125 @@ +/** + * Graceful Shutdown Handler + * + * Orchestrates clean shutdown of the coordination server. + * Handles SIGTERM, SIGINT, SIGHUP with proper cleanup sequence. + */ + +import type { CoordinationServer } from './index.js'; +import type { ProcessManager } from '../process/index.js'; +import type { LogManager } from '../logging/index.js'; + +/** Timeout before force exit in milliseconds */ +const SHUTDOWN_TIMEOUT_MS = 10000; + +/** + * Handles graceful shutdown of the coordination server. + * + * Shutdown sequence: + * 1. Log shutdown initiation + * 2. Stop accepting new connections + * 3. Stop all managed processes + * 4. Close all log file handles (not implemented yet in LogManager) + * 5. Remove PID file + * 6. Exit with code 0 + * + * If cleanup takes longer than SHUTDOWN_TIMEOUT_MS, force exit with code 1. + * Double SIGINT forces immediate exit. + */ +export class GracefulShutdown { + private readonly server: CoordinationServer; + private readonly processManager: ProcessManager; + private readonly logManager: LogManager; + private isShuttingDown = false; + private forceExitCount = 0; + + constructor( + server: CoordinationServer, + processManager: ProcessManager, + logManager: LogManager + ) { + this.server = server; + this.processManager = processManager; + this.logManager = logManager; + } + + /** + * Installs signal handlers for graceful shutdown. + * Call this after the server has started. + */ + install(): void { + // Handle SIGTERM (kill, docker stop) + process.on('SIGTERM', () => this.handleSignal('SIGTERM')); + + // Handle SIGINT (Ctrl+C) + process.on('SIGINT', () => this.handleSignal('SIGINT')); + + // Handle SIGHUP (terminal closed) + process.on('SIGHUP', () => this.handleSignal('SIGHUP')); + } + + /** + * Handles a shutdown signal. + * @param signal - The signal that triggered shutdown + */ + private async handleSignal(signal: string): Promise { + // Handle double SIGINT for force exit + if (signal === 'SIGINT' && this.isShuttingDown) { + this.forceExitCount++; + if (this.forceExitCount >= 1) { + console.log('\nForce exit requested. Exiting immediately.'); + process.exit(1); + } + } + + // Only run shutdown sequence once + if (this.isShuttingDown) { + console.log(`\nAlready shutting down... Press Ctrl+C again to force exit.`); + return; + } + + this.isShuttingDown = true; + console.log(`\nReceived ${signal}, shutting down gracefully...`); + + // Set timeout for force exit + const forceExitTimer = setTimeout(() => { + console.error('Shutdown timeout exceeded. Forcing exit.'); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + + // Ensure timeout doesn't keep the process alive + forceExitTimer.unref(); + + try { + await this.shutdown(); + clearTimeout(forceExitTimer); + console.log('Shutdown complete.'); + process.exit(0); + } catch (error) { + clearTimeout(forceExitTimer); + console.error('Error during shutdown:', (error as Error).message); + process.exit(1); + } + } + + /** + * Performs the shutdown sequence. + */ + async shutdown(): Promise { + // Step 1: Stop the HTTP server (stops accepting new connections) + console.log(' Stopping HTTP server...'); + await this.server.stop(); + + // Step 2: Stop all managed processes + console.log(' Stopping managed processes...'); + await this.processManager.stopAll(); + + // Step 3: Clean up log manager resources (future: close open file handles) + // Currently LogManager doesn't maintain persistent handles that need closing + // This is a placeholder for future cleanup needs + console.log(' Cleaning up resources...'); + + // Step 4: PID file is removed by server.stop() + // Nothing additional needed here + } +}