Testing shutdown
Test exit handlers without killing the test runner: drive a createRegistry directly, or install closeout against a fake process and assert how it was asked to leave.
Everything that decides what a shutdown does lives in a registry with no process attached, so most of it can be tested with no signal at all.
The registry alone
createRegistry() takes the same options as install(). registry.run(info) runs every
handler, phase by phase, inside the deadline, and resolves with the report:
import assert from 'node:assert/strict';
import { createRegistry } from 'closeout';
const order = [];
const registry = createRegistry({ deadline: 50, onTimeout: () => {} });
registry.add(() => order.push('restore'), 'restore');
registry.add(() => new Promise(() => {}), { phase: 'flush', label: 'stuck' });
registry.add(() => order.push('release'));
const report = await registry.run({ code: null, signal: 'SIGTERM' });
assert.deepEqual(order, ['release', 'restore']);
assert.equal(report.timedOut, true);
assert.deepEqual(report.unfinished, ['stuck']);
assert.equal(report.path, 'signal');
console.log('ok');okKeep the deadline short in a test: it is the longest a hung handler can make the test wait.
Against a fake process
install({ process }) wires the same registry to anything that looks enough like a process:
on, removeListener, listenerCount, exit, kill, pid and stderr. An
EventEmitter supplies the first three. kill is required, not optional, because re-raising
the signal is part of the contract, and a fake that could quietly skip it would hide exactly
the bug it should catch.
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';
import { install } from 'closeout';
/** A process that records how it was asked to leave instead of leaving. */
function fakeProcess() {
const proc = Object.assign(new EventEmitter(), {
pid: 4242,
stderr: { write: () => true },
left: [],
exit: (code) => proc.left.push(`exit ${code}`),
kill: (_pid, signal) => proc.left.push(`died of ${signal}`),
});
return proc;
}
const proc = fakeProcess();
const seen = [];
const closeout = install({ process: proc, deadline: 50, onTimeout: (report) => seen.push(`hung: ${report.unfinished}`) });
closeout.onExit(({ path, signal }) => seen.push(`${path} ${signal}`));
closeout.onExit(() => new Promise(() => {}), { phase: 'flush', label: 'upload' });
proc.emit('SIGTERM');
await sleep(100);
assert.deepEqual(seen, ['signal SIGTERM', 'hung: upload']);
assert.deepEqual(proc.left, ['died of SIGTERM', 'exit 143']);
console.log('ok');okTwo things to read off that:
- The
releasehandler ran while theflushhandler was still hung, and the breach was reported after it: past the deadline, the later phases are run before closeout leaves. - The fake's
killreturns, where a real one would have ended the process, so closeout falls through toexit(143). That fallback is what keeps a runtime that cannot raise the signal from staying alive.
On a real process
What only a real process can show — that it dies of the signal, and what reached the terminal
— closeout's own suite checks by spawning a child: see
signal.test.ts
and
terminal-restore.test.ts.
Every example on this site is run the same way.
A server that stops gracefully
Stop accepting connections on SIGTERM, flush the access log first, and still die of the signal so the orchestrator sees a clean stop — with a deadline that bounds a connection that will not close.
Incremental migration
Move from exit-hook, signal-exit or restore-cursor to closeout's own onExit a handler at a time: swap the import first, then move handlers, and know what changes when the last one moves.