P1: Graceful shutdown — drain, flush traces, uncaught handlers (#170) - #188
Conversation
|
Really nice to see this landed — bounding the drain, flushing spans, and centralizing exit into one idempotent, unit-tested orchestrator is exactly what #170 needed, and dropping the old One thing worth fixing before merge: the // graceful-shutdown.ts
return async function shutdown(reason: string, exitCode = 0): Promise<void> {
// …unchanged…
exitOnce(exitCode); // success path uses the caller's code
// catch { exitOnce(1) } // unchanged
};// index.ts
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
void shutdown('uncaughtException', 1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
void shutdown('unhandledRejection', 1);
});Signals keep the default Secondary (optional): the closers run only after Heads-up on merges: this overlaps #193 and #190 on both |
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>
|
Thanks @SanabriaRusso — good catch on the exit code, that one was a real regression in crash signal and I've fixed it in Crash exits are now non-zero. await shutdown('uncaughtException', 1);
assert.deepStrictEqual(calls, ['server', 'traces']); // drain still happens
assert.deepStrictEqual(exits, [1]); // but supervisors see a failureTook the On the merge overlap with #193/#190 — agreed, and that's the plan: this sits in wave 2 of the merge order, so I'll reconcile |
|
Verdict: MERGEABLE ✅ Both of @SanabriaRusso's points are genuinely fixed at What I checked
Non-blocking nits1. Neither closer is individually bounded, and they're serialized — the first hang eats the whole budget.
So today an unreachable Jaeger means the trace flush and the pg pool close are both skipped via force-exit — the exact teardown this PR exists to add. Bound each step: // src/index.ts
const withTimeout = (label: string, ms: number, run: () => Promise<unknown>) =>
Promise.race([
run(),
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms).unref()
),
]).then(() => undefined);
closers: [
// Flush buffered spans. 3s: well under SHUTDOWN_TIMEOUT_MS so an unreachable
// Jaeger (OTEL_BSP_EXPORT_TIMEOUT defaults to 30s) can't eat the whole budget
// and skip the pool close below.
() => withTimeout('trace flush', 3_000, async () => { if (provider) await provider.shutdown(); }),
// Close the Postgres pool. Bounded because porsager's sql.end() defaults to
// timeout: null (waits indefinitely for open connections).
() => withTimeout('pg pool close', 5_000, () => context.db_client.close()),
],A failing closer is already logged-and-continued ( Ordering is already right: both run after 2.
The concrete victim is 10s is correctly below the k8s default 3. The force timer is
4. Readiness pre-drain is missing — pre-existing, but
// src/index.ts, after #187 merges
const PRE_DRAIN_DELAY_MS = Number(process.env.PRE_DRAIN_DELAY_MS) || 5_000;
createGracefulShutdown({
timeoutMs: SHUTDOWN_TIMEOUT_MS,
closeServer: async () => {
setNotReady(); // /readiness -> 503; LB drops this pod
await new Promise((r) => setTimeout(r, PRE_DRAIN_DELAY_MS)); // let endpoints propagate
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeIdleConnections();
});
},
// ...
});Budget check: Regression test for the ordering, mirroring the existing suite's style: test('marks unready and waits before closing the listener', async () => {
const calls: string[] = [];
const shutdown = createGracefulShutdown({
closeServer: async () => {
calls.push('unready'); // stand-in for setNotReady()
calls.push('close');
},
closers: [async () => calls.push('db')],
timeoutMs: 1000,
onExit: () => {},
log: () => {},
});
await shutdown('SIGTERM');
assert.deepStrictEqual(calls, ['unready', 'close', 'db']);
});Branch is Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api. |
SanabriaRusso
left a comment
There was a problem hiding this comment.
Approving on the basis of the second-pass review comment above: no mid-to-high severity security, compatibility, or degradation issue found, and the downstream contract with mina-explorer / mina-explorer-api holds — GraphQL validation error text reaches errors[].message verbatim, the browser SPA's cross-origin access is preserved, and the real consumer query shapes (including the 2000-block analytics query and the 500-row page crawl) still pass.
Two things this approval does not mean:
- It does not close the non-blocking items in the review comment. Several are worth fixing before or shortly after merge; they are written up there with patches.
- It does not by itself mean the branch is ready to merge.
mainrequires branches to be up to date, so this needs an update-branch (or a rebase, if the branch is conflicting) first, and a few PRs in this series have cross-PR ordering constraints called out in their review comments.
Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.
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>
2e3abc3 to
848af58
Compare
SanabriaRusso
left a comment
There was a problem hiding this comment.
Approving. Both fix commits do what they claim, and I chased the cross-PR interaction I expected to be broken and found it already handled.
26bdb57a — non-zero exit on crash: correct. src/index.ts:73-80 logs the error/reason and then calls shutdown(reason, 1); the code is threaded through to exitOnce(exitCode) at graceful-shutdown.ts:68, so a crash drains in full and exits 1. The regression test asserts both halves ("a crash-initiated shutdown drains cleanly but still exits non-zero" → calls == ['server','traces'] and exits == [1]), which will catch a regression in either direction — an exit-0 regression or one that skips the drain. Nothing is swallowed: the console.error precedes the shutdown call.
848af58c — bounded teardown: correct, and the timeout cannot itself hang. I traced all three bounds:
- the outer
forceTimer(graceful-shutdown.ts:54-57, default 20 s →exitOnce(1)) — a baresetTimeoutwith nothing to await, so it structurally cannot hang; withTimeout('trace flush', 3000, …)(index.ts:57) for an unreachable Jaeger collector;withTimeout('pg pool close', 5000, …)(index.ts:63) for postgres.js, which otherwise waits forever.
closeServer has no individual timeout but the outer timer covers it. clearTimeout is in a finally (:73), exitOnce (:48-52) prevents a double-exit when the timer races a successful drain, a throwing closer is caught per-iteration (:64) without aborting the rest, and the started guard (:36/:44) makes a second signal — or an exception recurring mid-drain — a no-op rather than a loop. server.closeIdleConnections() alongside server.close() is the right pairing; without it browser keep-alives would hold the drain open for keepAliveTimeout.
The #187 integration gap I was looking for doesn't exist. #187 doesn't add a separate teardown path — it extends the existing close() to await Promise.all([this.client.end(), this.pingClient.end()]). So context.db_client.close() here transitively closes the new dedicated max:1 ping client, inside this PR's 5 s bound. Noting it explicitly so nobody "helpfully" adds a second close later.
Non-blocking notes
-
unhandledRejectionfatality is not a regression — worth being explicit, since registering the listener suppresses Node's default--unhandled-rejections=throw. Onmaina stray rejection already kills the process (default throw → uncaught → exit 1). After this it still does, but gracefully and with the same exit code. Net strictly better. If yoga/whatwg-node produced spurious rejections on client aborts,mainwould already be crash-looping — it isn't. -
The crash path drains for up to the full 20 s in a possibly-corrupt state. Node's guidance after
uncaughtExceptionis to exit fast. The listener is already closed so no new work is accepted, and letting in-flight requests finish is usually what you want — but a shorter budget for the crash path specifically (2–3 s) would be more conservative. Your call. -
SHUTDOWN_TIMEOUT_MS=0silently becomes 20000 (Number(x) || 20000,index.ts:9). Cosmetic.
Cross-cutting gap this PR should NOT be asked to fix. Round 1 flagged the rolling-deploy drop as an unowned gap, and it still is. Once this lands, SIGTERM closes the listener immediately — closeServer (index.ts:46-52) calls server.close() then closeIdleConnections(), with no readiness pre-drain and no delay. #187 adds /readiness with no shutdown integration; #196 does now add a preStop: sleep 15, which covers most of it. What is still missing is the in-process half: a readiness flag that flips to failing on SIGTERM before closeServer() runs, so /readiness returns 503 while the listener is still open and the endpoint removal has somewhere to land.
This should not block this PR — #188 is a strict improvement over main, which does a bare server.close() with no drain bound, no timeout, and process.exit(0) on every path including crashes. The remaining half belongs with #187, since it owns /readiness and would export the flag this handler sets. Recommend filing it as a standalone issue rather than expanding scope here; the shutdown machinery in this PR is already the correct foundation for it.
Downstream: positive, no risk. For both consumers this is a strict improvement — main tears the listener down with no drain bound at all, so in-flight requests are lost outright on every restart; this gives them up to 20 s to complete, comfortably inside both consumers' 20 s client timeout. No schema, resolver, error-text, or HTTP-status change.
Merge ordering: conflicts textually with #189 in .env.example.compose / .env.example.lightnet (this PR inserts SHUTDOWN_TIMEOUT_MS right after PORT; #189 deletes APP_COMMAND right before it — overlapping hunks, both changes should survive), with #193 in the docs/getting-started.md config table, and with both #193 and #194 in src/server/plugins.ts. All semantically compatible, just needs care.
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>
848af58 to
32f058e
Compare
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>
32f058e to
fd69df4
Compare
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>
fd69df4 to
59be462
Compare
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>
59be462 to
32b8afe
Compare
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>
d8a94fd to
be92c97
Compare
SanabriaRusso
left a comment
There was a problem hiding this comment.
Re-approving after the rebase (the previous approval was dismissed by the force-push).
The rebase here was not mechanical, so I re-checked the delta rather than assuming. Compared against the commit I approved (32b8afe), the only content change is the one the rebase required, and it is the correct resolution of the buildPlugins() signature hazard:
tests/unit/metrics.test.ts
- plugins: await buildPlugins(),
+ const built = await buildPlugins();
+ plugins: built.plugins,
#191 landed on main after my review and its test called the old form. Every caller is now consistent — src/index.ts:35 destructures { plugins, provider }, tests/unit/metrics.test.ts:26 uses built.plugins, and there is no remaining return plugins call site. All checks green.
Carrying forward the round-2 verification unchanged:
- Non-zero exit on crash-initiated shutdown verified, with a test asserting both that it drains and that it exits 1.
- Bounded teardown verified: three independent bounds, and the outer
forceTimeris a baresetTimeoutso it cannot itself hang.exitOnceguards the timer racing a successful drain. - The #187 integration gap does not exist: #187 extended the existing
close()rather than adding a second path, sodb_client.close()here transitively closes the readiness ping client inside the 5s bound.
Merge-train note: this signature change is the one that six other open PRs can silently undo. A conflict resolution that keeps return plugins drops trace flushing on shutdown with no error — provider is simply undefined. Merge this before the remaining plugins.ts editors, or re-check the destructure after each.
Still unowned (not this PR's scope): the in-process half of the readiness pre-drain flip — a flag that flips /readiness to 503 on SIGTERM before closeServer() runs, so endpoint removal has somewhere to land. That belongs with #187, which owns /readiness. Tracked in #210.
Shutdown previously called `server.close()` then `process.exit(0)` from the close event. It didn't bound how long draining could take, never flushed OpenTelemetry spans (losing the tail of traces on deploy), and had no handlers for uncaughtException / unhandledRejection. Add a small, unit-tested `createGracefulShutdown` orchestrator and wire it into the entry point: - Drain in-flight requests via `server.close()`, then run teardown steps (flush the tracer provider, close the Postgres pool) in order. - A hard `SHUTDOWN_TIMEOUT_MS` deadline (default 10s) forces exit if draining or teardown hangs; the process exits at most once. - The handler is idempotent, so a second signal is ignored. - SIGINT/SIGTERM/SIGQUIT plus uncaughtException/unhandledRejection all route through it. `buildPlugins` now returns the tracer provider so the entry point can flush it. Unit tests cover ordering, idempotency, a failing teardown step, and the timeout-forces-exit path with an injected exit hook. Closes #170. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
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>
be92c97 to
ee5887c
Compare
What & why
Part of the production-readiness epic (#163). Closes #170.
Shutdown previously called
server.close()thenprocess.exit(0)from the close event. It didn't bound how long draining could take, never flushed OpenTelemetry spans (losing the tail of traces on every deploy), and had no handlers foruncaughtException/unhandledRejection.Changes
src/server/graceful-shutdown.ts— a small, unit-testedcreateGracefulShutdownorchestrator.server.close(), then run teardown steps (flush the tracer provider, close the Postgres pool) in order.SHUTDOWN_TIMEOUT_MSdeadline (default 10s) forces exit if draining/teardown hangs; the process exits at most once.SIGINT/SIGTERM/SIGQUITplusuncaughtException/unhandledRejectionall route through it.buildPluginsnow returns the tracerproviderso the entry point can flush it.SHUTDOWN_TIMEOUT_MS10000Testing
npm run build— cleannpm run test:unit— all pass; new tests cover ordering, idempotency, a failing teardown step (logged, doesn't abort the rest), and the timeout-forces-exit path (injected exit hook, asserts exit code1fires exactly once)npm run lint— cleannpx prettier --debug-check .— exit 0🤖 Generated with Claude Code