- Force-exit timer now distinguishes serverClosed from managersStopped. Was: if (closed) return → suppressed timer when manager hung after server.close fired (the original bug). Now: if (managersStopped) return → timer fires only when full drain (HTTP close + all managers stopped) completes before the deadline. - Added _safeEmit() helper that wraps this.emit() so a buggy listener throwing during 'shutdown' or 'closed' doesn't abort the shutdown sequence. Each failed listener is logged via the structured logger. - Added 4 new tests covering: hung manager after HTTP close, throwing shutdown listener, throwing closed listener, and the fast-drain happy path that clears the timer cleanly.
465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|