/** * shutdown.ts — central registry for process-exit cleanup hooks. * * Why this exists * ─────────────── * Phase B + Phase C each installed their own `process.on('SIGTERM', …)` * and `process.on('SIGINT', …)` handlers (SSH console teardown, then * BackendStatusRegistry shutdown). Stacking N independent handlers * per signal has three issues: * * 1. Node's default MaxListeners is 10. Phase D will add more * subsystems; we'll start emitting MaxListenersExceededWarning. * 2. No ordering guarantee. If two hooks both touch shared state * (e.g. a logger flush + a worker drain), interleaving is * non-deterministic. * 3. No idempotence guard. Multiple signals (SIGTERM then SIGINT) * would re-run every hook. * * The registry solves all three: subsystems register a single hook * each, the registry installs exactly one listener per signal, hooks * run concurrently (Promise.allSettled — one slow hook doesn't gate * the others), and a `shutdownStarted` flag prevents re-entry. * * Test surface * ──────────── * `runShutdown` and `__resetShutdownForTests` are exported so unit * tests can drive the pure logic without raising real signals (which * would terminate the test runner). `installSignalHandlers` is the * production entry point and is called once from `startServer`. */ import { logger } from '../logger.js'; export type ShutdownHook = () => Promise | void; interface RegisteredHook { name: string; fn: ShutdownHook; } const hooks: RegisteredHook[] = []; let shutdownStarted = false; let signalsInstalled = false; let exitFn: (code: number) => void = (code) => process.exit(code); /** * Register a hook to run during graceful shutdown. * * `name` is used in shutdown logs only — it should describe the * subsystem (e.g. `"ssh-console"`, `"backend-status-registry"`) so * stuck or slow shutdowns are diagnosable from the log line. * * Hooks may be sync or return a Promise. Errors are caught and * logged; one hook's failure never blocks the others. */ export function registerShutdownHook(name: string, fn: ShutdownHook): void { hooks.push({ name, fn }); } /** * Drain all registered hooks and exit the process. * * Concurrent (Promise.allSettled) rather than sequential because the * hooks operate on independent subsystems — sequential would just * sum their latencies (and BackendStatusRegistry.stop alone can take * up to ~3s waiting for in-flight probes to abort). * * Idempotent: if shutdown is already in progress, second calls are * silently dropped (no double-drain, no double-exit). */ export async function runShutdown(signal: string): Promise { if (shutdownStarted) return; shutdownStarted = true; logger.info(`[shutdown] received ${signal}, draining ${hooks.length} hook(s)`); const results = await Promise.allSettled( hooks.map(async (h) => { try { await h.fn(); } catch (e) { // Re-throw so allSettled records `rejected` with the original // reason; the catch is here only to ensure sync throws surface // the same way as async rejections. throw e instanceof Error ? e : new Error(String(e)); } }), ); for (let i = 0; i < results.length; i++) { const r = results[i]!; const h = hooks[i]!; if (r.status === 'rejected') { const reason = r.reason instanceof Error ? r.reason.message : String(r.reason); logger.warn(`[shutdown] hook ${h.name} rejected: ${reason}`); } } exitFn(0); } /** * Install the SIGTERM / SIGINT listeners exactly once. * * Safe to call multiple times — subsequent calls are no-ops so unit * tests and integration paths can both invoke it without doubling * the listeners. */ export function installSignalHandlers(): void { if (signalsInstalled) return; signalsInstalled = true; process.on('SIGTERM', () => { void runShutdown('SIGTERM'); }); process.on('SIGINT', () => { void runShutdown('SIGINT'); }); } /** * Test-only reset. Clears registered hooks, the started flag, the * installed-signals flag, and the exit function. Real production * code must never call this — the singleton state is the entire * point of the registry. */ export function __resetShutdownForTests(opts?: { exitFn?: (code: number) => void }): void { hooks.length = 0; shutdownStarted = false; signalsInstalled = false; exitFn = opts?.exitFn ?? ((code) => process.exit(code)); } /** Test-only accessor for the current registered-hook count. */ export function __getRegisteredHookCountForTests(): number { return hooks.length; }