P0.4.2 Graceful Shutdown — Execution Packet

1. Implementation path

Path (c) — Augment inline + barrel (chosen in audit). All logic stays in src/startup.ts; src/shutdown.ts is a re-export-only barrel.


2. Concrete changes

2.1. src/startup.ts additions

2.1.1. New module-scope state (after sigtermHandler)

/**
 * User-registered shutdown cleanup functions. Called in LIFO order during
 * `shutdown()` — after transport and DB close, before `[Shutdown] Clean`.
 * Module-scope because signal handlers (installed inside `startup()`) must
 * reach the same list after the call-site returns.
 */
let shutdownHandlers: Array<() => Promise<void> | void> = [];

2.1.2. New export: registerShutdownHandler

Insert after the __resetForTests block (end of file):

/**
 * Register a cleanup function to be called during graceful shutdown.
 *
 * Handlers are invoked in **reverse registration order** (LIFO) after the
 * transport and DB have been closed. A throwing handler is logged and
 * swallowed — it does not abort remaining handlers or change the exit code.
 *
 * Returns a deregister function. Calling it removes this specific handler
 * from the list. Calling it after shutdown has already run is a no-op.
 */
export function registerShutdownHandler(
  fn: () => Promise<void> | void,
): () => void {
  shutdownHandlers.push(fn);
  return (): void => {
    const idx = shutdownHandlers.indexOf(fn);
    if (idx !== -1) {
      shutdownHandlers.splice(idx, 1);
    }
  };
}

/**
 * @internal test-only. Clears all registered shutdown handlers.
 * Called by `__resetForTests()` to ensure hermeticism between tests.
 */
export function clearShutdownHandlers(): void {
  shutdownHandlers = [];
}

2.1.3. Extend __resetForTests to call clearShutdownHandlers()

export function __resetForTests(): void {
  // ... existing resets ...
  clearShutdownHandlers();  // ← ADD THIS LINE
}

2.1.4. Extend shutdown() async body — new step 4

After the existing “Step 3 — signals” block and before the if (forced) check:

// Step 4 — user-registered handlers, LIFO order.
// Snapshot the list before iteration so late-registrations during shutdown
// are not called for this lifecycle.
const handlersSnapshot = shutdownHandlers.slice();
for (let i = handlersSnapshot.length - 1; i >= 0; i--) {
  try {
    await handlersSnapshot[i]!();
  } catch (err) {
    logger(`[Shutdown] handler[${i}] failed:`, err);
  }
}

2.2. src/shutdown.ts (new file)

/**
 * Colibri — γ Graceful Shutdown public surface (P0.4.2).
 *
 * This barrel re-exports the shutdown handler registration API and the
 * shutdown trigger from `./startup.js`. All implementation logic lives in
 * startup.ts alongside the transport + DB lifecycle machinery.
 *
 * Callers should import from here (`./shutdown.js`) for a stable public
 * surface that is insulated from future startup.ts refactors.
 *
 * Canonical references:
 *   - docs/guides/implementation/task-breakdown.md § P0.4.2
 *   - docs/contracts/p0-4-2-graceful-shutdown-contract.md
 *   - docs/audits/p0-4-2-graceful-shutdown-audit.md
 */
export { registerShutdownHandler, shutdown } from './startup.js';

2.3. src/__tests__/shutdown.test.ts (new file)

Test suite outline (12 tests across 4 describe blocks):

describe 1: registerShutdownHandler — registration

  1. Registers a handler that is called on shutdown
  2. Returns a callable deregister function (typeof === ‘function’)
  3. Deregister removes the handler (not called after deregister)
  4. Registering same fn twice calls it twice

describe 2: LIFO ordering

  1. Two handlers: B (registered last) runs before A
  2. Three handlers [A, B, C]: order is C, B, A

describe 3: error isolation

  1. Throwing handler does not abort remaining handlers
  2. Throwing handler logs [Shutdown] handler[i] failed:

describe 4: signal integration

  1. SIGTERM triggers registered handler + exit(0)
  2. SIGINT triggers registered handler + exit(0)
  3. Idempotency: handler called once when shutdown() called twice
  4. clearShutdownHandlers resets the list (test isolation)

3. Test list (expanded)

# Description Key assertion
1 Single handler registered → shutdown handler spy called once
2 Return value of registerShutdownHandler typeof deregister === ‘function’
3 Deregister then shutdown handler NOT called
4 Same fn registered twice fn.mock.calls.length === 2
5 A registered first, B second → shutdown B called before A
6 A, B, C registered → shutdown call order is C, B, A
7 Handler A throws, handler B follows → shutdown B still called
8 Handler throws → log message logs contain [Shutdown] handler[0] failed:
9 SIGTERM + registered handler handler called + exit(0)
10 SIGINT + registered handler handler called + exit(0)
11 shutdown() called twice in-flight handler called exactly once
12 clearShutdownHandlers → no handler invoked no spy calls after clear

4. Execution checklist

  • Add shutdownHandlers module state to startup.ts
  • Export registerShutdownHandler from startup.ts
  • Export clearShutdownHandlers from startup.ts
  • Extend __resetForTests to call clearShutdownHandlers()
  • Add step 4 to shutdown() async body
  • Create src/shutdown.ts barrel
  • Create src/__tests__/shutdown.test.ts with 12 tests
  • Run npm test — all existing startup.test.ts tests pass
  • Run npm run lint — no errors
  • Run npm run build — no TS errors

5. Risk guard-rails

  • Handler list starts empty: existing startup.test.ts tests never call registerShutdownHandler, so the new step 4 iterates 0 handlers — no effect.
  • Snapshot before LIFO loop: late-registrations during shutdown are not invoked for the current lifecycle (safe concurrency invariant).
  • clearShutdownHandlers in __resetForTests: prevents handler list leaking between startup.test.ts tests that now share module state with the handler list.

Back to top

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

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