/** * Tests for the graceful shutdown coordinator (DC-067). * * Covers: * - Constructor rejects bad inputs * - shutdown() emits 'shutdown' event with the signal name * - shutdown() stops each manager in declaration order * - shutdown() is idempotent — second call logs and returns * - shutdown() force-exits after drainTimeoutMs if server.close never fires * - shutdown() clears the force-exit timer when server.close fires first * - shutdown() catches manager.stop() throws so one bad manager doesn't * prevent the others from being stopped * - installSignalHandlers() registers for SIGTERM and SIGINT by default * * process.exit is mocked so tests don't actually kill the test runner. */ 'use strict'; const EventEmitter = require('events'); const { createShutdownCoordinator, installSignalHandlers, DEFAULT_DRAIN_TIMEOUT_MS, ShutdownCoordinator, } = require('../src/utilities/shutdown'); describe('ShutdownCoordinator (DC-067)', () => { let exitMock; let exitCalls; beforeEach(() => { exitCalls = []; // Use jest.spyOn so the mock is restored in afterEach (via clearMocks + // restoreMocks: true in jest.config.js). Direct assignment to process.exit // doesn't suppress Jest's process.exit watchlist which fails the test. exitMock = jest.spyOn(process, 'exit').mockImplementation((code) => { exitCalls.push(code); // Returning undefined prevents the test runner from actually exiting. return undefined; }); }); afterEach(() => { exitMock.mockRestore(); jest.clearAllTimers(); }); function makeFakeServer({ closeBehavior = 'sync' } = {}) { // 'sync' close calls back immediately. // 'never' close never calls back (used to test force-exit). if (closeBehavior === 'never') { return { close: jest.fn() }; } return { close: jest.fn((cb) => { cb(); }) }; } function makeFakeLog() { return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; } describe('constructor', () => { test('throws if server is missing', () => { expect(() => createShutdownCoordinator({ log: makeFakeLog(), managers: [] })) .toThrow('server is required'); }); test('throws if log is missing or invalid', () => { expect(() => createShutdownCoordinator({ server: makeFakeServer(), managers: [] })) .toThrow('log must have info'); expect(() => createShutdownCoordinator({ server: makeFakeServer(), log: { foo: 'bar' }, managers: [], })).toThrow('log must have info'); expect(() => createShutdownCoordinator({ server: makeFakeServer(), log: { info: () => {}, warn: () => {} }, // missing error managers: [], })).toThrow('log must have info'); // A log with all three methods should NOT throw. expect(() => createShutdownCoordinator({ server: makeFakeServer(), log: { info: () => {}, warn: () => {}, error: () => {} }, managers: [], })).not.toThrow(); }); test('uses DEFAULT_DRAIN_TIMEOUT_MS when drainTimeoutMs is not finite positive', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), drainTimeoutMs: 0, managers: [], }); expect(c.drainTimeoutMs).toBe(DEFAULT_DRAIN_TIMEOUT_MS); const c2 = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), drainTimeoutMs: NaN, managers: [], }); expect(c2.drainTimeoutMs).toBe(DEFAULT_DRAIN_TIMEOUT_MS); const c3 = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), drainTimeoutMs: 5000, managers: [], }); expect(c3.drainTimeoutMs).toBe(5000); }); test('defaults managers to [] when not an array', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), }); expect(c.managers).toEqual([]); }); test('is an EventEmitter', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); expect(c).toBeInstanceOf(EventEmitter); expect(c).toBeInstanceOf(ShutdownCoordinator); }); }); describe('shutdown()', () => { test('emits shutdown event with signal name', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const handler = jest.fn(); c.on('shutdown', handler); c.shutdown('SIGTERM'); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith('SIGTERM'); }); test('swallows exceptions thrown by shutdown event listeners', () => { const log = makeFakeLog(); const server = makeFakeServer(); const c = createShutdownCoordinator({ server, log, managers: [], }); c.on('shutdown', () => { throw new Error('listener boom'); }); // shutdown() must NOT propagate the exception — that would abort // the entire shutdown sequence before server.close is even called. expect(() => c.shutdown('SIGTERM')).not.toThrow(); expect(log.error).toHaveBeenCalledWith( 'shutdown', "event listener for 'shutdown' threw", expect.objectContaining({ error: 'listener boom' }), ); // server.close should still have been called. expect(server.close).toHaveBeenCalledTimes(1); }); test('swallows exceptions thrown by closed event listeners', async () => { const log = makeFakeLog(); const server = makeFakeServer(); const c = createShutdownCoordinator({ server, log, managers: [], }); c.on('closed', () => { throw new Error('closed listener boom'); }); // process.exit is mocked; we just verify the throw doesn't bubble. c.shutdown('SIGTERM'); await new Promise((resolve) => setImmediate(resolve)); // The closed listener threw but the exit still got recorded. expect(exitCalls).toEqual([0]); }); test('calls server.close() once', () => { const server = makeFakeServer(); const c = createShutdownCoordinator({ server, log: makeFakeLog(), managers: [], }); c.shutdown('SIGTERM'); expect(server.close).toHaveBeenCalledTimes(1); }); test('stops each manager in declaration order AFTER server.close fires', async () => { const order = []; const managers = [ { name: 'first', stop: jest.fn(() => { order.push('first'); }) }, { name: 'second', stop: jest.fn(() => { order.push('second'); }) }, { name: 'third', stop: jest.fn(() => { order.push('third'); }) }, ]; const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers, }); c.shutdown('SIGTERM'); // Wait for the async chain (server.close → _stopManagersInOrder → // process.exit) to settle. The mock exit is synchronous so this // resolves once all microtasks drain. await new Promise((resolve) => setImmediate(resolve)); expect(order).toEqual(['first', 'second', 'third']); }); test('does NOT stop managers until server.close callback fires', () => { const order = []; // Use a server whose close callback fires only when we manually call it. let deferredCloseCb; const server = { close: jest.fn((cb) => { deferredCloseCb = cb; }), }; const managers = [ { name: 'first', stop: jest.fn(() => { order.push('first'); }) }, ]; const c = createShutdownCoordinator({ server, log: makeFakeLog(), managers, }); c.shutdown('SIGTERM'); // server.close has been called but its callback hasn't fired yet. expect(server.close).toHaveBeenCalledTimes(1); // Manager has NOT been stopped yet — server is still draining. expect(order).toEqual([]); // Now fire the deferred callback to simulate drain completion. deferredCloseCb(); // Manager stopped AFTER server.close fired. expect(order).toEqual(['first']); }); test('continues stopping remaining managers if one throws', async () => { const order = []; const managers = [ { name: 'first', stop: jest.fn(() => { order.push('first'); }) }, { name: 'broken', stop: jest.fn(() => { throw new Error('boom'); }) }, { name: 'third', stop: jest.fn(() => { order.push('third'); }) }, ]; const log = makeFakeLog(); const c = createShutdownCoordinator({ server: makeFakeServer(), log, managers, }); c.shutdown('SIGTERM'); await new Promise((resolve) => setImmediate(resolve)); expect(order).toEqual(['first', 'third']); expect(log.warn).toHaveBeenCalledWith( 'shutdown', 'manager stop failed: broken', expect.objectContaining({ error: 'boom' }), ); }); test('is idempotent — second shutdown() returns without re-running', () => { const server = makeFakeServer(); const c = createShutdownCoordinator({ server, log: makeFakeLog(), managers: [{ name: 'm', stop: jest.fn() }], }); c.shutdown('SIGTERM'); c.shutdown('SIGTERM'); c.shutdown('SIGINT'); expect(server.close).toHaveBeenCalledTimes(1); expect(c.isShuttingDown()).toBe(true); }); test('isShuttingDown() flips false→true on first shutdown call', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); expect(c.isShuttingDown()).toBe(false); c.shutdown('SIGTERM'); expect(c.isShuttingDown()).toBe(true); }); test('force-exits after drainTimeoutMs if server.close never fires', () => { jest.useFakeTimers(); try { const server = makeFakeServer({ closeBehavior: 'never' }); const log = makeFakeLog(); const c = createShutdownCoordinator({ server, log, drainTimeoutMs: 1000, managers: [], }); c.shutdown('SIGTERM'); expect(exitCalls).toEqual([]); jest.advanceTimersByTime(999); expect(exitCalls).toEqual([]); jest.advanceTimersByTime(2); expect(exitCalls).toEqual([0]); expect(log.warn).toHaveBeenCalledWith( 'shutdown', expect.stringContaining('drain timeout (1000ms) reached before HTTP server closed'), ); } finally { jest.useRealTimers(); } }); test('force-exits after drainTimeoutMs if manager.stop() hangs after HTTP close', () => { jest.useFakeTimers(); try { const server = makeFakeServer(); // calls back immediately const log = makeFakeLog(); // Manager that NEVER resolves — simulates a hung cleanup. const hungManager = { name: 'hung', stop: jest.fn(() => new Promise(() => {})), // never resolves }; const c = createShutdownCoordinator({ server, log, drainTimeoutMs: 1000, managers: [hungManager], }); c.shutdown('SIGTERM'); // After the synchronous shutdown() call: server.close has fired // (serverClosed=true), but hungManager.stop() has been called and // its promise is pending. managersStopped is still false. // process.exit should NOT have been called yet. expect(exitCalls).toEqual([]); jest.advanceTimersByTime(1001); // Now the safety-net timer fires — force-exit because manager hung. expect(exitCalls).toEqual([0]); expect(log.warn).toHaveBeenCalledWith( 'shutdown', expect.stringContaining('after HTTP close (manager hung)'), ); } finally { jest.useRealTimers(); } }); test('clears force-exit timer when manager drain completes promptly', () => { jest.useFakeTimers(); try { const server = makeFakeServer(); // calls back on the same tick const log = makeFakeLog(); // Quick-stopping manager. The close callback awaits stop(), // which resolves immediately, so managersStopped flips true // and the safety-net timer is cleared before it can fire. const fastManager = { name: 'fast', stop: jest.fn(() => Promise.resolve()), }; const c = createShutdownCoordinator({ server, log, drainTimeoutMs: 1000, managers: [fastManager], }); c.shutdown('SIGTERM'); // Flush microtasks so the close callback's await stop() resolves, // managersStopped flips true, the timer is cleared, and // process.exit(0) is recorded exactly once. return Promise.resolve().then(() => Promise.resolve()).then(() => { expect(exitCalls).toEqual([0]); // Advance well past the drain timeout — no extra exit should fire. jest.advanceTimersByTime(5000); expect(exitCalls).toEqual([0]); }); } finally { jest.useRealTimers(); } }); }); describe('installSignalHandlers()', () => { test('registers listeners on the given signals', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const shutdownSpy = jest.spyOn(c, 'shutdown'); installSignalHandlers(c); // Emit fake signals through process.emit to verify the listener was // registered (process.on listens to the process EventEmitter). process.emit('SIGTERM'); process.emit('SIGINT'); expect(shutdownSpy).toHaveBeenCalledWith('SIGTERM'); expect(shutdownSpy).toHaveBeenCalledWith('SIGINT'); }); test('accepts custom signal list', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const shutdownSpy = jest.spyOn(c, 'shutdown'); installSignalHandlers(c, ['SIGHUP']); process.emit('SIGHUP'); expect(shutdownSpy).toHaveBeenCalledWith('SIGHUP'); }); test('is idempotent — calling installSignalHandlers twice does not double-register', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const shutdownSpy = jest.spyOn(c, 'shutdown'); installSignalHandlers(c); installSignalHandlers(c); // second call installSignalHandlers(c); // third call // The installedSignals tracker should have one entry per signal. expect(c._installedSignals).toEqual(['SIGTERM', 'SIGINT']); process.emit('SIGTERM'); expect(shutdownSpy).toHaveBeenCalledTimes(1); }); }); });