closeout
Runs every exit handler exactly once on every path out of the process, restores the terminal, and holds shutdown to a deadline so it cannot hang. Zero dependencies. Drop-in paths for signal-exit, exit-hook, restore-cursor, cli-cursor and onetime.
closeout runs every exit handler exactly once, on every path out of the process, restores
the terminal, and holds shutdown to a deadline so it cannot hang. Every handler gets one
record, and reportToJson() and reportToEvent() project that record as a --json line or
an agent event. Zero dependencies. Node builtins only.
Drop-in for signal-exit, exit-hook and restore-cursor: npx burgee migrate moves
the imports to closeout's subpaths, each graded by the incumbent's own suite.
Over the platform. The builtin is process.on(signal) beside process.on('exit'). An
exit listener runs on a code and never on a signal, and a signal listener replaces the default
exit rather than running before it. closeout runs each handler exactly once on every path, then
leaves the way the process would have. The repository's absorption probe asks every supported
Node line how many of those paths the builtin covers alone.
Close everything out. To close out is to settle and finish — an account, a position, a shift. Everything outstanding is resolved and nothing is left open. That is what a process should do on the way out, and mostly does not.
Install
npm install closeout
pnpm add closeout
yarn add closeout
bun add closeoutQuick start
import { onExit } from 'closeout';
const off = onExit(({ path, code, signal, error }) => {
// Runs once, whichever door the program left by: a normal exit, an emptied event loop,
// Ctrl-C, SIGTERM, SIGHUP, SIGQUIT, an uncaught throw, an unhandled rejection.
releaseTheLock();
});
// Cleaned up early? Take the handler back out.
off();Your handler is handed one record — { path, signal, code, error } — and the same record is
what reportToJson() and reportToEvent() project, so a --json line and an agent event
cannot disagree with what the handler was told.
path is 'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection'. error is what was
thrown or rejected on the two paths that have one, and null on the others.
SIGKILL is not in that list and cannot be. It is not deliverable to a listener by design. Any package that claims it is claiming something no program can do.
The cursor, which is the common case
import { hideCursor } from 'closeout';
const restore = hideCursor(process.stdout);
try {
await drawTheSpinner();
} finally {
restore();
}hideCursor registers the restore at the same moment it hides. That pairing is the
whole reason it lives here rather than in each renderer: the two cannot drift apart, and a
process that dies between them still shows the cursor again.
Call restore() and the handler unregisters itself, so a program that cleans up normally
leaves nothing behind for exit to do. Call it twice, or call it and then die — the cursor is
shown once either way.
Nothing is written to a non-TTY. Escape sequences in a pipe corrupt the output the pipe exists to carry.
The alternate screen and raw mode, the same way
import { alternateScreen, hideCursor, rawMode } from 'closeout';
const undo = [rawMode(process.stdin), alternateScreen(process.stdout), hideCursor(process.stdout)];
try {
await runTheFullScreenApp();
} finally {
for (const back of undo) back();
}Each call makes the change and registers its undo in the restore phase, so a full-screen
program that dies — Ctrl-C, SIGTERM, SIGHUP, a throw, a handler that threw, a deadline that
fired — still hands back a terminal with raw mode off, the alternate screen left and the
cursor shown, after every other handler has run. Each undo runs once, whoever asks first.
Both register on the process-wide instance; pass the object install() returned as a second
argument to register on that one instead. They are functions rather than methods on that
object so that a program which never enters a screen does not bundle them.
rawMode(input) turns off only what it turned on. If the input is already raw, somebody else
owns that state and the call changes nothing, now or at exit. An input that is not a terminal
gets no mode change, and a stream that is not a terminal gets no escape, in either direction.
The problem
A program leaves by several doors: returning from main, process.exit, Ctrl-C, SIGTERM
from an orchestrator, SIGHUP when the terminal closes, an uncaught throw, a rejected
promise nobody awaited. A handler registered on 'exit' alone catches one of them.
That is why Ctrl-C so often leaves a hidden cursor in your shell, a half-written file, or a lock nobody released. Registering on all the doors is easy. Registering on all of them and running the handlers exactly once when two fire at the same moment is where the bugs are, and that is what this package is.
The deadline
import { install } from 'closeout';
const { onExit } = install({ deadline: 5000 });A handler that awaits something which never resolves — a socket that will not close, a lock nobody releases — turns Ctrl-C into a process the user has to kill twice, and the second one is SIGKILL, which runs no handlers at all. Abandoning a slow handler is the better trade.
On a breach the process leaves with the code it was already leaving with, and says which handler did not come back:
closeout: shutdown deadline of 2000ms expired; exiting anyway. Handlers that had not returned: acme:unlock, closeTheDatabaseNames come from the function's own name, or from a label you give it —
onExit(fn, { label: 'flush-the-audit-log' }) — which is worth doing for the arrow
functions, since an anonymous arrow is exactly the shape that hangs. A plugin's handlers are
named "<plugin>:<handler>" for free.
Infinity and 0 are both refused at registration, with a USAGE-class error that says
what to pass instead. Both reintroduce the failure the package exists to remove: one waits
forever, the other gives no asynchronous handler a turn. A caller who genuinely wants either
wants a different package.
The default is 2 000 ms, and it is provisional. Measured 2026-09-14 on darwin arm64 / node 24.13, 100 runs of each of the five cleanup shapes this layer sees: flushing a write stream p99 67.1 ms, closing a server 1.5 ms, killing a child 1.3 ms, restoring the terminal 0.2 ms — and removing a temp directory of 100 files p99 17 818 ms, on a machine at load average 19–22 across 14 cores (p50 161 ms / p99 2 166 ms when re-run alone). Four shapes inside 70 ms, one that is entirely the disk it is queued behind. The number stays 2 000 ms and stays labelled provisional rather than being rounded off a p99 with somebody else's I/O inside it.
Phases, so the order is not an accident
onExit(flushTheLog, 'flush'); // get the data out
onExit(releaseTheLock); // let go — the default, `release`
// `restore` is closeout's own: raw mode off, alternate screen left, cursor shown — last, alwaysRegistration order is the wrong order for a shutdown, and it is the order every incumbent gives you. The handler that hands the terminal back is registered by whichever renderer hid the cursor, at whatever moment it first drew — so anything registered a line later runs after the cursor is back, which is to say it cleans up nothing it was registered to clean up. An order that depends on import order is not an order.
Three phases, and the names are the sequence: flush (write the file, drain the log),
release (locks, sockets, children — the default), restore (the terminal). Phases
run in sequence: an async handler in flush settles before release starts. Handlers
inside one phase run together, in registration order.
Past the deadline the later phases are still run — they are only no longer waited for. A
handler that hung in flush does not get to decide that the cursor stays hidden.
Plugins
import { register, attach } from 'closeout/plugin';
register({
name: 'acme',
handlers: [{ name: 'unlock', phase: 'release', run: async () => { await release(); } }],
});
attach(closeout.registry);A plugin is one plain object shared by the whole family; closeout keeps handlers and
ignores every other layer's keys without complaining, so the same object works on whatever
subset of the family you have installed. contributions() projects the whole shutdown
sequence as data — readable without running any of it.
A plugin handler may declare flush or release, and not restore. Terminal restore is
closeout's own last phase; a handler admitted to it could land after the terminal was handed
back depending on nothing but which registered first, which is the coincidence phases exist
to replace.
Three guarantees, and what each one costs to get wrong
Exactly once. Two signals, or a signal and the 'exit' behind it, are one shutdown.
Handlers that run twice release a lock someone else has since taken.
One handler's failure is its own. A throw is reported and the remaining handlers still run. Shutdown is the worst possible place for an exception to short-circuit a loop, because the handler that restores the terminal is usually registered last.
Bounded. Shutdown returns on the handlers or on the clock, whichever comes first — and on the handlers when they are all synchronous, not on the clock.
The exit code is yours. On a signal, a throw or a rejection, how the process leaves is
decided at the trigger, before a single handler runs: a handler that sets process.exitCode = 0
on its way past cannot turn a crash into a success, and a breached deadline leaves the same way
rather than with a code invented by the fact that something hung. Every handler is told the
code in report.code. On process.exit() and a normal finish the process is leaving on its
own, and Node reads process.exitCode after the handlers, so a handler must not assign it
there.
A signalled process dies of the signal. Once the handlers have run, closeout removes its
own listener and re-raises — so a program killed by Ctrl-C really dies of SIGINT rather than
exiting 130. The two are different events to everything upstream of you: WIFSIGNALED is
true for one and false for the other, so a shell knows to print ^C, make stops a parallel
build, a CI runner marks a job cancelled rather than failed, and a supervisor decides whether
to restart. 128 + n is the number a shell reports afterwards; it is not a status a process
can set for itself, and closeout only falls back to it on a runtime that refuses to raise the
signal at all (SIGHUP on Windows).
A program that owns the signal keeps it. The re-raise happens only when no other listener
remains, counted after closeout's own comes off. A program with its own SIGINT handler gets
the cleanup and still decides what happens next — and gets exactly one delivery for one
Ctrl-C.
closeout/exit-hookis the one place this does not apply.exit-hookexits128 + nand listens on SIGINT and SIGTERM only — no SIGHUP — and its own suite grades both, so the drop-in keeps them. Use closeout'sonExitrather than the drop-in when a closing terminal has to reach your cleanup.
Testing it
Everything interesting is in a registry with no process attached:
import { createRegistry } from 'closeout';
const registry = createRegistry({ deadline: 10 });
registry.add(handler);
await registry.run({ code: null, signal: 'SIGINT' });And install({ process: fake }) wires one to something that is not the global process, for
a test or for a runner hosting other programs. A ProcessLike owes kill and pid as well
as the listener methods, because re-raising a signal is part of the contract above and a fake
that could quietly skip it is how the missing re-raise survived two incumbent suites.
Migrating
One import per incumbent:
- import exitHook, { asyncExitHook, gracefulExit } from 'exit-hook';
+ import exitHook, { asyncExitHook, gracefulExit } from 'closeout/exit-hook';- import restoreCursor from 'restore-cursor';
+ import restoreCursor from 'closeout/restore-cursor';- import { onExit } from 'signal-exit';
+ import { onExit } from 'closeout/signal-exit';- import cliCursor from 'cli-cursor';
+ import cliCursor from 'closeout/cli-cursor';- import onetime from 'onetime';
+ import onetime from 'closeout/onetime';The swap is an import change, not an overrides entry. An override points the incumbent's
name at closeout's root, and the root is closeout's own API — it has one default to give,
and five incumbents would each need it. An override for a transitive copy has nowhere to
land, so this package does not print one.
One thing to know before you swap exit-hook: its bound is per hook ({ wait }) and the
façade keeps that bound rather than imposing closeout's own 2 000 ms deadline, because a
drop-in that silently tightens your timeout is not a drop-in. onExit() — closeout's own
API — is where the bounded shutdown lives.
Or let the codemod make the change: npx burgee migrate --dry-run lists every import it would
rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes it
(Migrate).
Compatibility
What signal-exit, exit-hook, restore-cursor, cli-cursor, onetime and mimic-fn do
between them is one problem — leaving cleanly — and this is one package with no dependencies
rather than six with a tree.
Five of those paths are built and graded by the incumbent's own test suite, unedited apart
from the import specifier, through compat-oracle. The control column is that suite run
against the incumbent itself, which is what says the gate works before it grades us:
| subpath | incumbent | control | closeout |
|---|---|---|---|
closeout/exit-hook | exit-hook@5.1.0 (8.8 M/wk) | 21 / 21 | 21 / 21 |
closeout/restore-cursor | restore-cursor@5.1.0 (107.5 M/wk) | 6 / 6 | 6 / 6 |
closeout/signal-exit | signal-exit@4 (198.9 M/wk) | 134 / 135 | 134 / 135 |
closeout/cli-cursor | cli-cursor@5.0.0 (126.7 M/wk) | 8 / 8 | 8 / 8 |
closeout/onetime | onetime@8.0.0 (155.0 M/wk) | 6 / 6 | 6 / 6 |
The one signal-exit case short fails against signal-exit itself as well, which is what the
control column is for. mimic-fn has no row: onetime's suite asserts nothing of it, and
closeout/onetime keeps the wrapped function's name and length without exporting its API.
closeout/onetime is not closeout/once renamed: onetime retries a first call that throws,
and once never runs a handler twice.
Weight
Measured rather than claimed, and the package reaches no other package. Each drop-in weighs
no more in a user's bundle than the package it replaces (esbuild, minified, a fixture
importing one name, as the benchmarks
measure every drop-in): closeout/exit-hook is 1,579 B against exit-hook@5.1.0's
1,645 B, and closeout/signal-exit 2,657 B against signal-exit@4.1.0's 2,856 B. The
drop-ins carry the incumbent's behaviour and nothing else; the phase ordering, the bounded
runner and the report are the product, on closeout's own onExit.
Benchmarks
Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.
Graded by the incumbent's own test suite:
| suite | passing |
|---|---|
cli-cursor | 8 / 8 |
exit-hook | 21 / 21 |
onetime | 6 / 6 |
restore-cursor | 6 / 6 |
signal-exit | 134 / 135 |
Weight, installed and tree-inclusive: 111,003 bytes against 183,804 for the incumbents it replaces — a ratio of 0.6039 (measured).
For agents
- One record, two projections. Every handler is handed
{ path, signal, code, error }, andreportToJson()andreportToEvent()project that same record as a--jsonline or an agent event — they cannot disagree with what the handler was told. - A hang names itself. A breached deadline prints which handlers had not returned, and exits with the code the process was already leaving with.
- The shutdown is readable as data.
contributions()fromcloseout/pluginlists every contributed handler in the order it will run, without running any of it. - A plugin can be checked before it ships.
npx closeout check ./unlock.mjsvalidates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. - The docs are machine-readable at closeout.interlace.tools/llms.txt and llms-full.txt.
API
onExit(handler, phase | { phase, label }?) | register; returns the unregister function |
|---|---|
once(fn) | run at most once, first result thereafter — name, length and this kept |
hideCursor(stream) | hide and register the restore (in restore); returns the show function |
showCursor(stream) | show now — idempotent, no-op on a non-TTY |
alternateScreen(stream, closeout?) | enter the alternate screen and register leaving it (in restore); returns the leave function |
rawMode(input, closeout?) | turn raw mode on and register turning it off (in restore); an input already raw is left alone |
install(options) | wire a registry to a process; { deadline, onError, onTimeout, process } |
createRegistry(options) | the registry alone, with no process |
reportToJson(report) / reportToEvent(report) | the two projections of the one record |
SIGNALS | ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGBREAK'] |
DEFAULT_DEADLINE | 2000 |
PHASES | ['flush', 'release', 'restore'] |
DEFAULT_PHASE | 'release' |
EXIT_PATHS | ['exit', 'beforeExit', 'signal', 'uncaught', 'rejection'] |
run() resolves with the shutdown's own record: the four fields above plus timedOut and
unfinished, the handlers that had not returned.
And the two leaves, for a program that wants one of them and none of the rest (measured):
closeout/once | once(fn) — 441 B, reaching nothing |
|---|---|
closeout/cursor | showCursor, hideCursor, alternateScreen, bracketedPaste, rawMode (each taking the registrar as a second argument), HIDE_CURSOR, SHOW_CURSOR, ENTER_ALTERNATE_SCREEN, LEAVE_ALTERNATE_SCREEN, ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE — 1,786 B, no registry |
And from closeout/plugin:
register(plugin) | validate and keep a plugin's handlers; other layers' keys are ignored |
|---|---|
attach(registry) | wire every contributed handler into its phase; returns the undo |
contributions() | the shutdown sequence as data, in the order it will run |
registered() / reset() | the plugins, and forgetting them |
CONTRACT / PLUGIN_PHASES | 1 · ['flush', 'release'] |
And the drop-in subpaths, which reproduce their incumbent's API rather than this one:
closeout/exit-hook | exitHook(fn) (default), asyncExitHook(fn, { wait }), gracefulExit(code?) |
|---|---|
closeout/restore-cursor | restoreCursor() (default) |
Importing this package attaches nothing. The process-wide instance installs on first use,
so a library that imports closeout for its types pays nothing.
Every export, with its types, is on closeout.interlace.tools.
Where it sits
Plugins register under the handlers key, against the one schema the whole family shares.
burgee, caique, controlroom, flagstaff build on it, and it builds on nothing in this family.
The family
Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each
owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority
— depend on nothing; the others depend only on packages in this table, and every dependency points
one way, down the layers.
No package declares a dependency from outside the family; react and react-reconciler are
optional peers of controlroom, which npm does not install; vitest is an optional peer of
oxmutate, which npm does not install.
package-shape-lock
holds that for every manifest, and
independence-install-lock
installs each package alone and finds nothing but the family packages it declares.
Releases are published by one workflow through npm trusted publishing: no npm token is used, and
each release carries SLSA provenance, which npm view <package> dist.attestations shows.
trusted-publishing-lock
keeps both true.
| Package | What it is | Migrates from |
|---|---|---|
| burgee | The CLI framework: one declaration, every surface | commander, yargs and meow |
| roundel | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors |
| flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
| caique | Prompts that are flags first, and never hang | @inquirer/core |
| linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line |
| paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
| seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc |
| closeout (this package) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime |
| bellpull | Subprocesses, and which executable actually ran | cross-spawn and which |
| controlroom | Keyboard-driven terminal screens, inline or full-screen | ink |
| oxmutate | Mutation testing on the oxc engine, inside your own Vitest | Stryker |
Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.
Contributing
Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.
Licence
MIT © Ofri Peretz — see LICENSE.