closeout
Coming from…

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() and process.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:

passingrate
closeout/signal-exit134 / 13599.3%
signal-exit itself (control)134 / 13599.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. path says which door it was, and error is what was thrown or rejected on the two paths that have one.
  • Shutdown cannot hang. A bounded deadline — DEFAULT_DEADLINE is 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, restore order, 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() and reportToEvent() project, so a --json line 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.

On this page