node:cluster: worker.disconnect() drops queued send() messages and the worker never sees 'disconnect' - #33594
node:cluster: worker.disconnect() drops queued send() messages and the worker never sees 'disconnect'#33594robobun wants to merge 2 commits into
Conversation
…annel
The primary-side Worker.prototype.disconnect called this.process.disconnect()
immediately after queueing the internal {act:'disconnect'} message. That tears
down the primary's end of the IPC channel while earlier worker.send() writes
are still queued, so the worker receives only whatever already reached the
kernel socket buffer and never sees 'disconnect' (the internal message is
ordered behind the dropped user messages). Node's primary.js has never had
this call: it only sends {act:'disconnect'} and the worker closes the channel
from its side after its servers have closed.
Removing the call exposes a second bug on the worker side: internal/cluster/child
and node:net each declared a private Symbol('owner_symbol'), so Worker#_disconnect
could never find the net.Server owning a cluster faux handle, fell back to the
faux close() (which ignores the callback), and never called process.disconnect().
Without the primary force-closing the channel this now hangs instead of silently
truncating. Share owner_symbol via internal/shared and link the faux handle to
its server in listenInCluster so _disconnect closes the real server and completes.
test-cluster-worker-wait-server-close.js's in-worker assertion now passes
instead of firing into a worker that was already being force-exited.
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 2:05 AM PT - Jul 7th, 2026
❌ @robobun, your commit a2eba0c has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33594That installs a local version of the PR into your bun-33594 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Correct, this is intentionally a subset of #30548's src/ changes (see the Fix section of the PR body). #30548 has been open since May with a broader scope (it also adds a |
There was a problem hiding this comment.
I didn't find any bugs, but this changes the cluster disconnect protocol (shifting channel-teardown ownership from primary to worker) and overlaps with #30548, so a maintainer should sign off on the lifecycle change and decide how to reconcile the two PRs.
Extended reasoning...
Overview
This PR fixes worker.disconnect() in node:cluster dropping queued IPC messages. It removes this.process.disconnect() from the primary's Worker.prototype.disconnect (so the worker owns channel teardown, matching Node), unifies the previously-duplicated owner_symbol between node:net and internal/cluster/child via internal/shared, and wires the cluster faux handle to its owning net.Server in listenInCluster so the worker-side _disconnect can complete. A new regression test in test/js/node/cluster.test.ts sends 50×64KiB messages before disconnect() and asserts full delivery plus 'disconnect' observation on both sides.
Security risks
None identified. This is purely IPC lifecycle/ordering within the cluster module; no auth, crypto, or untrusted-input parsing is touched.
Level of scrutiny
Moderate-to-high. Although the diff is small (~25 lines of src/ changes), it inverts who closes the IPC channel during graceful shutdown. The PR body itself notes that removing the primary's force-close exposed a latent worker-side hang that had to be fixed in the same PR — which means correctness now depends on the worker always reaching process.disconnect() via the owner_symbol/server.close(cb) path. If any server type (the PR body mentions http.Server is handled only in #30548) or edge case leaves waitingCount stuck, the worker hangs where it previously would have been force-closed. That's a real behavioral trade-off a maintainer should weigh.
Other factors
- There's an open, overlapping PR (#30548) that is a superset of these src/ changes; a human should decide whether to land this narrower fix first or fold it into the broader one.
- The
listenInClusterchange addsserver.once("close", () => handle.close())— a new lifecycle coupling innode:netthat interacts with the faux-handleclose()and thehandlesmap inchild.ts. It looks correct (close()guards onkey === undefined), but it's the kind of subtle ordering change that benefits from a reviewer familiar with the cluster round-robin implementation. - The PR reports all 54
test-cluster-*parallel tests pass and adds a solid regression test, which is reassuring.
|
Re the review's concern that an // worker: http.createServer(...).listen(0)
// primary: wait for 'listening', then worker.disconnect()
Bun's |
|
Status: ready for review. No cluster, net, or IPC test has failed on either CI run of this branch. Remaining red on build 69627 is unrelated to the diff:
The new test ( |
worker.send()followed byworker.disconnect()is the documented graceful-shutdown pattern for cluster workers. In Bun the primary discards every message still queued in the IPC write buffer and the worker never gets its'disconnect'event, so rolling-restart / drain loops silently lose work.Reproduction
Node v26:
got 50. Bun 1.4.0:got 3(varies with scheduling) and the worker's'disconnect'listener never fires.Cause
Worker.prototype.disconnectininternal/cluster/primary.tscallsthis.process.disconnect()right after queueing the internal{act:'disconnect'}message. Node'sprimary.jshas never had this call: the primary only enqueues{act:'disconnect'}(ordered after the user's messages on the same channel) and the worker closes the channel from its side once it has drained its servers. Closing the primary's end first discards the still-queued outbound writes, including the internal disconnect message itself, so the worker treats it as an unexpected channel close and exits before user'disconnect'listeners run.Removing that call exposes a second bug in the worker-side
_disconnect:internal/cluster/child.tsandnode:neteach declared their own privateSymbol("owner_symbol"), sohandle[owner_symbol]on a cluster faux handle was alwaysundefinedand_disconnectfell through to the fauxclose()(which ignores its callback).waitingCountnever reached zero and the worker never calledprocess.disconnect(). Previously the primary's force-close masked this by closing the channel anyway; without it, a worker holding anet.Serverwould hang forever ondisconnect().Fix
internal/cluster/primary.ts: drop thethis.process.disconnect()call so the worker owns channel teardown, matching Node.internal/shared.ts: a single sharedowner_symbolthatnode:netandinternal/cluster/childboth import.node:netlistenInCluster: sethandle[owner_symbol] = serveron the cluster faux handle and close it when the server closes, so_disconnectcan callserver.close(cb)and complete.This is a subset of the src/ changes in #30548, which reached the same primary-side conclusion while fixing the server-shutdown hang. That PR additionally handles
http.Serverin cluster workers via a new_trackServerhook; this PR is narrower so the data-loss fix can land independently.Verification
New test in
test/js/node/cluster.test.tssends 50 messages with 64 KiB payloads (enough to back up past the kernel socket buffer), callsworker.disconnect(), and asserts the worker received all 50 and observed'disconnect'. On current main:received: 3, workerSawDisconnect: false. With this change it matches Node.test-cluster-worker-wait-server-close.js's in-worker assertion (serverClosedbefore'disconnect') now passes instead of firing in a worker that was already being force-exited. All 54test/js/node/test/parallel/test-cluster-*.jstests pass.