diff --git a/CHANGELOG.md b/CHANGELOG.md index c978c18..6383027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **The `graceful shutdown: complete` line could say `reason: "drained"` next + to a non-zero exit code.** When cleanup timed out or failed it set the exit + code but not the reason, so the one line an operator alerts on contradicted + itself. `reason` now names whatever decided the exit code + (`cleanup-timeout` / `cleanup-failed`) and the drain outcome keeps its own + `drain` key, so the shape stays stable and neither fact is lost. Shipped in + 0.5.0; found reviewing the same code in + [auth.provider#511](https://github.com/o3co/auth.provider/pull/511). + +- The `exit` option's doc said it defaults to `process.exit` after the default + became `deferExit`. + ## [0.5.0] — 2026-09-06 ### Security diff --git a/src/__tests__/shutdown.test.mts b/src/__tests__/shutdown.test.mts index 52d5331..649ca5a 100644 --- a/src/__tests__/shutdown.test.mts +++ b/src/__tests__/shutdown.test.mts @@ -250,6 +250,34 @@ describe("installGracefulShutdown", () => { expect(exitProcess).toHaveBeenCalledWith(3); }); + it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => { + // `exitCode` became 1 while `reason` still said "drained", so the one line + // an operator alerts on contradicted itself. The drain outcome is still + // carried, under its own key, so neither fact is lost. + const { signals, finishDraining, logger, exit } = install({ + cleanup: () => Promise.reject(new Error("teardown failed")), + }); + signals.get("SIGTERM")?.(); + finishDraining(); + await settle(); + expect(logger.info).toHaveBeenCalledWith( + { reason: "cleanup-failed", drain: "drained", exitCode: 1 }, + "graceful shutdown: complete", + ); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("keeps reason and drain identical when cleanup succeeds", async () => { + const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() }); + signals.get("SIGTERM")?.(); + finishDraining(); + await settle(); + expect(logger.info).toHaveBeenCalledWith( + { reason: "drained", drain: "drained", exitCode: 0 }, + "graceful shutdown: complete", + ); + }); + it("removes its own signal listeners once shutting down", () => { const { signals } = install(); signals.get("SIGTERM")?.(); diff --git a/src/shutdown.mts b/src/shutdown.mts index 6f2c870..97f3f86 100644 --- a/src/shutdown.mts +++ b/src/shutdown.mts @@ -71,7 +71,7 @@ export interface GracefulShutdownOptions { * sequence — size both against the orchestrator's grace period, not one. */ readonly cleanupTimeoutMs?: number; - /** Injected in tests; defaults to `process.exit`. */ + /** Injected in tests; defaults to {@link deferExit}. */ readonly exit?: (code: number) => void; /** Injected in tests; defaults to `process.on`. */ readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void; @@ -146,10 +146,16 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow if (finished) return; finished = true; let exitCode = code; + // `reason` names whatever decided the exit code, so the line an operator + // alerts on cannot say "drained" next to a non-zero code. The drain + // outcome keeps its own key rather than being overwritten — both facts + // are wanted, and a stable shape is what makes the line queryable. + let outcome = reason; try { if ((await runCleanup()) === CLEANUP_TIMED_OUT) { logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out"); exitCode = 1; + outcome = "cleanup-timeout"; } } catch (err) { // Through the app logger, not `console.error`: a shutdown that @@ -158,8 +164,9 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow // drops. logger.error({ err }, "graceful shutdown: cleanup failed"); exitCode = 1; + outcome = "cleanup-failed"; } - logger.info({ reason, exitCode }, "graceful shutdown: complete"); + logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete"); exit(exitCode); };