Skip to content

Commit 5bbb308

Browse files
dkijaniaclaude
andcommitted
fix(shutdown): exit non-zero on crash-initiated shutdown
uncaughtException/unhandledRejection routed through shutdown(), whose happy path always exited 0. A crash that drained cleanly therefore exited 0, which Kubernetes and systemd read as a clean stop — suppressing OnFailure restarts and non-zero-exit alerting, and reducing crash signal below Node's own default of 1. shutdown() now takes an exitCode used on the success path; signals keep 0 and the crash handlers pass 1. Also drop idle keep-alive sockets when draining: server.close() waits on them, so browser clients holding connections open could push the drain past SHUTDOWN_TIMEOUT_MS and skip the trace flush and pool close entirely. Addresses review feedback on #188. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b7d6964 commit 5bbb308

3 files changed

Lines changed: 40 additions & 6 deletions

File tree

‎src/index.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 10000;
2424
closeServer: () =>
2525
new Promise<void>((resolve, reject) => {
2626
server.close((error) => (error ? reject(error) : resolve()));
27+
// `close` also waits on idle keep-alive sockets, which browser
28+
// clients hold open for keepAliveTimeout; dropping them keeps the
29+
// drain prompt so the closers below still run inside the timeout.
30+
server.closeIdleConnections();
2731
}),
2832
closers: [
2933
// Flush any buffered OpenTelemetry spans before exit.
@@ -39,13 +43,15 @@ const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 10000;
3943
process.on(signal, () => void shutdown(signal));
4044
});
4145

46+
// Crashes exit non-zero: an exit 0 reads as a clean stop to Kubernetes and
47+
// systemd, suppressing restarts and non-zero-exit alerting.
4248
process.on('uncaughtException', (error) => {
4349
console.error('Uncaught exception:', error);
44-
void shutdown('uncaughtException');
50+
void shutdown('uncaughtException', 1);
4551
});
4652
process.on('unhandledRejection', (reason) => {
4753
console.error('Unhandled rejection:', reason);
48-
void shutdown('unhandledRejection');
54+
void shutdown('unhandledRejection', 1);
4955
});
5056
} catch (error) {
5157
console.error('An error occurred:', error);

‎src/server/graceful-shutdown.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ interface GracefulShutdownOptions {
1717
/**
1818
* Build an idempotent shutdown handler. On the first invocation it drains the
1919
* server, runs each closer (a failing closer is logged but doesn't abort the
20-
* rest), then exits 0. A hard timeout guarantees the process exits even if a
21-
* connection or teardown step hangs, and `onExit` is invoked at most once.
20+
* rest), then exits with the caller's code. A hard timeout guarantees the
21+
* process exits even if a connection or teardown step hangs, and `onExit` is
22+
* invoked at most once.
2223
*
2324
* Subsequent invocations (e.g. a second signal) are ignored.
2425
*/
@@ -34,7 +35,11 @@ function createGracefulShutdown(options: GracefulShutdownOptions) {
3435

3536
let started = false;
3637

37-
return async function shutdown(reason: string): Promise<void> {
38+
/**
39+
* `exitCode` is the code used when the drain succeeds; a crash-initiated
40+
* shutdown must pass non-zero so supervisors still see a failed exit.
41+
*/
42+
return async function shutdown(reason: string, exitCode = 0): Promise<void> {
3843
if (started) return;
3944
started = true;
4045
log(`Shutting down (${reason})…`);
@@ -61,7 +66,7 @@ function createGracefulShutdown(options: GracefulShutdownOptions) {
6166
log('Error during shutdown step', error);
6267
}
6368
}
64-
exitOnce(0);
69+
exitOnce(exitCode);
6570
} catch (error) {
6671
log('Error closing server', error);
6772
exitOnce(1);

‎tests/unit/graceful-shutdown.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,29 @@ describe('Graceful shutdown', () => {
7171
assert.deepStrictEqual(exits, [0]);
7272
});
7373

74+
test('a crash-initiated shutdown drains cleanly but still exits non-zero', async () => {
75+
const calls: string[] = [];
76+
const exits: number[] = [];
77+
const shutdown = createGracefulShutdown({
78+
closeServer: async () => {
79+
calls.push('server');
80+
},
81+
closers: [
82+
async () => {
83+
calls.push('traces');
84+
},
85+
],
86+
timeoutMs: 1000,
87+
onExit: (code) => exits.push(code),
88+
log: silent,
89+
});
90+
91+
await shutdown('uncaughtException', 1);
92+
// The drain still runs in full — only the exit code differs from a signal.
93+
assert.deepStrictEqual(calls, ['server', 'traces']);
94+
assert.deepStrictEqual(exits, [1]);
95+
});
96+
7497
test('exits 1 when draining exceeds the timeout, and only once', async () => {
7598
const exits: number[] = [];
7699
const shutdown = createGracefulShutdown({

0 commit comments

Comments
 (0)