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.
| Path | Trigger | After the handlers |
|---|---|---|
beforeExit | the event loop emptied | nothing: the process ends as it would have |
exit | process.exit(), or leaving after beforeExit | nothing — Node allows no waiting here, so an async handler is started and not awaited |
signal | SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGBREAK | the same signal is raised again at the process |
uncaught | an uncaught exception | the error is printed and the process exits 1 |
rejection | an unhandled rejection | the 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:
flush— write what must not be lost: logs, telemetry, a partial result.release(the default) — close what the program holds: sockets, child processes, locks.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:
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');work done
flush: awaited before release starts
release: path=beforeExit code=0
restore: last, though it was registered firstA 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:
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');restore: signal SIGTERM
closeout: shutdown deadline of 100ms expired; exiting anyway. Handlers that had not returned: flushLogsWhere the rules live
- Spec: closeout R1–R12.
- API:
closeout,closeout/cursor. - Guides on closeout's site: Exit paths, Phases, The deadline, The terminal and Signals and exit status.
Exit codes and errors
Seven typed exit codes a caller can branch on, one error envelope under --json, and a fixed rule for which stream a failure goes to.
Configuration and precedence
seniority's one fixed order — flag, env, config file, package.json field, default — resolved by a pure function that records where every value came from, so --explain can say what won and what it beat.