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
- Registers a handler that is called on shutdown
- Returns a callable deregister function (typeof === ‘function’)
- Deregister removes the handler (not called after deregister)
- Registering same fn twice calls it twice
describe 2: LIFO ordering
- Two handlers: B (registered last) runs before A
- Three handlers [A, B, C]: order is C, B, A
describe 3: error isolation
- Throwing handler does not abort remaining handlers
- Throwing handler logs
[Shutdown] handler[i] failed:
describe 4: signal integration
- SIGTERM triggers registered handler + exit(0)
- SIGINT triggers registered handler + exit(0)
- Idempotency: handler called once when shutdown() called twice
- 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
shutdownHandlersmodule state tostartup.ts - Export
registerShutdownHandlerfromstartup.ts - Export
clearShutdownHandlersfromstartup.ts - Extend
__resetForTeststo callclearShutdownHandlers() - Add step 4 to
shutdown()async body - Create
src/shutdown.tsbarrel - Create
src/__tests__/shutdown.test.tswith 12 tests - Run
npm test— all existingstartup.test.tstests 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.tstests never callregisterShutdownHandler, 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).
clearShutdownHandlersin__resetForTests: prevents handler list leaking betweenstartup.test.tstests that now share module state with the handler list.