burgee
Concepts

Shutdown and terminal restore

closeout's one registry for every way a process leaves: three phases, one deadline for the whole shutdown, the terminal restored last, and a process that dies of the signal it was sent.

What it is

closeout keeps one registry of exit handlers per process and runs it, once, whichever way the process leaves. Every package in the family that changes the terminal — hides the cursor, enters the alternate screen, switches the keyboard to raw mode — registers the undo here, and nowhere else: the family's rules make closeout the one owner of signal handlers, exit hooks, the cursor and raw mode.

Why it exists

A CLI that dies with the cursor hidden, the alternate screen still up or the keyboard still raw leaves the user's terminal broken. Each path out needs the same cleanup: a normal exit, a process.exit(), a Ctrl-C, a SIGTERM from a supervisor, an uncaught throw, an unhandled rejection. Before this rule the family had the cursor restore written three times, in closeout, flagstaff and caique; removing the two copies found that caique never restored the cursor on a signal at all (the header of inline-implementation-lock.test.ts records it).

Every exit path

The registry is installed lazily, on the first onExit, hideCursor, alternateScreen or rawMode; importing the package attaches nothing.

PathTriggerAfter the handlers
beforeExitthe event loop emptiednothing: the process ends as it would have
exitprocess.exit(), or leaving after beforeExitnothing — Node allows no waiting here, so an async handler is started and not awaited
signalSIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGBREAKthe same signal is raised again at the process
uncaughtan uncaught exceptionthe error is printed and the process exits 1
rejectionan unhandled rejectionthe same

Every handler receives one record, { path, signal, code, error }. A program that installed its own listener for a signal or a crash keeps it: closeout runs the handlers and leaves the exit to the program. Pinned by every door out leaves the terminal as it was found, last in restore.test.ts and the exit-path matrix in matrix.test.ts.

Phases

onExit(handler, phase) takes one of three phases, which run in this order:

  1. flush — write what must not be lost: logs, telemetry, a partial result.
  2. release (the default) — close what the program holds: sockets, child processes, locks.
  3. restore — give the terminal back.

A phase starts only when every handler of the one before has settled. Inside a phase all handlers are started in registration order and then awaited together. A handler that throws or rejects is reported on stderr and the rest still run. An unknown phase is refused when the handler is registered. Plugins may add handlers to flush and release only; refuses a handler in the restore phase in plugin.test.ts is why "restore last" is a rule and not a convention.

The deadline

The whole shutdown has one clock: 2,000 ms by default, set with install({ deadline }). A deadline that cannot bound anything — Infinity, zero, a negative number, NaN — is refused where it is written, as a usage error (a deadline that cannot bound anything is refused where it is written in deadline.test.ts).

When the deadline passes, closeout stops waiting. The phases not yet reached are still started, so the terminal is still restored; the report says timedOut: true and lists the handlers that had not returned; and stderr gets one line naming them. The process then leaves the way the trigger decided: a signal is raised again, a crash exits 1. The deadline never changes the exit code (a breached deadline exits with the signal’s code, not with one a handler set on its way past in matrix.test.ts).

Restore last

hideCursor(stream), alternateScreen(stream) and rawMode(input) each make the change and, in the same call, register its undo in the restore phase. Each undo runs at most once, and calling the returned function early undoes it and unregisters it. A stream that is not a terminal gets no escape and no registration; raw mode someone else turned on is left on. On the exit path, which cannot wait, a shutdown still waiting on flush runs the restore phase synchronously before the process ends ('exit' arriving before the deadline, while the shutdown still waits on flush in restore.test.ts).

A second signal

A shutdown runs once. A second Ctrl-C while the first shutdown is still waiting on a handler joins the shutdown already in flight rather than starting another or cutting it short; the process then dies of the first signal, after restore has run (a second signal (%s) while a handler holds the first shutdown in terminal-restore.test.ts). The deadline, not a second signal, is what bounds a hung handler.

Dying of the signal

After the handlers, closeout removes its listener and sends the process the same signal again. The process then dies of the signal, which is what a shell, make or a CI runner reads to tell "interrupted" from "failed". The POSIX codes (130 for SIGINT, 143 for SIGTERM, 129 for SIGHUP) are only a fallback, for a runtime that cannot raise a signal at itself (%s: the cursor comes back and the process dies of the signal, not of an exit code in signal.test.ts). The exit-code contract counts on this.

Run it

Phases, not registration order, decide when a handler runs:

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

onExit(() => console.log('restore: last, though it was registered first'), 'restore');
onExit(async () => {
  await new Promise((resolve) => setTimeout(resolve, 20));
  console.log('flush: awaited before release starts');
}, 'flush');
onExit((report) => console.log(`release: path=${report.path} code=${report.code}`));

console.log('work done');
node phases.mjs
work done
flush: awaited before release starts
release: path=beforeExit code=0
restore: last, though it was registered first

A handler that never settles, a deadline of 100 ms, and a SIGTERM. The restore still runs, the hung handler is named, and the process dies of the signal:

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

const closeout = install({ deadline: 100 });
closeout.onExit(function flushLogs() {
  return new Promise(() => {}); // never settles
}, 'flush');
closeout.onExit((report) => console.log(`restore: ${report.path} ${report.signal}`), 'restore');

setInterval(() => {}, 1000);
process.kill(process.pid, 'SIGTERM');
node deadline.mjs
restore: signal SIGTERM
closeout: shutdown deadline of 100ms expired; exiting anyway. Handlers that had not returned: flushLogs

Where the rules live

On this page