EkoLite, our small real time backend, recently learned to shut down properly. On SIGINT or SIGTERM it closes the websocket, which takes the web server with it, then closes the Mongo connection, then exits. Stop taking requests before you drop the database underneath them. Tidy.
Then someone asked the obvious question. What if the close never finishes?
The hang nobody sees
Both halves of that goodbye can stall. A Mongo driver can sit waiting on a server that vanished mid conversation. A websocket close waits for clients to finish their closing handshake, and a wedged client never does. If either promise refuses to settle, the line that says process.exit(0) is simply never reached.
Locally that looks like Ctrl+C doing nothing. In a container it is quieter and worse: SIGTERM, then silence, then the orchestrator loses patience and sends SIGKILL. The process does die, eventually, but you lose the clean exit code and any clue about what hung. The failure leaves no story behind.
Arm the deadline first
The fix is to start a timer before you start saying goodbye, and let whichever finishes first decide the exit:
const SHUTDOWN_GRACE_MS = 5000;
const deadline = setTimeout(() => {
console.error('shutdown timed out, exiting hard');
process.exit(1);
}, SHUTDOWN_GRACE_MS);
await app.close();
clearTimeout(deadline);
process.exit(0);
Four small decisions are hiding in those ten lines, and each one earns its place.
You do not need Promise.race. It is tempting to race the close against a timeout promise, but process.exit inside the timer callback ends the process no matter what the awaited close is doing. A plain setTimeout is the whole mechanism. Racing promises would be a more elaborate way of saying the same thing.
The clearTimeout is about honesty, not liveness. On the happy path the explicit exit(0) wins either way, so skipping the clearTimeout would still work. But cancelling the deadline states the intent: a clean close stands the guard down. Sloppiness that production happens to mask is still sloppiness, and we will come back to how a test can see it.
Pick the grace period relative to whoever kills you next. Kubernetes gives you thirty seconds by default, docker stop gives you ten. Anything comfortably under that window means you own the failure, log it in your own words, and choose your exit code, rather than being SIGKILLed mid flush. Five seconds is generous for a socket and a database client.
Exit codes carry the story. Zero means everything closed properly. One means we gave up waiting. Your orchestrator and your logs can now tell a clean stop from a hung one, which is the entire point of doing better than SIGKILL.
Two refinements round it off. A second signal should mean I meant it: a small flag makes the first signal graceful and the second an immediate hard exit, instead of re entering the handler and closing things twice. And a close that rejects, rather than hangs, wants a catch that logs and exits 1, so the last thing in the log is your sentence and not an unhandled rejection trace.
The part you can test
Here is the uncomfortable bit. Those ten lines live in the boot file, the humble shell that reads config and starts the server, and the shell is untested on purpose. Signals, timers and process.exit are exactly the things a test runner cannot let you touch. Calling the real exit kills the test along with the process.
The move is the same one that tamed Mongo and the websocket in this codebase: put the awkward thing behind a nullable wrapper. A ProcessWrapper owns signals in, exit codes out, and the deadline timer. The real one delegates to the process. The nulled one lets a test simulate a signal, advance time by hand, and record exits in an output tracker instead of dying.
The shutdown policy then becomes a small class that takes anything closable plus that wrapper, and the payoff is easiest to see as two call sites. In production, the boot file wires the real world and steps back:
const app = App.create(config);
const server = await createServer(app);
await server.listen({ port: config.port, host: '0.0.0.0' });
new Shutdown(app, ProcessWrapper.create()).arm();
Nobody calls anything after arm(). From here the operating system is the caller, and the policy waits for it.
In a test, the same class gets the nulled process, and the test takes over every role that is normally unownable: who sends signals, when time passes, and what exit does.
const proc = ProcessWrapper.createNull();
const exits = proc.trackExits();
new Shutdown(closable, proc).arm();
proc.simulateSignal('SIGINT'); // the test plays the operating system
proc.advanceTime(5000); // the test plays the clock
expect(exits.data).toEqual([{ code: 1 }]); // recorded, not executed
The class under test is byte for byte the one that runs in production. Only the world it was handed changed, and no code between the two ever asks which world it got. Every decision above turns into a plain assertion in this style. Deliver a signal, resolve the close, expect exit 0. Deliver a signal, leave the close hanging, advance time past the grace, expect exit 1. Deliver two signals, expect the second to exit immediately.
And the nulled process pays an unexpected dividend. A real process stops existing at exit(0), so it can never testify about the timer you forgot to cancel. The nulled one outlives the exit. Resolve the close, advance time past the grace anyway, and assert that exactly one exit was recorded. The missing clearTimeout, invisible in production, fails a test.
The move to take home
When a policy worth testing grows inside an untested shell, do not test the shell. Extract the policy, wrap the part of the world it touches, and keep the shell to wiring. The shell stays humble, the policy gets a test for every sentence of its design, and the next person who wonders why the deadline is five seconds finds the answer written down twice, once in a test name and once in whoever kills you next.


