# Why closeout

> closeout against signal-exit, exit-hook and restore-cursor, one capability per row, every cell linked to the test, grade or source that proves it.

Source: https://closeout.interlace.tools/docs/why-closeout

signal-exit runs a handler when a process ends, exit-hook lets that handler be async, and
restore-cursor shows the cursor again. closeout does all three — through drop-in paths graded
by each one's own test suite — and adds what none of them has: one handler on every exit path
told which one it was, phases that put the terminal back last, raw mode and the alternate
screen restored as well as the cursor, and a deadline that ends a hung shutdown and names the
handler that hung.

The table below is the whole comparison. Every mark links to its evidence: a test in this
repository for ours, and for theirs the source file of the exact version compat-oracle grades,
or that package's own test suite. `scripts/capabilities-lock.test.ts` fails the build when a
cited test no longer contains the title it is cited for, when a source no longer contains the
line it is quoted for, or when a source we say lacks something has gained it.

✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades.

### Every way out

| Capability | **closeout** | signal-exit | exit-hook | restore-cursor |
| :-- | :-- | :-- | :-- | :-- |
| **One registry for every exit path** — A handler registered once runs on a signal, an uncaught throw, an unhandled rejection, an emptied event loop (`beforeExit`) and `process.exit()`, so no door out skips the cleanup. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/restore.test.ts) | [◐ no `beforeExit` trigger, so an async handler never gets the emptied loop's time; a throw or a rejection reaches handlers only through the `exit` event behind it](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [◐ no SIGHUP, SIGQUIT, uncaughtException or unhandledRejection listener; a throw reaches only the synchronous hooks, through `exit`](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— registers one handler of its own, the cursor restore, and takes none of yours](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **The handler is told which door, and what was thrown** — Every handler gets `{ path, signal, code, error }`, so it can tell Ctrl-C from a crash and keep the temp directory for a bug report. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts) | [✗ a handler gets `(code, signal)`; a throw arrives as code 1 with no error](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ a hook gets the exit code alone](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— takes no handler of yours](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **Exactly once when two doors fire together** — Ctrl-C twice, or a signal and the `exit` behind it, is one shutdown, so a lock is never released twice. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts) | [✓](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✓](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓ registers once, and signal-exit's emitter runs the handler once](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **One handler's throw does not skip the rest** — A handler that throws is reported and the handlers after it still run, so the one that restores the terminal is not the one that gets skipped. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts) | [✗ calls each handler in a plain loop; a throw ends the loop, and the handlers after it do not run](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ calls each synchronous hook in a plain loop; a throw ends the loop](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— runs one handler of its own](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |

### A shutdown that ends

| Capability | **closeout** | signal-exit | exit-hook | restore-cursor |
| :-- | :-- | :-- | :-- | :-- |
| **A deadline that names the handler that hung** — A handler that never returns cannot hold Ctrl-C hostage: after the deadline (2000 ms by default) the process leaves with the code it was leaving with and prints which handlers had not returned. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/matrix.test.ts) | [✗ awaits nothing a handler returns, so an async cleanup is abandoned rather than bounded](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [◐ each async hook carries its own `wait`, the longest one bounds the rest, and the forced exit names no hook](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— one synchronous write; nothing to bound](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **Phases: flush, release, then restore last** — Handlers run in `flush`, `release`, `restore` order, each phase awaited before the next, so the terminal comes back after the log is written whichever registered first. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/plugin.test.ts) | [◐ two tiers, `exit` then `afterExit`, in registration order within each; nothing is awaited between them](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ registration order, every synchronous hook before any asynchronous one](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [◐ its one restore goes last through signal-exit's `alwaysLast`; there are no phases for anything else](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **The restore still runs after a handler hung** — Past the deadline the later phases are still run, just no longer waited for, so a hung `flush` does not decide that the cursor stays hidden. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/deadline.test.ts) | [— has no deadline to run past](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [◐ every async hook is started at once, so a restore hook does run, but beside the hung one rather than after it](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— has no deadline to run past](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |

### The terminal

| Capability | **closeout** | signal-exit | exit-hook | restore-cursor |
| :-- | :-- | :-- | :-- | :-- |
| **Cursor, raw mode and the alternate screen restored** — `hideCursor`, `rawMode` and `alternateScreen` make the change and register its undo in the `restore` phase, so a full-screen program that dies by any door hands back a usable terminal. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/cursor.test.ts) | [✗ writes nothing to a terminal; the restore is a handler you write](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ writes nothing to a terminal; the restore is a hook you write](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [◐ the cursor only; raw mode and the alternate screen are left as they were](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **A second signal waits for the first shutdown** — A second Ctrl-C, or a SIGTERM behind it, while a handler still holds the first shutdown waits for that shutdown, so the restore runs before the process dies. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/terminal-restore.test.ts) | [— handlers run synchronously inside the first signal's listener, so no second signal arrives mid-shutdown](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ the listener is `once`: a second SIGINT while an async hook runs finds none, and the process dies there](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— one synchronous write inside signal-exit's handler](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **No escape sequences into a pipe** — A stream that is not a terminal gets no cursor or screen sequence on the way in or out, so a log carries only what the program wrote. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/cursor.test.ts) | [— writes nothing to any stream](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [— writes nothing to a terminal](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |

### Exit status

| Capability | **closeout** | signal-exit | exit-hook | restore-cursor |
| :-- | :-- | :-- | :-- | :-- |
| **A signalled process dies of the signal** — After the handlers run, the signal is re-raised, so a shell prints `^C`, `make` stops, and a CI runner marks the job cancelled rather than failed. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/signal.test.ts) | [✓](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ exits 128 + n, so a parent sees an exit code, not a signal; `closeout/exit-hook` keeps that, as exit-hook's suite requires](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓ through signal-exit, which it depends on](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) |
| **A program that owns the signal keeps it** — When the program has its own listener for the signal, the handlers run and the program still decides what happens next, with one delivery for one Ctrl-C. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/install.test.ts) | [✓](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ exits after the hooks, whatever other listeners the program has](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓ through signal-exit, which it depends on](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) |

### Agents and plugins

| Capability | **closeout** | signal-exit | exit-hook | restore-cursor |
| :-- | :-- | :-- | :-- | :-- |
| **The shutdown as one `--json` line or an agent event** — `reportToJson()` and `reportToEvent()` project the record the handlers were given, with `timedOut` and the handlers that hung, so a log and an agent read the same facts. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/report.test.ts) | [✗](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✗](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |
| **Plugins: named handlers in a phase, readable as data** — A plugin contributes named handlers to `flush` or `release`, is validated against the family schema, and `contributions()` lists the whole shutdown in order without running it. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/plugin.test.ts) | [✗](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✗](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) |

### Compatibility

| Capability | **closeout** | signal-exit | exit-hook | restore-cursor |
| :-- | :-- | :-- | :-- | :-- |
| **Passes exit-hook's own test suite** — `closeout/exit-hook` is graded by exit-hook 5.1.0's own tests, unedited, so changing the import keeps exit-hook's behaviour, 128 + n exit codes included. | [✓ 21 / 21 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/exit-hook.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) |
| **Passes restore-cursor's own test suite** — `closeout/restore-cursor` is graded by restore-cursor 5.1.0's own tests, which spawn a child per case and check which stream the cursor comes back on. | [✓ 6 / 6 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/restore-cursor.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) |
| **Passes signal-exit's own test suite, level with signal-exit** — `closeout/signal-exit` passes 134 of the 135 cases of signal-exit 4.1.0's own suite, and signal-exit itself passes the same 134: the one case short fails for both on current Node. | [◐ 134 / 135 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/signal-exit.json) | [◐ its own suite, the control run, passes 134 of 135: this case fails on Node 22, 24 and 26, all released after signal-exit's last release](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) |

## Reading it

- **"ours" means closeout's own API**, `onExit` and `install()`. The three drop-ins keep their
  incumbents' behaviour, which is what their grades measure: `closeout/exit-hook` exits
  `128 + n` and ignores SIGHUP because exit-hook does. [Compatibility](/docs/drop-ins) lists
  what each drop-in keeps.
- **Parity rows are here too.** signal-exit and exit-hook both run a handler once when two
  doors fire together, and signal-exit re-raises a signal as closeout does. A row where they
  match us is a row a reader would otherwise have to go and check.
- **— does not apply** is not a soft ✗. restore-cursor takes no handler of yours, so rows about
  your handlers do not apply to it; the cell says why.
- **signal-exit's grade is level with signal-exit.** `closeout/signal-exit` passes 134 of the
  135 cases of signal-exit's own suite, and signal-exit passes the same 134. The one case both
  fail, `does not exit if user handles signal`, fails for signal-exit itself on every Node
  released since its last version.

## What is not in the table

A row goes in only when every cell of it can be proved. These were left out:

- **"The exit code is the program's."** On a signal, a throw and a rejection, closeout decides
  how the process leaves before any handler runs, and `matrix.test.ts` proves it for the
  deadline. On `process.exit()` a handler that assigns `process.exitCode` still changes the
  code, because Node reads it after the `exit` listeners — see
  [Signals and exit status](/docs/guides/signals#the-exit-code). The row would not be true on
  every path, so it is not a row.
- **`npx closeout check`.** The plugin checker ships and is shown on
  [Plugins](/docs/guides/plugins), where the example is run by this site's tests; closeout has
  no unit test of the command itself yet.
- **Weight.** Zero dependencies is a fact of the manifest, and the per-entry byte budgets are
  asserted by
  [`weight.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/weight.test.ts)
  and published on [Benchmarks](https://burgee.interlace.tools/docs/benchmarks), measured the
  same way on both sides. They are not a yes-or-no capability. `closeout/exit-hook` is larger
  than exit-hook, and closeout's README says so.
- **SIGKILL.** No package can run a handler on it, closeout included; a row would be four ✗s
  that say nothing about any of them.
