node:cluster: let workers shut down gracefully on worker.disconnect() - #30548
node:cluster: let workers shut down gracefully on worker.disconnect()#30548robobun wants to merge 3 commits into
Conversation
|
Updated 9:40 AM PT - Aug 15th, 2026
❌ @robobun, your commit 7cd2886 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 30548That installs a local version of the PR into your bun-30548 --bun |
|
Warning Review limit reached
Next review available in: 3 minutes 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 (4)
WalkthroughThis PR fixes a hang that occurs when Bun cluster workers call ChangesCluster worker disconnect with TCP server
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status: ready for review at The case #20642 reports (a
Verification (debug build): with About the red The |
There was a problem hiding this comment.
Fix looks correct and well-tested, but it rewires faux-handle ↔ net.Server lifecycle in listenInCluster and activates the previously-dead maxConnections check in cluster's onconnection — subtle enough that someone familiar with Bun's cluster/net internals should give it a look.
Extended reasoning...
Overview
This PR fixes #20642 (cluster worker hangs on disconnect() when holding a net.Server). It touches three runtime files: src/js/internal/shared.ts (adds a shared owner_symbol), src/js/internal/cluster/child.ts (imports the shared symbol instead of a private one), and src/js/node/net.ts (imports the shared symbol and, in listenOnPrimaryHandle, sets handle[owner_symbol] = server plus server.once('close', () => handle.close())). It also adds a regression test + fixture. My earlier nit about the explicit 30_000 test timeout was addressed in a23fee2.
Security risks
None. This is internal handle-lifecycle bookkeeping between node:cluster child and node:net; no auth, crypto, user input parsing, or privilege boundaries are involved.
Level of scrutiny
Moderate-to-high. The diff is small (~15 runtime lines), the root-cause analysis is convincing, and the faux handle's close() is idempotent (key === undefined guard) so the new server.once('close', …) hook can't double-free. However, this is core node:net + node:cluster plumbing: it changes which object Worker#_disconnect closes (now the net.Server rather than the faux handle directly), it activates the previously-dead maxConnections branch in onconnection(), and the new owner_symbol/close-listener wiring in listenOnPrimaryHandle applies to both the round-robin and shared-handle code paths. These are exactly the kind of lifecycle interactions that benefit from review by someone with context on Bun's cluster implementation.
Other factors
No CODEOWNERS cover these files. The PR claims all test-cluster-* Node parallel tests still pass, and the new regression test exercises the exact reported hang. No bugs were flagged by the bug-hunting system. Given the subtlety of the area rather than any specific concern, I'm deferring rather than approving.
a23fee2 to
2118a4b
Compare
There was a problem hiding this comment.
Thanks for dropping the timeout. I didn't find any further issues — the symbol unification and faux-handle linkage look correct and the faux close() is idempotent — but since this changes teardown/ref-counting behavior in the core node:net cluster-worker listen path (and incidentally activates the previously-dead maxConnections check in onconnection), it's worth a quick human pass before merge.
Extended reasoning...
Overview
This PR fixes a hang when a cluster worker calls disconnect() while holding a net.Server. It touches:
src/js/internal/shared.ts: adds a sharedowner_symbol.src/js/internal/cluster/child.ts: imports the shared symbol instead of declaring a private one.src/js/node/net.ts: imports the shared symbol instead of declaring a private one, and inlistenInCluster'slistenOnPrimaryHandlecallback setshandle[owner_symbol] = serverplusserver.once("close", () => handle.close())so the faux round-robin handle is torn down when the server closes.- A new fixture and regression test in
test/js/node/cluster.test.ts.
The root-cause analysis is solid: the two modules previously had distinct private Symbol("owner_symbol") instances, so Worker#_disconnect could never find the owning server, fell back to the faux close() (which ignores its callback), and waitingCount never hit zero.
Security risks
None identified. No user-controlled input handling, auth, or privilege boundaries are affected; this is internal IPC/lifecycle wiring between cluster worker and primary.
Level of scrutiny
Moderate. The diff is small (~15 runtime lines) and gated to the cluster-worker branch of listenInCluster (!cluster.isPrimary && !exclusive), so the blast radius for non-cluster net users is nil. However:
- It changes teardown ordering and ref-counting (
setIntervalunref via fauxhandle.close()) in a path with subtle Node-compat semantics. - Sharing
owner_symbolbetweennet.tsandcluster/child.tsalso activates previously-dead code: themaxConnectionsguard inonconnection()(child.ts) now actually resolves to thenet.Serverand can reject connections. This matches Node.js, but it is a behavioral change beyond the headline fix. server.once("close", () => handle.close())plus the user's ownserver.close()and_disconnect'sserver.close(checkWaitingCount)meanshandle.close()may be reached afterhandles.clear()has already run; I traced this and the fauxclose()is idempotent (if (key === undefined) return), so it's safe.
Other factors
- My one prior nit (explicit
30_000test timeout) was addressed in a23fee2 / 2118a4b. - The bug-hunting system found no issues.
- CI is green for all
test-cluster-*and the new test across lanes; remaining failures are documented as unrelated flakes. - No CODEOWNERS cover these paths.
Given it's correct as far as I can tell but alters lifecycle semantics in core node:net/node:cluster, deferring to a human for a quick look rather than auto-approving.
61c07ee to
24e6306
Compare
There was a problem hiding this comment.
Fix looks correct and the timeout nit was addressed — deferring to a human since this rewires net/cluster handle lifecycle (and as a side effect activates the previously-dead maxConnections check in onconnection), which is worth a maintainer's eyes.
Extended reasoning...
Overview
This PR fixes #20642: cluster workers hang forever after worker.disconnect() when they own a net.Server. The root cause is that internal/cluster/child.ts and node/net.ts each declared their own private Symbol("owner_symbol"), so Worker#_disconnect could never find the owning server on the faux round-robin handle, fell back to handle.close(cb) (which ignores the callback), and waitingCount never reached zero.
The fix:
- Hoists
owner_symbolintointernal/shared.tsand imports it in both modules so they agree on identity. - In
listenInCluster'slistenOnPrimaryHandlecallback (src/js/node/net.ts), setshandle[owner_symbol] = serverand addsserver.once("close", () => handle.close())so closing the server also tears down the faux handle (unref the keep-alive interval + notify primary). - Adds a regression test + fixture spawning two workers that each listen, connect to themselves, then
disconnect()+server.close().
My earlier nit (drop the 30_000 per-test timeout) was addressed in a23fee2 and the PR was rebased onto current main at 24e6306.
Security risks
None. This is internal lifecycle plumbing between node:net and node:cluster in worker processes; no auth, crypto, parsing, or external input handling is touched.
Level of scrutiny
Medium-high. While the diff is small (~15 source lines + tests), it touches:
src/js/node/net.ts— a core, very widely-used module, though the change is confined to the!cluster.isPrimarybranch oflistenInCluster, so blast radius is limited to cluster workers.- Handle-close ordering between
net.Server, the faux RR handle, and the shared-handle path. The fauxclose()is idempotent (if (key === undefined) return), so the newserver.once("close", ...)chain doesn't risk double-close — but Bun's architecture here diverges from Node's (Bun keeps the realBun.listen()handle asserver._handlerather than swapping in the faux handle), so this is Bun-specific plumbing rather than a straight Node port. - A side-effect behavioral change: unifying
owner_symbolactivates the previously-deadmaxConnectionscheck inchild.ts'sonconnection()(lines ~213-220). Cluster workers will now actually reject connections whenserver.maxConnectionsis exceeded. The PR description calls this out, but it's a second behavior change riding on the same symbol fix and isn't directly covered by the new test.
Other factors
- The regression test gates the fix (author verified it hangs without the
src/change and passes with it). - Author reports all existing
test-cluster-*Node parallel tests still pass after rebase. - No CODEOWNERS cover these paths.
- No bugs were found by the automated review.
Given the subtle net/cluster lifecycle interaction and the secondary maxConnections activation, a maintainer familiar with Bun's cluster internals should sign off rather than auto-approving.
24e6306 to
f08fbb6
Compare
There was a problem hiding this comment.
All prior nits are addressed and I found no further issues, but I'm leaving this for a maintainer since it rewires the net↔cluster faux-handle linkage (and incidentally activates the previously-dead maxConnections check in onconnection()).
Extended reasoning...
Overview
This PR fixes a hang where cluster workers never exit after worker.disconnect() when they own a net.Server. The root cause was that internal/cluster/child.ts and node/net.ts each declared a private Symbol("owner_symbol"), so Worker#_disconnect's handle[owner_symbol] lookup on the round-robin faux handle always missed, the faux close() ignored the callback, and waitingCount never reached zero. The fix moves owner_symbol into internal/shared.ts, imports it from both modules, and in listenInCluster's callback explicitly sets handle[owner_symbol] = server plus server.once("close", () => handle.close()) so the faux handle's keep-alive interval is unref'd and the primary is notified. A new fixture + regression test in test/js/node/cluster.test.ts covers the scenario.
Security risks
None identified. No input parsing, auth, crypto, or filesystem-path handling is touched. The change is internal symbol plumbing and a close-chain hook between two already-coupled modules.
Level of scrutiny
Moderate-to-high. The diff is small (~15 source lines) and the reasoning is sound, but it sits in a production-critical Node-compat path (node:net × node:cluster) where Bun deliberately diverges from Node.js's design (Bun keeps the real Bun.listen() handle as server._handle rather than the faux handle). Two non-obvious behavioral side-effects warrant a maintainer's eye:
- Unifying
owner_symbolactivates the previously-deadmaxConnectionscheck inchild.ts:onconnection(), so workers can now reject round-robin connections they previously accepted. The PR description notes this as an incidental fix, but it is a behavior change beyond the headline bug. - The new
server.once("close", () => handle.close())hook adds a close chain; the fauxclose()is idempotent (key === undefinedguard) so double-close from_disconnect → server.close → handle.closeis safe, but someone familiar with Bun's cluster handle lifecycle should confirm there is no ordering hazard withhandles.clear()/ IPC teardown.
Other factors
All three of my earlier style nits (explicit test timeout, comment length, standalone expect(stderr).toBe("")) have been addressed in f08fbb6 / a64fd57 / 6b545d0, and the bug-hunting pass found nothing this round. CI on the prior runs was green except for an unrelated darwin artifact-download infra failure. No CODEOWNERS cover these paths. Given the cross-module coupling and the secondary behavior change, I'm not auto-approving — but I have nothing further to request.
There was a problem hiding this comment.
All prior nits are addressed and no bugs were found; deferring to a human for the substantive review since this changes cluster's IPC disconnect ordering across four src files (drops the primary-side process.disconnect(), adds a Bun-specific _trackServer hook into the http listen path, and shares owner_symbol which also activates the previously-dead maxConnections check in onconnection).
Extended reasoning...
Overview
The PR fixes worker.disconnect() never closing a cluster worker's servers. It touches four source files — internal/cluster/child.ts (import shared owner_symbol, add cluster._trackServer), internal/cluster/primary.ts (remove this.process.disconnect() so the worker owns channel teardown), internal/shared.ts (export a single shared owner_symbol), node/net.ts (link the cluster faux handle to its server and tear it down on server close), and node/_http_server.ts (call cluster._trackServer(server) in the worker listen path) — plus three new regression tests and a fixture. The latest commit (7d9ca45) addressed my last open nit by inserting a blank line between the section header and the 3-line rationale in primary.ts.
Security risks
None apparent. No auth, crypto, permissions, or untrusted-input parsing is touched. The change is internal lifecycle wiring between Bun's cluster, net, and http modules.
Level of scrutiny
Medium-high. This is process-lifecycle / IPC code in core Node-compat modules that real cluster managers depend on for rolling restarts. The individual edits are small, but together they change disconnect ordering: the primary no longer tears the IPC channel down itself, the worker now closes http servers it previously didn't track, and sharing owner_symbol activates the previously-dead maxConnections branch in child.ts's onconnection. _trackServer is also a new Bun-specific internal API (a deliberate deviation from Node's _getServer flow). These are reasonable design calls but worth a human look rather than a bot rubber-stamp.
Other factors
The bug-hunting system found nothing this run. All five of my prior inline nits (per-test timeouts, comment-length caps, combined-object stderr assertion) have been addressed; the last open thread on primary.ts is fixed by 7d9ca45. Test coverage is solid: three new regression tests (worker-initiated net, primary-initiated http, primary-initiated net) plus the author reports all 56 test-cluster-* Node parallel tests still pass and one previously-failing in-worker assertion now passes. CI reds on the branch have been unrelated flakes per the author's status comment. Given the cross-module behavioral change to disconnect semantics, deferring rather than approving.
|
Pushed c5a17e0 onto this branch: the primary-side change here is necessary but not sufficient for a double const cluster = require("node:cluster");
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on("online", () => {
worker.disconnect();
worker.disconnect();
});
worker.on("exit", (code, signal) => console.log("worker-exit", code, signal));
}
With the primary no longer closing the channel, the second The guard is a three-line early return in Two tests added to For the record, the |
c5a17e0 to
bd5a2c4
Compare
|
Independent report of the same root cause, with a user-visible symptom this PR's description doesn't call out: the primary never emits const cluster = require("node:cluster");
if (cluster.isPrimary) {
const events = [];
const worker = cluster.fork();
worker.disconnect(); // before the worker has reported itself online
worker.on("online", () => events.push("worker:online"));
cluster.on("online", () => events.push("cluster:online"));
worker.on("disconnect", () => events.push("worker:disconnect"));
worker.on("exit", () => {
events.push("worker:exit");
console.log(JSON.stringify(events));
});
}It is the Verified this branch at
The only red lane on build #68658 is I had an independent fix on |
|
Another face of the primary-side for (let i = 0; i < 50; i++) worker.send({ i, payload: Buffer.alloc(64*1024, 'z').toString() });
worker.disconnect();
// node: worker receives 50/50, then 'disconnect'
// bun: worker receives ~3/50, 'disconnect' never fires, send() callbacks report successOpened #33594 with a narrower subset of this PR's src/ changes (primary.ts + the |
|
Triage note: #20642 was closed by #31155, and the fixture from that issue (worker-initiated
Leaving this open. |
Three gaps in the primary-initiated disconnect path, each covered by a test: - A worker's node:http server binds its own socket rather than going through cluster._getServer, so the child's handle table never knew about it and _disconnect() had nothing to close: the worker kept serving and never exited. Register such servers via a new cluster._trackServer(). - The primary's Worker.prototype.disconnect() tore the IPC channel down itself. Node leaves that to the worker, which does it only after its servers have closed; closing from the primary skipped that shutdown and could drop a worker's in-flight "online" message when disconnect() was called early. - A second disconnect() from the primary re-ran the worker's _disconnect(), closing servers twice and tearing down an already-closed channel. Node treats the repeat as a no-op; do the same. The original report (#20642, a net.Server keeping the worker alive) is already fixed on main by the shared kClusterOwner linkage, so this no longer touches node:net. Fixes #20642
bd5a2c4 to
eabf260
Compare
…uster.test.ts Every test in this file forks at least one cluster worker, which runs well past the 5s default under a debug build (the http disconnect test measures 5-9s). Use the file-level default the suite uses elsewhere rather than per-test numbers.
There was a problem hiding this comment.
LGTM — the last nit (per-test timeouts) is addressed in 7cd2886 with the file-level setDefaultTimeout(40_000), and the three new tests no longer carry trailing timeout args.
What was reviewed:
primary.ts: droppingthis.process.disconnect()matches Node'sWorker.prototype.disconnect; the worker now owns channel teardown after closing its servers. Checked thatremoveWorker/removeHandlesForWorkerare still safe on a second call.child.ts:_trackServerkeyshandlesby the server object, so it can't collide with the string keys from_getServer/onconnection;_disconnectfalls through tohandle.close(cb)for it. TheexitedAfterDisconnectearly return only affects the primary-initiated path (worker-initiated already guards onstate)._http_server.ts: the_trackServercall is inside the!isPrimarybranch, andServer.prototype.close(cb)does invoke the callback viasetCloseCallback, socheckWaitingCountfires.
Extended reasoning...
Overview
Three small src/ changes (~10 lines) to node:cluster's disconnect lifecycle plus one call site in _http_server.ts, with three new subprocess-spawning tests in test/js/node/cluster.test.ts:
src/js/internal/cluster/primary.ts: removethis.process.disconnect()fromWorker.prototype.disconnectso the worker (not the primary) tears the IPC channel down after closing its servers. This is what Node's reference implementation does.src/js/internal/cluster/child.ts: addcluster._trackServer(server)so servers that don't go through_getServer(Bun'snode:httpbinds its own SO_REUSEPORT socket) still land in thehandlestable that_disconnect()iterates; add anexitedAfterDisconnectearly return so a second primary-sent{act:"disconnect"}is a no-op.src/js/node/_http_server.ts: callcluster._trackServer(server)in the worker listen branch.
Security risks
None. This is Node-compat lifecycle plumbing between a cluster primary and its forked workers over the existing IPC channel; no new external input, no auth/crypto/permissions surface.
Level of scrutiny
Moderate. node:cluster disconnect is a subtle lifecycle path (channel teardown ordering, event-loop keepalive), but the changes are minimal, each aligns with Node's reference implementation, and each is pinned by a dedicated test. The author reports all 80 upstream test/js/node/test/parallel/test-cluster-*.js tests pass, and the PR description verifies each new test fails on main and passes with the fix.
Other factors
- This PR has been through six-plus review rounds since May; every prior thread (comment length, per-test timeouts, stderr assertions, comment-cop) is resolved. The last open item — my per-test-timeout nit at line 1356 — was addressed in
7cd28869exactly as suggested (file-levelsetDefaultTimeout, per-test args dropped from all three new tests). - I checked the interactions I was unsure about:
_trackServeruses the server object as the map key so it cannot collide withonconnection's string-keyed lookups;handles.clear()after theforEachmakes the lateronce("close")deletion a harmless no-op;Server.prototype.closein_http_server.tsdoes register the callback socheckWaitingCountdecrements; the_trackServercall is gated on!isPrimary; andWorker.prototype.destroyalready setsexitedAfterDisconnectbefore exiting, so the new guard doesn't break that path. - The bug-hunting system found nothing this run.
Problem
worker.disconnect()called from the primary does not shut a worker down cleanly. Three separate gaps, each reproduced by a test intest/js/node/cluster.test.ts:node:httpserver keeps it alive forever._http_server.tsbinds its own socket in a worker instead of going throughcluster._getServer, so the child's handle table (src/js/internal/cluster/child.ts,handles) never contains it and_disconnect()has nothing to close.Worker.prototype.disconnecton the primary (src/js/internal/cluster/primary.ts) calledthis.process.disconnect()itself. The worker is supposed to close its servers and then disconnect from its side; closing the channel from the primary skips that, and ifdisconnect()is called before the worker has booted, the worker'sonlinemessage is lost.disconnect()twice re-ran the worker's_disconnect(), which closed the servers again and tore down an already-closed channel. Node makes the repeat a no-op.net.Serverkeeping the worker alive) is already fixed on current main by thekClusterOwnerlinkage betweennode:netand the cluster child, so that part of this PR was dropped during the rebase. Its test passes on main without this change.Fix
child.ts: addcluster._trackServer(server), which puts a server into the handle table the disconnect protocol closes, and removes it when the server closes on its own._disconnect()returns early if it already ran._http_server.ts: callcluster._trackServerin the worker listen path.primary.ts: stop callingthis.process.disconnect(); this matches Node'sWorker.prototype.disconnect, which only sends thedisconnectmessage and drops the worker's handles.src/reverted to main: the three new tests fail (http variant times out; the other two get noonline/ no events).cluster.test.tsis unchanged.test/js/node/test/parallel/test-cluster-*.jsupstream tests pass.Background
node:cluster, a worker registers each listening server with the primary viacluster._getServer; the child keeps a table of those handles.worker.disconnect()in the primary sends an internaldisconnectmessage, and the worker's_disconnect()closes everything in that table, waits for the close callbacks, and then callsprocess.disconnect()to drop the IPC channel, at which point nothing keeps its event loop alive and it exits. The primary emitsexitwhen the process is gone.node:httpdoes not use_getServerin workers; it listens on its own socket, so it falls outside that table unless registered explicitly.Earlier shape of this PR
The PR originally shared an
owner_symbolbetweennode:netand the cluster child so the faux handle returned by_getServercould find itsnet.Server, which is what #20642 reports. main has since reworked that area (kClusterOwner,kClusterFauxListen), so after rebasing onto95cb6939fathenet.ts/shared.tschanges and thenet.Servertests were removed as redundant. The three remaining fixes were squashed into one commit.Fixes #20642
no test proof · iteration 20 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/cluster.test.ts