http: closeAllConnections() must not stop the listener - #33394
Conversation
|
Updated 7:09 PM PT - Jul 5th, 2026
✅ @robobun, your commit 0175067df671bb4067c9564554f6ad6de73e54a8 passed in 🧪 To try this PR locally: bunx bun-pr 33394That installs a local version of the PR into your bun-33394 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Warning Review limit reached
Next review available in: 13 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 (9)
Walkthrough
ChangescloseAllConnections Behavior Update
Related PRs: None identified. Suggested labels: Suggested reviewers: None identified. 🐰 A rabbit hops through sockets tight, 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/http/node-http-close-all-connections.test.ts`:
- Around line 1-115: Move these `closeAllConnections()` tests into the existing
`node-http.test.ts` file instead of keeping them in a new
`node-http-close-all-connections.test.ts` file. Keep the same coverage and
assertions, but place the new cases alongside the other `node:http` tests so the
existing `listenAndConnect`-style setup and related `createServer` coverage stay
consolidated in one test module.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 28166af6-4367-41ba-b84e-7d4e59b54eaa
📒 Files selected for processing (8)
src/js/node/_http_server.tstest/js/bun/test/parallel/test-http-server.listening-should-work.tstest/js/bun/test/parallel/test-http-timeout-destruction-should-be-visible-using-kConnectionsCheckingInterval.tstest/js/first_party/ws/ws.test.tstest/js/node/http/node-http-close-all-connections.test.tstest/js/node/http/node-http.test.tstest/js/web/fetch/client-fetch.test.tstest/js/web/fetch/fetch.stream.test.ts
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Thanks — addressing the four bot threads, two of which were real finds. Missed sibling ( Pre-request sockets — also correct, and I've written it into the PR body as a known limitation rather than pretending it isn't there. A socket that has been accepted but hasn't sent a request head has no JS wrapper, so it isn't in
Test file location — keeping the dedicated file for now. On the overlap with #31302 and #30505The duplicate-detector is right that all three touch Destroying at the native layer leaves the JS socket object stale. const serverSocket = /* from server.on("connection") */;
server.closeIdleConnections(); // native us_socket_close
// node: socket.destroyed === true, 'close' fired
// bun: socket.destroyed === false, 'close' never firesNode's So, concretely:
The complete fix is the union: this PR's #30505 is a separate question: it fixes the #30501 hang by keeping |
Server.prototype.closeAllConnections() nulled the native server handle and called stop(true), a full shutdown: new clients got ECONNREFUSED, listening flipped to false, a 'close' event fired, and a later close(cb) failed with ERR_SERVER_NOT_RUNNING. Node only destroys the connections and keeps accepting, which is the point of the API during a graceful drain. Destroy the tracked sockets instead and leave the listen socket alone. Since the handle is no longer consulted, closeAllConnections() also works after close(), so the close(); closeAllConnections() teardown now force-closes in-flight keep-alive sockets and lets the close callback run.
… 'close'
node-http-with-ws.test.ts used closeAllConnections() as its only teardown, so
the https listener now leaks until process exit. Pair it with close(), like the
other call sites.
Also assert that the server-side socket emits 'close', the observable Node
relies on for 'connection' + socket.on('close') tracking.
93a8760 to
0175067
Compare
Status: ready for review, the red check is CI infraEvery test lane that ran on build 68678 passed (284 jobs). The one red check is For the record, the previous build (68669) had three reds, none of which survived a rebase onto current
One note worth passing on, unrelated to this PR: the ASAN report in 68669 came through with no symbolized frames (the The fix itself is unchanged and green: |
|
Closing in favour of #35839, which takes the same approach (destroy the sockets in Verified against a fresh |
Fixes #31301 (reported by @mxschmitt, who also has a fix in flight as #31302 — see the comparison at the bottom).
Repro
nodebun(before)bun(after)listeningtruefalsetrue200ECONNREFUSED200'close'eventclose(cb)nullERR_SERVER_NOT_RUNNINGnullCause
Server.prototype.closeAllConnections()nulledthis[serverSymbol]and called the nativestop(true), which is a full shutdown of the listen socket and every connection:Node's contract is narrower: destroy the connections, keep accepting. The API exists for a graceful reload/drain, so on Bun the drain step was an outage.
Two consequences beyond the listener dying:
listening, the'close'event andclose(cb)all reported a server that was never asked to stop.close()also clearsthis[serverSymbol], the standardclose(); closeAllConnections()teardown was a no-op for the forced half. The@azure/msal-nodeloopback client uses exactly that sequence and hangs on Bun (getTokenInteractive in @azure/msal-node will cause Bun to hang after the end of the script #30501).Fix
Iterate
kTrackedConnections(the set the'connection'event andgetConnections()already maintain) anddestroy()each socket, exactly like Node. Nothing else is touched, which is also what makes it work afterclose().Destroying through the JS socket (rather than closing the underlying uSocket) is what gives the socket object Node's observable state:
socket.destroyed === trueand a'close'event. Theserver.on("connection", s => set.add(s))+s.on("close", () => set.delete(s))idiom depends on it.Verification
test/js/node/http/node-http-close-all-connections.test.ts(new, 4 tests, all pass unmodified on Node.js):destroyedflag +'close'event), listener still accepting, no server'close'event,close(cb)reports no errorclose()thencloseAllConnections()destroys in-flight sockets so the close callback runs3 of the 4 fail on
main.test/js/node/test/parallel/test-http-server-close-all.jsand the other 356test-http-*node parallel tests are unaffected.Five existing tests used
closeAllConnections()as a stand-in forclose(), and two asserted the old behaviour directly (listening === false, the connections-checking interval destroyed). Both of those now assert what Node does:closeAllConnections()leaves them alone,close()changes them.Known limitation
A TCP connection that has been accepted but has not yet sent a request head has no JS socket wrapper, so it is not in
kTrackedConnectionsand survivescloseAllConnections(). Node tracks connections from TCP-accept and would destroy it.This is the same architectural gap already noted on
getConnections()("the native server does not surface raw accepts to JS yet") —'connection'does not fire for those sockets either, so they are invisible to the whole connection-tracking surface, not just this method. They are still reaped byheadersTimeout/requestTimeout. Closing it properly needs a native "close every socket, keep the listener" primitive, which is what #31302 adds.Relationship to #30505 and #31302
Both are open and neither is a duplicate of the other:
closeAllConnectionsprimitive through uWS →libuwsockets.cpp→App.rs→ host fn → class registration, and delegating to it. That catches the pre-request sockets above, which this PR does not. But routing only through the native layer leaves the JS socket wrapper stale: bun's existingcloseIdleConnections()already takes that path, and on itsocket.destroyedstaysfalseand the socket never emits'close', where Node givestrue/ fires. It also still early-returns afterclose(), so it does not address getTokenInteractive in @azure/msal-node will cause Bun to hang after the end of the script #30501.this[serverSymbol]alive pastclose()and teaching the Ruststop_listening/stop_from_jsto force-close the app when the listener is already gone, socloseAllConnections()can still reachstop(true). It keeps the full-stop, so the listener-teardown bug remains.This PR removes the
stop(true)call instead, which fixes both bugs in 11 lines of JS with no Rust change: the #30501 teardown sequence exits normally on this branch (verified against Node — both exit,mainhangs).The complete fix is probably this PR's
socket.destroy()loop plus #31302's native sweep for the pre-request sockets. Happy to fold that in with credit, or to close this in favour of #31302 plus a small JS change there — whichever reviewers prefer.