closeout
Guides

Every exit path, exactly once

The five ways a Node.js process ends, which ones can await, and how closeout runs each handler once when two fire together — and keeps going when one throws.

A program leaves by one of five doors, and closeout listens on all of them:

pathwhat triggers itcan a handler await?
beforeExitthe event loop emptied: the program simply finishedyes
signalSIGINT (Ctrl-C), SIGTERM, SIGHUP, SIGQUIT, SIGBREAKyes, up to the deadline
uncaughtan exception nobody caughtyes, up to the deadline
rejectiona rejected promise nobody awaitedyes, up to the deadline
exitprocess.exit(), or the end after any of the aboveno — Node is already leaving

A handler registered on process.on('exit') alone sees only the last row, and cannot await anything there. That is why a Ctrl-C so often leaves a lock file behind: the one event the handler listened for was never the one that fired.

Exactly once

Two doors often open together. Ctrl-C twice, or a signal and the exit that follows it, or a throw inside a shutdown that was already running. closeout treats every arrival after the first as the same shutdown: each handler runs once, and a later trigger waits for the first run instead of starting a second one.

once.mjs
import { onExit } from 'closeout';

let runs = 0;
onExit(() => {
  runs += 1;
  console.log(`cleanup run ${runs}`);
});

setTimeout(() => {}, 10_000);
process.kill(process.pid, 'SIGTERM');
process.kill(process.pid, 'SIGTERM');
node once.mjs
cleanup run 1

A handler that ran twice would release a lock that another process has taken since.

One handler's failure is its own

A handler that throws is reported on stderr, and every handler after it still runs. Shutdown is the worst place for an exception to end a loop early, because the handler that restores the terminal is often the last one registered.

throws.mjs
import { install } from 'closeout';

const { onExit } = install({ onError: (error) => console.log(`reported: ${error.message}`) });

onExit(() => {
  throw new Error('the lock file was already gone');
});
onExit(() => console.log('the next handler still ran'));
node throws.mjs
reported: the lock file was already gone
the next handler still ran

onError defaults to printing the error on stderr. A program that owns a logger passes its own, as above.

Synchronous and asynchronous handlers

A handler may return a promise, and on every path but exit it is awaited — within the deadline. On exit there is no time left: closeout calls every handler synchronously, abandons any promise one returns, and names it as unfinished in the report. So work that has to happen on process.exit() should be synchronous (writeFileSync, not writeFile), and work that needs to await belongs in a program that leaves by returning, by a signal or by a throw.

Handlers the program owns

closeout never takes a decision away from a program that made one. If the program has its own listener for a signal, or for uncaughtException, closeout runs the handlers and then stands aside: the program's listener decides whether and how to exit. See Signals and exit status.

What is tested

  • registry.test.ts: a handler runs once however many times shutdown is asked for; the rest run after one throws.
  • restore.test.ts: every door — five signals, exit, beforeExit, a throw and a rejection — runs the handlers.
  • matrix.test.ts: a handler that never returns does not stop any of those doors from ending the process.

On this page