Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +17 to +19

- The `exit` option's doc said it defaults to `process.exit` after the default
became `deferExit`.

## [0.5.0] — 2026-09-06

### Security
Expand Down
28 changes: 28 additions & 0 deletions src/__tests__/shutdown.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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")?.();
Expand Down
11 changes: 9 additions & 2 deletions src/shutdown.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment on lines +149 to +153
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
Expand All @@ -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);
};

Expand Down
Loading