P0.4.2 Graceful Shutdown — Behavioral Contract
1. Scope
This contract governs the public handler registration API and the signal-to-exit
semantics introduced by P0.4.2. It extends (does not replace) the existing shutdown
contract implied by src/startup.ts. The underlying shutdown() function behavior
(idempotency, 5s race, transport-before-DB ordering, exit codes) is inherited verbatim.
2. New public API
2.1. registerShutdownHandler(fn)
function registerShutdownHandler(
fn: () => Promise<void> | void
): () => void
Behaviour:
- Appends
fnto the module-level handler list. - Returns a deregister function
() => void— calling it removesfnfrom the list. The deregister is a no-op iffnis no longer in the list (already removed or shutdown already fired). - Thread-safety / re-entrancy: Node.js is single-threaded; no mutex needed.
- Registering the same function reference twice adds it twice; it will be called twice during shutdown.
Lifecycle:
- Handlers registered before
startup()is called are valid; they will be invoked on any subsequentshutdown(). - Handlers registered after
shutdown()starts will NOT be invoked for the current shutdown (the list is snapshotted at the start of the handler invocation loop). - Handlers registered after
shutdown()completes are silently ignored for that lifecycle. Colibri does not support re-starting after shutdown.
2.2. clearShutdownHandlers() (test-only)
function clearShutdownHandlers(): void
Empties the handler list. Not part of the public production surface. Exported only
for __resetForTests() to call. Production code must not invoke this.
3. Invocation semantics during shutdown
3.1. Ordering: LIFO (last-registered, first-called)
Handlers are invoked in reverse registration order — last registered runs first. Rationale: domain-specific cleanup (registered later) must run before general infrastructure cleanup (registered earlier). Example: a task-pipeline flush handler registered after DB init should fire before DB close.
Example:
registerShutdownHandler(A) // registered 1st
registerShutdownHandler(B) // registered 2nd
// on shutdown: B runs first, then A
3.2. Position in the shutdown sequence
The registered handlers are invoked after DB close and after signal deregistration
(i.e., after the existing steps 1-3 in shutdown()). Rationale: the handlers are
user-supplied and may depend on the transport being closed first (step 1) and DB being
flushed (step 2), but they do not themselves control those primitives.
Full sequence:
1. stopFn(ctx) — transport close (with 5s race)
2. closeDbFn() — DB close
3. Remove SIGINT/SIGTERM listeners
4. Run registered handlers LIFO ← NEW
5. Log [Shutdown] Clean
3.3. Error isolation
Each handler is wrapped in its own try/catch. A throwing handler:
- Is logged as
[Shutdown] handler[i] failed: <err> - Does NOT abort remaining handlers (all handlers run regardless)
- Does NOT change the exit code from the signal path (exit(0) on clean, exit(1) only if
shutdown()itself throws — which it does not by contract)
3.4. Async handlers
If fn returns a Promise, it is await-ed. All handlers are awaited sequentially
(not concurrently) to preserve LIFO ordering semantics and prevent resource contention.
4. Signal handling contract (inherited + extended)
4.1. Signals handled
SIGINT (Ctrl-C) and SIGTERM (OS termination request). Both map to the same
gracefulSignalExit path.
4.2. Idempotency
If SIGINT arrives twice:
- First arrival:
shutdown()starts (setsshutdownPromise). - Second arrival:
shutdown()returns the same in-flight promise. No double-invocation of handlers,stopFn, orcloseDbFn.
4.3. Exit codes
| Condition | Exit code |
|---|---|
| All shutdown steps complete without throwing | 0 |
shutdown() itself throws (logger throws, catastrophic failure) |
1 |
Individual stopFn, closeDbFn, or registered handler throws |
0 (errors are swallowed, shutdown continues) |
| Force-exit after 5s transport timeout | 0 (clean; only the transport was forced) |
Note: individual handler errors do NOT affect the exit code. The exit code reflects whether the shutdown orchestration succeeded, not whether every cleanup function was error-free.
5. src/shutdown.ts public barrel contract
src/shutdown.ts re-exports the following from ./startup.js:
registerShutdownHandler(the AC-required public function)shutdown(for callers who need to trigger shutdown programmatically)
It does NOT re-export:
startup(belongs to the startup surface, not the shutdown surface)clearShutdownHandlers(test-only; must not appear in the public barrel)__resetForTests(test-only; same)
6. Test contract
The test file src/__tests__/shutdown.test.ts must cover:
| # | Scenario | Assert |
|---|---|---|
| 1 | Single handler registered → shutdown called | handler invoked once |
| 2 | Two handlers A, B → shutdown | B invoked before A (LIFO) |
| 3 | No handlers registered → shutdown | no error, [Shutdown] Clean emitted |
| 4 | Handler throws → shutdown | remaining handlers still called, no re-throw |
| 5 | SIGTERM emitted after startup + registerShutdownHandler | handler called, exit(0) |
| 6 | SIGINT emitted after startup + registerShutdownHandler | handler called, exit(0) |
| 7 | Deregister fn called → handler not invoked | handler count = 0 in assertions |
| 8 | Idempotency: shutdown called twice during in-flight | handlers called exactly once |
| 9 | Multiple handlers (3+) → all called in LIFO | order matches reverse registration |
| 10 | clearShutdownHandlers resets list | no handlers remain |
| 11 | Async handler (returns Promise) → awaited before next | order preserved |
| 12 | registerShutdownHandler returns a callable deregister fn | type assertion + invoke |
Tests use __resetForTests() in afterEach (which calls clearShutdownHandlers())
so handler lists do not leak between tests.
All tests use the same DI injection pattern as startup.test.ts:
registerSignalHandlers: false, injected exit, stopFn, closeDbFn, logger.
7. Invariants
- Handler list is process-scoped (module singleton). Acceptable: signal handlers are inherently process-scoped; this is not a unit-of-work boundary.
registerShutdownHandleris callable before, during, or afterstartup()— but handlers registered aftershutdown()completes are never invoked.- The barrel
src/shutdown.tshas zero implementation logic; it is a stable public surface that survives future refactors ofstartup.tsinternals.