closeout
Every export of closeout, with its signature and doc comment: alternateScreen, assertDeadline, createRegistry, DEADLINE_ERROR_CODE, DeadlineError, DEFAULT_DEADLINE and 17 more, plus 16 types.
closeout — close everything out.
Exit handlers that run exactly once on every path, terminal restore, and a bounded deadline so shutdown cannot hang.
"Every path" is the hard part and the reason this is a package. A program leaves by
several doors — returning from main, process.exit, SIGINT, SIGTERM, SIGHUP — and a
handler registered on 'exit' alone misses most of them, which is why a Ctrl-C so often
leaves a hidden cursor or a half-written file behind. Registering on all of them is
easy; registering on all of them and running the handlers exactly once when two fire
together is where the bugs live.
Zero dependencies; Node builtins only.
import { alternateScreen, assertDeadline, createRegistry, … } from 'closeout';Functions
alternateScreen
Enter the alternate screen and register leaving it in restore; the returned function
leaves it. Registers on the process-wide instance unless closeout names another.
function alternateScreen(stream: OutputStream, closeout?: Closeout): () => void;| Parameter | Type |
|---|---|
stream | OutputStream |
closeout (optional) | Closeout |
Returns () => void
assertDeadline
Check a deadline at the moment it is registered, not at the moment it would have fired.
Infinity and 0 are both rejected, and they are the same mistake wearing two hats.
Infinity is "wait forever", which is the unbounded shutdown this package exists to
remove. 0 reads like "do not wait", and what it actually buys is a shutdown where no
asynchronous handler ever gets a turn — every one of them abandoned mid-flight, which is
the other half of the same failure: work that silently did not happen. A caller who
genuinely wants either wants a different package, and being told so at install() is
worth far more than finding out during the one shutdown that mattered.
Returns the number so a caller can use it in an expression, which is what makes it hard to call and then ignore.
function assertDeadline(ms: number): number;| Parameter | Type |
|---|---|
ms | number |
Returns number
createRegistry
function createRegistry(options?: RegistryOptions): Registry;| Parameter | Type |
|---|---|
options (optional) | RegistryOptions |
Returns Registry
hideCursor
Hide the cursor and register its restore; the returned function shows it again.
function hideCursor(stream: OutputStream): () => void;| Parameter | Type |
|---|---|
stream | OutputStream |
Returns () => void
install
Wire a registry to a process.
Exposed rather than kept internal because a test — or a program that owns its own lifecycle, like a runner hosting other programs — needs to install this against something that is not the global process.
function install(options?: InstallOptions): Closeout;| Parameter | Type |
|---|---|
options (optional) | InstallOptions |
Returns Closeout
onExit
Register a handler that runs exactly once, on every path out of the program.
function onExit(handler: ExitHandler, spec?: HandlerSpec): () => void;| Parameter | Type |
|---|---|
handler | ExitHandler |
spec (optional) | HandlerSpec |
Returns () => void
rawMode
Turn raw mode on and register turning it off in restore; the returned function turns it
off. An input that was already raw belongs to somebody else and is left alone, now and at
exit. Registers on the process-wide instance unless closeout names another.
function rawMode(input: InputStream, closeout?: Closeout): () => void;| Parameter | Type |
|---|---|
input | InputStream |
closeout (optional) | Closeout |
Returns () => void
reportToEvent
The agent rendering. Same values, one key added, still a projection of the one record.
function reportToEvent(report: ShutdownReport): ExitEvent;| Parameter | Type |
|---|---|
report | ShutdownReport |
Returns ExitEvent
reportToJson
The --json rendering: one line, machine-first, error flattened to text.
JSON.stringify of the record itself would emit "error": {} for every Error — they
have no enumerable own properties — which is a line that looks like a report and carries
nothing. That is why this is a projection rather than a stringify.
function reportToJson(report: ShutdownReport): string;| Parameter | Type |
|---|---|
report | ShutdownReport |
Returns string
showCursor
Show the cursor. Idempotent, and a no-op on a non-TTY.
function showCursor(stream: OutputStream): void;| Parameter | Type |
|---|---|
stream | OutputStream |
Returns void
timeoutMessage
The line the deadline prints when it fires.
Kept here beside the projections rather than in the registry because it is a rendering of the record, and because the sentence is the product: a hang that used to be silence becomes a named handler a reader can go and look at.
function timeoutMessage(report: ShutdownReport, deadline: number): string;| Parameter | Type |
|---|---|
report | ShutdownReport |
deadline | number |
Returns string
Classes
DeadlineError
class DeadlineError extends TypeError {
readonly code = "USAGE";
/** What to do instead. Always present, always actionable — contract R8's shape. */
readonly fix: string;
constructor(message: string, fix: string);
}Constants
DEADLINE_ERROR_CODE
A caller error, in the family's one vocabulary: a code that classifies it and a fix
that says what to do instead.
USAGE rather than an E_… code: the E_… names belong to the plugin contract, where a
code travels between a plugin author and a host. This is a caller passing a number that
cannot mean what they wanted it to mean, which is the same class caique's binding reports
and the same class a CLI exits 2 for.
const DEADLINE_ERROR_CODE = "USAGE";DEFAULT_DEADLINE
Two seconds: long enough to flush a file, short enough that nobody reaches for the keyboard.
Provisional, and the measurement that was supposed to settle it is recorded rather than
quietly re-run until it agreed. measure/deadline.mjs runs the five cleanup shapes this
layer actually sees — flush a write stream, close a listening server, kill a child, remove
a temp directory of 100 files, restore the terminal — 100x each and prints each p99. Run
2026-09-14 on darwin arm64, node 24.13:
flush a write stream p99 67.1 ms
close a server p99 1.5 ms
kill a child p99 1.3 ms
remove a temp dir p99 17 818.5 ms
restore the terminal p99 0.2 msFour of the five are inside 70 ms and the fifth is four orders of magnitude out, which is
not a fact about removing a directory: the machine was at load average 19-22 across 14
cores, the same condition that makes exit-hook's four signal cases a race. Re-run
alone, the removal is p50 161 ms / p99 2 166 ms — still the shape that decides the answer,
and still measured through the load.
So the default stays 2 000 ms and stays stated as provisional, because rounding a p99
that has another process's disk queue inside it would be a number with a decimal point
and no meaning. The honest reading of the run is that everything except filesystem
removal finishes inside 70 ms, and that the deadline is there for the shape that does not.
closeout/intent.md's open question is not closed; what changed is that it now has a
repeatable instrument and one recorded run instead of an argument.
const DEFAULT_DEADLINE = 2000;DEFAULT_PHASE
Where a handler goes when its author did not say.
release rather than flush: an unphased handler is far more often closing something
than writing something, and either way it lands before restore — the property every
caller of this package is relying on whether or not they know the word.
const DEFAULT_PHASE: Phase;EXIT_PATHS
Every path, in the order install() wires them. Exported so a matrix can iterate it.
const EXIT_PATHS: readonly ["exit", "beforeExit", "signal", "uncaught", "rejection"];PHASES
The order shutdown happens in, as data rather than as registration order.
Registration order is the wrong ordering for a shutdown, and it is the ordering 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 and raw mode is off, which is to say it cleans up nothing it was registered to clean up. An ordering that depends on import order is not an ordering; it is a coincidence that happens to hold until someone moves an import.
Three phases, and the names are the sequence:
flush— get the data out: write the file, drain the log, post the last event.release— let go: locks, sockets, children, temp directories. The default.restore— hand the terminal back: cursor shown, raw mode off. Last, always.
Phases run in sequence — an async handler in flush is awaited before release
begins — and handlers within one phase run together, in registration order. The
sequencing is the half that makes the guarantee worth stating: sorting the calls but
starting them all at once would put a plugin's await after the cursor was already back,
which is the bug with the ordering merely rearranged.
const PHASES: readonly ["flush", "release", "restore"];SIGNALS
The signals a CLI is expected to survive politely (design R1).
SIGINT is Ctrl-C. SIGTERM is what an orchestrator sends before it loses patience. SIGHUP is the terminal closing out from under you, which is the one people forget and the one that most often strands a lock file. SIGQUIT is Ctrl-\, which a shell sends expecting a core dump and which otherwise leaves every lock this package exists to release.
SIGBREAK is Windows' Ctrl-Break and exists nowhere else; listening for it on POSIX costs
one listener that can never fire, which is a better trade than a platform read inside
the one package whose whole design is that it does not read the process.
SIGKILL is deliberately absent, and cannot be added. It is not deliverable to a listener by design; a package that claimed it would be claiming something no program can do. The README says so in those words rather than leaving a reader to infer a guarantee.
const SIGNALS: readonly ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "SIGBREAK"];Interfaces
Closeout
interface Closeout {
/**
* Register a handler. Returns the function that unregisters it.
*
* The second argument is a phase (default `release`), or `{ phase, label }` when the
* handler wants a name in the deadline's report that is better than an arrow's empty one.
*/
onExit(handler: ExitHandler, spec?: HandlerSpec): () => void;
/** Hide the cursor and register its restore; the returned function shows it again. */
hideCursor(stream: OutputStream): () => void;
/** Show the cursor now. Idempotent, and a no-op on a non-TTY. */
showCursor(stream: OutputStream): void;
/** The registry, for a caller that wants to drive shutdown itself. */
readonly registry: Registry;
}ExitEvent
The shape an agent event carries: the same values, under the family's event key (Y6).
interface ExitEvent {
type: 'closeout.shutdown';
path: ExitPath;
signal: string | null;
code: number | null;
error: string | null;
timedOut: boolean;
unfinished: string[];
}ExitInfo
What a trigger supplies. The path is inferred from the signal when it is not stated.
interface ExitInfo {
code: number | null;
signal: string | null;
path?: ExitPath;
error?: unknown;
}ExitReport
What a handler is handed. The same record --json and an agent event project from.
interface ExitReport {
/** Which door: a normal exit, an emptied loop, a signal, a throw, a rejected promise. */
path: ExitPath;
/** The signal that ended it, when one did. */
signal: string | null;
/** The code the process is leaving with, when it is leaving by a code. */
code: number | null;
/** What was thrown or rejected, on the two paths that have one. `null` on the others. */
error: unknown;
}HandlerOptions
How a handler is registered when a bare phase is not enough to say it.
interface HandlerOptions {
/** Which phase it runs in. Defaults to {@link DEFAULT_PHASE}. */
phase?: Phase;
/**
* What to call it in a report.
*
* Defaults to the function's own `name`, which is right for `onExit(releaseTheLock)` and
* useless for `onExit(async () => …)` — and the anonymous arrow is the shape that hangs.
* A label is the caller's chance to make the deadline's sentence name something they can
* go and look at.
*/
label?: string;
}InstallOptions
interface InstallOptions extends RegistryOptions {
/** Defaults to the real `process`. */
process?: ProcessLike;
}ProcessLike
The half of process this package needs, so the wiring can be tested without one.
interface ProcessLike {
on(event: string, listener: (...args: never[]) => void): unknown;
removeListener(event: string, listener: (...args: never[]) => void): unknown;
listenerCount(event: string): number;
exit(code?: number): never;
/**
* Send a signal to a process. closeout passes exactly one pid — {@link ProcessLike.pid},
* its own — because the only thing it ever does with this is re-raise the signal it just
* handled, so that a process killed by SIGINT *dies of SIGINT* instead of reporting 130.
*
* Required rather than optional, which is a deliberate cost. A `ProcessLike` without it
* would still compile and would silently take the exit path, and a seam that lets a fake
* quietly opt out of the behaviour under test is how `leaveAfter` stayed wrong through
* 21 / 21 and 6 / 6. A process that cannot raise a signal cannot honour this package's
* contract, so the type says so.
*/
kill(pid: number, signal: string): unknown;
/** This process's own id — the only one {@link ProcessLike.kill} is ever given. */
pid: number;
stderr: OutputStream;
/**
* Optional because the signal wiring has never needed it and its tests do not supply one:
* only the terminal-restore façade picks between the two streams.
*/
stdout?: OutputStream;
/**
* The code the process will leave with, which is a property of the process and not of any
* handler. `exit-hook`'s contract is written in terms of it — a hook is handed the code the
* program is about to exit with, and a signal overrules whatever was set — so the drop-in
* path reads and writes it here. Typed as Node types it (`string` is legal and coerced).
*/
exitCode?: number | string | undefined;
}Registry
interface Registry {
/**
* Register a handler in a phase. Returns the function that unregisters it.
*
* The phase defaults to {@link DEFAULT_PHASE}, so a caller that never heard of phases
* keeps the behaviour it had: unphased handlers share one phase and run in registration
* order within it — ahead of `restore`, which is the part that was not true before.
*/
add(handler: ExitHandler, spec?: HandlerSpec): () => void;
/**
* Run every handler, once, phase by phase, inside the deadline. A later call runs nothing
* and resolves with the first: it waits on a shutdown still in flight.
*/
run(info: ExitInfo): Promise<ShutdownReport>;
/** Run every handler synchronously, in phase order; a returned promise is abandoned. */
runSync(info: ExitInfo): ShutdownReport;
/** How many handlers are registered — for a caller that wants to know if any are. */
readonly size: number;
/** How many are registered in one phase. */
count(phase: Phase): number;
/** Whether shutdown has already happened. */
readonly settled: boolean;
/**
* The report of the shutdown that happened, or `undefined` before one has.
*
* Readable after `runSync`, which cannot return a promise, and after a second trigger
* that found the work already done — the path that would otherwise have nothing to say.
*/
readonly report: ShutdownReport | undefined;
}RegistryOptions
interface RegistryOptions {
/**
* How long shutdown may take before it stops waiting, in milliseconds.
*
* A deadline is not a nicety. A handler that awaits something that 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 with no cleanup at all. Better
* to abandon a slow handler than to strand the person at the keyboard.
*
* `Infinity` and `0` are rejected here, at registration — see `deadline.ts`.
*/
deadline?: number;
/** Where a handler's own failure is reported. Defaults to stderr. */
onError?: (error: unknown) => void;
/**
* Where a breached deadline is reported. Defaults to stderr, naming every handler that
* had not returned. Takes the whole report, so a caller can project it as JSON or as an
* agent event instead of a line of prose.
*/
onTimeout?: (report: ShutdownReport) => void;
}ShutdownReport
What shutdown was, once it has run: the record plus what the deadline found.
unfinished is the sentence this package exists to be able to say. It is empty on every
ordinary shutdown, and on a breach it names each handler that had not returned — by the
label its caller gave it, or by the function's own name.
interface ShutdownReport extends ExitReport {
/** Whether the deadline expired before the handlers were done. */
timedOut: boolean;
/**
* The handlers that had not returned when shutdown stopped waiting — by the label their
* caller gave them, or by the function's own `name`.
*
* Empty on an ordinary shutdown. Non-empty in two cases, and they are different: the
* deadline fired (`timedOut`), or the trigger was `'exit'`, where Node gives no time at
* all and an asynchronous handler never had a turn to lose. The second is why this is
* not simply "what the deadline caught".
*/
unfinished: readonly string[];
}Types
ExitHandler
A handler is handed the one record: { path, signal, code, error } (design R2).
type ExitHandler = (report: ExitReport) => void | Promise<void>;ExitPath
Which door the process left by.
signal covers all of them rather than naming each: the signal's own name is in
signal, and a consumer switching on the path wants the class of exit — "somebody
asked us to stop" is one case whether it arrived as SIGINT or SIGHUP.
type ExitPath = 'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection';HandlerSpec
A phase, or the options object. The bare phase is the common case and stays spellable.
type HandlerSpec = Phase | HandlerOptions;Phase
One of {@link PHASES}.
type Phase = (typeof PHASES)[number];Re-exported
Documented on the page of the entry point that declares them.
| Export | Kind | Documented in |
|---|---|---|
once | function | closeout/once |
ENTER_ALTERNATE_SCREEN | const | closeout/cursor |
HIDE_CURSOR | const | closeout/cursor |
LEAVE_ALTERNATE_SCREEN | const | closeout/cursor |
SHOW_CURSOR | const | closeout/cursor |
InputStream | interface | closeout/cursor |
OutputStream | interface | closeout/cursor |