DC-067: graceful shutdown coordinator (EventEmitter, 10s drain, idempotent)
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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() {
|
||||
// Synchronous close callback. async (setImmediate) callbacks would fire
|
||||
// AFTER the test that triggered shutdown has ended, hitting the real
|
||||
// process.exit in the restored mock window.
|
||||
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 with info/warn is required');
|
||||
expect(() => createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: { foo: 'bar' },
|
||||
managers: [],
|
||||
})).toThrow('log with info/warn is required');
|
||||
});
|
||||
|
||||
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('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', 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 one microtask + macrotask for the async chain to settle.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(order).toEqual(['first', 'second', 'third']);
|
||||
});
|
||||
|
||||
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 = { close: jest.fn(/* never calls back */) };
|
||||
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',
|
||||
'drain timeout (1000ms) reached, force-exiting',
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('clears force-exit timer when server.close fires first', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const server = makeFakeServer(); // calls back on setImmediate
|
||||
const log = makeFakeLog();
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
drainTimeoutMs: 1000,
|
||||
managers: [],
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
// Flush the setImmediate so server.close callback fires.
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.advanceTimersByTime(0);
|
||||
|
||||
expect(exitCalls).toEqual([0]);
|
||||
|
||||
// Even after the full drain timeout, no extra exit should fire
|
||||
// (because we cleared the timer in the close callback).
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user