Coming from signal-exit
A signal-exit alternative with a drop-in path: import { onExit } from closeout/signal-exit, graded 134 / 135 by signal-exit's own test suite, level with signal-exit itself — then an onExit that runs once on every exit path under a bounded deadline, and reports as --json or an agent event.
closeout is a signal-exit alternative you adopt by changing one import.
closeout/signal-exit is signal-exit 4's API, and signal-exit's own test suite is the grade.
Migrate from signal-exit in one import
- import { onExit } from 'signal-exit';
+ import { onExit } from 'closeout/signal-exit';Everything else stays: onExit(handler, { alwaysLast }) returns the function that removes
the handler, the handler is called with (code, signal), and returning true from it
claims the signal so the process lives on. load(), unload() and signals are exported
beside it, and closeout/signal-exit/signals is signal-exit's signal-exit/signals. So are
its semantics:
- A fatal signal still kills. When the only listeners for a signal are signal-exit's,
the handlers run and the signal is re-raised, so a parent sees
signal: 'SIGHUP', not an exit code. - Every exit path is seen,
process.exit()andprocess.reallyExit()included. - One emitter per process, under the key signal-exit itself uses, so a program with this façade and a transitive copy of real signal-exit runs every handler once and re-raises once.
Like signal-exit, the façade ships twice: require('closeout/signal-exit') loads a CommonJS
file and import an ES one over the same code and the same emitter.
It is graded against signal-exit 4: version 3's callable default,
require('signal-exit')(handler), is not reproduced.
The two neighbours in this layer have one-import paths too, each graded by its incumbent's own suite:
- import exitHook, { asyncExitHook, gracefulExit } from 'exit-hook';
- import restoreCursor from 'restore-cursor';
+ import exitHook, { asyncExitHook, gracefulExit } from 'closeout/exit-hook';
+ import restoreCursor from 'closeout/restore-cursor';Is closeout compatible with signal-exit?
Graded, not claimed. signal-exit's own suite, vendored at 4.1.0 and unmodified apart from
the import specifier, runs against closeout/signal-exit beside a control that runs it
against real signal-exit:
| passing | rate | |
|---|---|---|
closeout/signal-exit | 134 / 135 | 99.3% |
| signal-exit itself (control) | 134 / 135 | 99.3% |
From Compatibility, which npm run compat:page generates from the
oracle's last run; that page is the authority, and its front-end column reads closeout
because the row's import is composed onto the package root as closeout/signal-exit.
The one case short fails against signal-exit itself. It is signal-exit-test.ts >
does not exit if user handles signal: its fixture re-sends SIGTERM from a setTimeout
inside the listener and expects the fourth to kill the process, and on current Node the
process exits cleanly after the first. That was measured on Node 22, 24 and 26 on macOS and
on Node 24 in a Linux container, with the fixture requiring signal-exit directly; signal-exit's
last release, 2023-07-29, predates all of them. The Compatibility page lists it as a declared
control failure of exactly one.
The suite registers 135 cases on Linux and 127 on macOS, because Linux adds four signals —
SIGIO, SIGPOLL, SIGPWR, SIGSTKFLT — to two loops over the signal list. The reference
is Linux's 135, and all eight extra cases pass against signal-exit and against closeout.
What you gain over signal-exit
closeout/signal-exit is deliberately signal-exit's behaviour, so the façade alone changes
the package name and not what your process does. The gain is closeout's own onExit, which
you adopt a handler at a time:
- import { onExit } from 'closeout/signal-exit';
- const off = onExit((code, signal) => {
+ import { onExit } from 'closeout';
+ const off = onExit(({ path, code, signal, error }) => {
releaseTheLock();
});- Exactly once, on every door. A normal exit, an emptied event loop, Ctrl-C,
SIGTERM,SIGHUP,SIGQUIT, an uncaught throw and an unhandled rejection all reach the same handler, once, even when two fire at the same moment.pathsays which door it was, anderroris what was thrown or rejected on the two paths that have one. - Shutdown cannot hang. A bounded deadline —
DEFAULT_DEADLINEis 2000 ms — ends a shutdown whose handlers do not return, and the result names the ones that had not. - Phases, not registration order. Handlers run in
flush,release,restoreorder, so the cursor comes back after the file is flushed, whichever registered first. - One record, three readers. The record your handler receives is what
reportToJson()andreportToEvent()project, so a--jsonline and an agent event cannot disagree with what the handler was told.
SIGKILL is not deliverable to a listener, by design, and neither package claims it.
When to switch from signal-exit
- You want signal-exit's behaviour, and exit-hook's and restore-cursor's, from one package.
- A shutdown handler that never returns has hung your CLI or your CI job.
- An uncaught throw or an unhandled rejection has to reach your cleanup.
The API, the phases and the deadline are on closeout.
Compatibility
How closeout's three drop-ins are graded — each incumbent's own test suite, unedited — the current grades, including signal-exit's 134 / 135 level with signal-exit itself, and the differences that remain.
Coming from exit-hook
An exit-hook alternative with a drop-in path: import exitHook from closeout/exit-hook, graded 21 / 21 by exit-hook's own test suite — then phases that hand the terminal back last, and an onExit that reaches every exit path under one bounded deadline.