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 fn to the module-level handler list.
  • Returns a deregister function () => void — calling it removes fn from the list. The deregister is a no-op if fn is 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 subsequent shutdown().
  • 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 (sets shutdownPromise).
  • Second arrival: shutdown() returns the same in-flight promise. No double-invocation of handlers, stopFn, or closeDbFn.

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.
  • registerShutdownHandler is callable before, during, or after startup() — but handlers registered after shutdown() completes are never invoked.
  • The barrel src/shutdown.ts has zero implementation logic; it is a stable public surface that survives future refactors of startup.ts internals.

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.