Shutdown Owner
A signal listener suppresses Node's default terminate behaviour. A component that attaches one has taken on the exit, whether or not it meant to.
In this section
A signal listener suppresses Node's default terminate behaviour. A component that attaches one has taken on the exit, whether or not it meant to.
When two components each attach one, both run concurrently, and whichever finishes first exits the process. The other is terminated mid-flight, at exit code 0, with its work unwritten. That is not a hypothetical: it is the measured failure this API exists to end — a library flush finishing before an application's close() terminated that close, leaving locks and markers behind, after a shutdown the operator saw succeed.
The cure is not "be careful with listeners". It is that exactly one thing owns the exit, and everything else registers work under it.
Using it
import {
mountShutdownOwner,
registerShutdownTask,
assertSoleShutdownOwner,
} from '@soulcraft/brainy'
mountShutdownOwner() // once, at boot, by the HOST
registerShutdownTask('http', () => server.close())
registerShutdownTask('jobs', () => queue.drain())
assertSoleShutdownOwner() // after everything is wiredTSThat is the whole surface. A component registers a task; it does not attach a signal listener and it does not mount its own owner.
What the owner guarantees
One owner |
|
| A task that throws is logged with its error and does not stop the others. A shutdown in which one component's failure denies the rest their turn is how a pool of components fails in a batch. |
Every task named and timed | Each task's outcome and duration are logged, so a slow shutdown is attributable instead of mysterious. |
A watchdog names stragglers | At 30 s the owner logs exactly which tasks have not settled, by name. |
It exits once | A second signal during a run is narrated and ignored — and, importantly, does not exit. |
The watchdog names; it does not kill
30 s is chosen against the supervisor, not against a task: Kubernetes' default terminationGracePeriodSeconds is 30 s and systemd's default TimeoutStopSec is 90 s, so a straggler has to be named well inside the shorter of them or the process is killed before anyone learns which task hung.
It reports and lets the task keep running. Killing a task mid-write is the outage a graceful shutdown exists to avoid, and an owner that killed its own tasks would be the second thing racing the exit.
Why the registry lives on globalThis
Two module realms — a CJS require and an ESM import of the same package, or a bundled copy beside a node_modules copy — are two module instances with two module-scoped registries. A task registered through one would be invisible to an owner mounted through the other. The registry is keyed on globalThis under a Symbol.for key, which is the one identity both realms agree on.
The engine integration
The engine registers its own SIGTERM/SIGINT/beforeExit listeners on first init(), and exits only when it is the sole handler (process.listenerCount(signal) <= 1).
Without a host owner, that is exactly what still happens. A script that opens a brain and never mounts an owner behaves as it always has: the engine flushes, closes the generation store so the clean-shutdown marker lands, releases its writer locks, and — being sole owner — exits. Nothing about that path changes.
With a host owner mounted, the engine stands down:
its signal listeners are detached, so it no longer races the host;
its flush-and-close registers as a task named
brainy:flush-and-close;it never calls
process.exit, because the path that would is no longer attached. The owner owns the exit.
The task closes every live brain through the engine's own close() — flush, then the generation store's close so the clean-shutdown marker lands, then the writer lock released in a finally — and steps aside for any brain whose close another caller already owns. That is the same door the engine's own signal listener takes; the durable work is not reimplemented, it is called.
It does not invoke the engine's beforeExit listener. Since reference engine 10.4.12 that listener is a non-closing flush of a drained event loop — a drained loop is not a shutdown, and closing a live brain out from under a running script was the defect it was rewritten to end. A shutdown task that called it would persist derived state and leave every writer lock held, which is a shutdown that never closed.
A brain whose close() fails is reported as a failed task, with its error, in the owner's report — the other brains still get their turn, and a shutdown that did not fully happen never reports ok.
The handoff happens inside Brainy.init(), which is the earliest moment both the owner and the engine's listeners are known to exist: a host normally mounts the owner at boot, before any brain is opened.
The guard
assertSoleShutdownOwner()TSCall it once, after everything is wired. It refuses — by name, with the count — when any owned signal has more listeners than the owner's own.
A component that attached its own listener after the owner mounted has taken back a share of the exit, and the symptom is a shutdown that sometimes truncates. The guard turns that into a refusal at wiring time, where it is cheap, instead of an intermittent data loss in production.
Operating notes
Register early, mount once. Registration is safe before the owner mounts; the registry outlives any particular owner.
unmount()puts the engine back. An unmounted owner leaves a process with the engine's own shutdown intact, rather than with none at all.A duplicate task name is allowed and both run. Two HTTP servers are a real shape, and silently dropping one would be a shutdown that skipped a component.
exitOnComplete: falseis for the case where something else genuinely owns the exit and this owner is only sequencing the work.