fix: bound network worker termination to prevent shutdown hang - #9582
fix: bound network worker termination to prevent shutdown hang#9582lodekeeper wants to merge 3 commits into
Conversation
`terminateWorkerThread` awaited `Thread.terminate(worker)` outside the timeout race. `Thread.terminate` resolves to Node's `Worker.terminate()`, which cannot preempt a worker stuck inside a synchronous native (napi) call, so that await can hang indefinitely — making the intended `retryCount * retryMs` budget and the throw unreachable. Graceful shutdown then hangs (observed ~5 min until SIGKILL) instead of failing bounded. Move the `Thread.terminate()` call inside the `Promise.race` so both the terminate call and the termination-event wait are bounded by the timeout. Add a unit test covering the success path and the stuck-terminate case (throws within retryCount * retryMs instead of hanging forever). 🤖 Generated with AI assistance
There was a problem hiding this comment.
Code Review
This pull request updates terminateWorkerThread to race Thread.terminate(worker) against the timeout, preventing indefinite hangs when a worker is stuck in a synchronous native call, and adds corresponding unit tests. The review feedback recommends utilizing Vitest's fake timers (vi.useFakeTimers and vi.advanceTimersByTimeAsync) in the new tests to eliminate real-time delays and prevent potential flakiness in CI environments.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); |
There was a problem hiding this comment.
Since afterEach already restores real timers via vi.useRealTimers(), it looks like the intention was to use fake timers. Enabling fake timers in beforeEach allows us to run the timeout tests deterministically and instantly without relying on real-world clock drift or introducing flakiness in CI environments.
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| }); | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| vi.useFakeTimers(); | |
| }); |
There was a problem hiding this comment.
Good catch — done in 46bdc72. You're right that the afterEach(vi.useRealTimers()) was vestigial (fake timers were never enabled in beforeEach), so I wired in vi.useFakeTimers() there to make the setup consistent.
| it("throws bounded instead of hanging when Thread.terminate() never resolves", async () => { | ||
| // Simulate a worker stuck in a blocking native call: terminate() never resolves and no | ||
| // termination event is ever emitted. The old implementation awaited terminate() outside the | ||
| // race and would hang forever; the fix must fall through to the throw within the retry budget. | ||
| mockEvents([]); | ||
| vi.mocked(Thread.terminate).mockReturnValue(new Promise<void>(() => {}) as never); | ||
|
|
||
| const start = Date.now(); | ||
| await expect(terminateWorkerThread({worker, retryMs, retryCount})).rejects.toThrow( | ||
| `Worker thread failed to terminate in ${retryCount * retryMs}ms.` | ||
| ); | ||
| // Must have retried the terminate call each iteration and stayed bounded (allow generous slack). | ||
| expect(Thread.terminate).toHaveBeenCalledTimes(retryCount); | ||
| expect(Date.now() - start).toBeLessThan(retryMs * retryCount * 20); | ||
| }); |
There was a problem hiding this comment.
With fake timers enabled, we can avoid using Date.now() and real-time delays entirely. We can instead use vi.advanceTimersByTimeAsync to fast-forward the timers deterministically, making the test faster and completely immune to CI CPU scheduling delays.
it("throws bounded instead of hanging when Thread.terminate() never resolves", async () => {
// Simulate a worker stuck in a blocking native call: terminate() never resolves and no
// termination event is ever emitted. The old implementation awaited terminate() outside the
// race and would hang forever; the fix must fall through to the throw within the retry budget.
mockEvents([]);
vi.mocked(Thread.terminate).mockReturnValue(new Promise<void>(() => {}) as never);
const promise = terminateWorkerThread({worker, retryMs, retryCount});
// Fast-forward time to trigger the timeouts sequentially
await vi.advanceTimersByTimeAsync(retryMs * retryCount);
await expect(promise).rejects.toThrow(
`Worker thread failed to terminate in ${retryCount * retryMs}ms.`
);
// Must have retried the terminate call each iteration and stayed bounded.
expect(Thread.terminate).toHaveBeenCalledTimes(retryCount);
});There was a problem hiding this comment.
Applied in 46bdc72, adapted to the current code. The test now creates the promise, drives the per-retry sleep(retryMs) timeouts with await vi.advanceTimersByTimeAsync(retryMs * retryCount), and asserts on the result. Two deltas from the snippet:
terminateWorkerThreadnow returnsfalseinstead of throwing (changed in 6ec9952, after this review), so the assertion isresolves.toBe(false)rather thanrejects.toThrow(...).- Dropped the wall-clock
Date.now()bound — under fake timers it's meaningless (Date.now()is faked), and the bound is now enforced deterministically by the controlled timer advancement.
Verified 2/2 green; the bounded test dropped from ~60ms real time to ~2ms.
…meout Complete the bounded-terminate fix so a stuck network worker yields a clean shutdown, not just a bounded one: - Return `false` instead of throwing when the worker can't be terminated. Throwing aborts the rest of `BeaconNode.close()` (the AbortController that stops the clock/chain timers, `chain.persistToDisk()`, and `db.close()`), leaving the process to exit via unhandledRejection with an unclean DB close. - `unref()` the network worker when it can't be terminated: a still-running worker is ref'd and keeps the main event loop alive, so bounding the terminate alone would still prevent the process from exiting on its own. On nodes hitting the hang every shutdown (glamsterdam-devnet-6) this turns each shutdown from a ~5 min zombie / unclean crash into a clean bounded exit. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Gemini review on ChainSafe#9582: the `afterEach(vi.useRealTimers())` cleanup was vestigial (fake timers were never enabled in `beforeEach`). Enable them and drive the per-retry `sleep(retryMs)` timeouts with `vi.advanceTimersByTimeAsync`, so the bounded-shutdown test is deterministic on CI instead of depending on real-time delays and a wall-clock assertion. Kept `resolves.toBe(false)` (the function returns false rather than throwing since 6ec9952). 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review — applied the fake-timer suggestion in 46bdc72: |
nflaig
left a comment
There was a problem hiding this comment.
this seems like a workaround to me, we should rather fix the root cause
|
You're right, the bound is a workaround — agreed. Laying out where the root cause actually sits so we can decide whether to ship it as a safety net vs. block on the real fix: Graceful shutdown already runs before terminate. Why terminate itself hangs. The true fix needs evidence we don't have yet: a native stack from a stuck worker on prod ( Case for shipping the bound now as defense-in-depth (explicitly not as "the fix"):
Suggestion: merge this as the safety net and track the root cause separately — I can open a tracking issue (capture native stack → upstream |
|
@lodekeeper please open a PR on libp2p-quic instead to fix the root cause |
|
Opened the root-cause PR: ChainSafe/js-libp2p-quic#59. Root cause: Fix: make the transport That makes #9582's bound pure defense-in-depth on top of the real fix — your call whether to keep it as a belt-and-suspenders safety net or close it once #59 lands. |
closing for now |
* fix: release QUIC client endpoints on transport stop QuicTransport creates its dialer Client endpoints eagerly in the constructor and shares them across every dial, but never closes them: it implements no Startable lifecycle, so libp2p's component teardown (which only stops `isStartable` components) skips it, and unlike the listener's Server (torn down in QuicListener.close()) nothing else aborts the client endpoints. Each Client holds a live quinn::Endpoint (UDP socket + Tokio driver task). Left open, the driver keeps polling the socket indefinitely. When the transport runs in a worker thread (e.g. Lodestar's network worker) this keeps the worker busy so Worker.terminate() can never reach a V8 teardown safepoint, hanging shutdown until the process is force-killed. Make QuicTransport Startable and abort both client endpoints in stop(), mirroring how the listener releases its Server. libp2p then invokes stop() during libp2p.stop(), releasing the endpoints so the worker can exit cleanly. Refs ChainSafe/lodestar#9582 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: recreate QUIC dialer endpoints on restart * fix: log QUIC transport start --------- Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Motivation
Lodestar can hang on graceful shutdown:
terminating network workeris logged butterminated network workernever is, the node keeps tickingSynced (slot -N), and it only exits when force-killed (~5 min via docker SIGKILL). On ethpandaops devnets (glamsterdam-devnet-6, lodestar-geth nodes) this reproduces on every shutdown. Recurrence of #5775 / #6053, reintroduced by the libp2p v3 + QUIC upgrades.Root cause
terminateWorkerThreadawaitedThread.terminate(worker)outside the timeout race.Thread.terminate()delegates to Node'sworker.terminate(), which forcibly stops the worker at the next JS safepoint but cannot preempt a worker blocked inside a synchronous native (napi) call. The network worker's only native transport is@chainsafe/libp2p-quic(Rust/quinn), so when a QUIC native call is in flight at terminate time the promise never resolves — and with theawaitoutside the race theretryCount * retryMsbudget is unreachable → unbounded hang.A
getActiveResourcesInfo()/_getActiveHandles()probe in the worker at shutdown showed no leftover UDP/TCP libuv handle — so it isn't a lingering handle keeping the loop alive, it'sworker.terminate()being unable to preempt native code. The hang did not reproduce locally onunstable(6 SIGTERMs mid-QUIC-download, all terminated in 6–14 ms), so it's timing/condition-specific to the affected build; QUIC is implicated by elimination (@libp2p/tcpis pure-JS and can't block a forced terminate; discv5 is a separate worker, already closed earlier).Fix
Thread.terminate()inside the timeout so a stuckworker.terminate()fails withinretryCount * retryMs(~3 s) instead of hanging.BeaconNode.close()rejects atawait this.network.close(), sochain.persistToDisk()etc. are skipped and the shutdown handler falls to itscatch→db.close()+process.exit(1). Returningfalseletsnode.close()run to completion (state persisted, everything closed) →process.exit(0). For a node that hangs on every shutdown, that's the difference between a clean exit-0 and an error exit-1 (skipping the state persist) on every restart.unref()the worker when it can't be terminated so a still-running (native-blocked) worker can't keep the event loop alive. Harmless given the handler's explicitprocess.exit, but makesBeaconNode.close()self-sufficient and is a step toward removing that explicit-exit workaround (Beacon node active handles after close sequence is completed #5642).Testing
workerEvents.test.ts): a worker that never terminates returnsfalsein bounded time (~retryCount * retryMs) instead of hanging; the normal path returnstrue.unrefsemantics directly (a live worker keeps the process alive;unreflets it exit).biome+tsgoclean for the changed files.Follow-up (not blocking)
Pinning the exact
@chainsafe/libp2p-quic/ quinn native frame (a worker-thread stack on a box during a live hang) for an upstream report. The bound here fixes the hang regardless of which native call it is.