Skip to content

fix(http): preserve server reference across close() for closeAllConnections() - #30505

Closed
robobun wants to merge 4 commits into
mainfrom
farm/c7da8917/fix-http-close-all-connections
Closed

fix(http): preserve server reference across close() for closeAllConnections()#30505
robobun wants to merge 4 commits into
mainfrom
farm/c7da8917/fix-http-close-all-connections

Conversation

@robobun

@robobun robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator

What

@azure/msal-node's LoopbackClient.closeServer() (the code that tears down the localhost server after an interactive token flow) calls:

server.close();
server.closeAllConnections();
server.unref();

Under Bun, this sequence left the process hanging because:

  1. JS layerServer.prototype.close synchronously nulled out this[serverSymbol] (the reference to the underlying Bun.serve handle). The subsequent closeAllConnections() / unref() then saw undefined and early-returned. The 'force-close every socket' step msal relies on never reached the native layer.
  2. Zig layer — even if closeAllConnections() could reach server.stop(true), stopFromJS gated on hasListener(), which stop(false) (the graceful close) had already cleared. And stopListening bailed out via this.listener orelse return before it could run this.app.?.close() — the call that force-closes open uWS sockets.

Result: the keep-alive connection from the browser tab stayed open, pinning the loop (macOS hung until the user closed the tab; Windows hung indefinitely).

Fix

src/js/node/_http_server.ts

  • Keep this[serverSymbol] populated past close(); clear it only in emitCloseNTServer (fires when the allClosed promise fulfills).
  • Introduce kServerClosed as the 'has close been called' flag so address() still returns null and double-close() still errors with ERR_SERVER_NOT_RUNNING, matching Node.
  • Reset kServerClosed in kRealListen so re-listen works.

src/runtime/server/server.zig

  • In stopFromJS and disposeFromJS, allow the abrupt stop to proceed when the listener was already nulled by a prior graceful stop — the app may still own open connections worth force-closing.
  • In stopListening, run this.app.?.close() on the abrupt path even when this.listener is null (graceful-then-abrupt sequence). terminated remains the single-shot guard.

Test

Two tests in test/js/node/http/node-http.test.ts:

  1. closeAllConnections() after close() force-closes in-flight sockets — opens a keep-alive TCP connection, lets the server receive the request but never reply (so the socket is in-flight, not idle), runs the msal teardown sequence, and waits for 'close' on the client socket.
  2. process exits after close()+closeAllConnections()+unref() teardown — spawns a subprocess that does the same flow end-to-end and checks the subprocess exits promptly instead of hanging on the keep-alive timer.

Both fail on main (timeout) and pass with the fix.

Fixes #30501

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:36 AM PT - Jun 18th, 2026

@robobun, your commit 73af2d7 has 4 failures in Build #63336 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30505

That installs a local version of the PR into your bun-30505 executable, so you can run:

bun-30505 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. https.Server.close callback is not always called #22490 - https.Server.close callback not always firing is consistent with the server reference being nulled before the close sequence completes
  2. Bun process hangs when using Supertestwith Express — does not exit after test completion #23648 - Process hanging after server.close() with Supertest/Express matches keep-alive connections not being force-closed due to the nulled server reference

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #22490
Fixes #23648

🤖 Generated with Claude Code

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

I tested the repro from #23648 against this branch — it still hangs (supertest creates an internal keep-alive socket that server.close() doesn't tear down, and process._getActiveHandles() shows zero handles while the loop is still alive — so something other than the HTTP server is pinning it). That's a separate bug from what this PR fixes, so I'm leaving #23648 off the Fixes list.

For #22490 there's no repro to test against; this PR makes close() more robust by not nulling the native reference prematurely, but I can't confirm the close callback bug described there is rooted in the same place.

@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Separates "close initiated" from native server teardown via a new kServerClosed flag; preserves native handle during shutdown to allow closeAllConnections()/unref(); guards against stale close callbacks when re-listening; and adds runtime graceful-to-abrupt shutdown upgrades plus regression tests verifying teardown behavior.

Changes

HTTP Server Shutdown Lifecycle

Layer / File(s) Summary
Server Lifecycle State
src/js/node/_http_server.ts
Introduces kServerClosed symbol to track shutdown initiation separate from native reference cleanup.
Emit Close & Listen Wiring
src/js/node/_http_server.ts
emitCloseNTServer now accepts the specific Bun serve handle and only clears serverSymbol for the matching generation; listen wiring captures and passes that handle.
Close Entry Points
src/js/node/_http_server.ts
closeAllConnections() and close() now set kServerClosed, destroy the connections-checking interval, mark not listening, and preserve serverSymbol during shutdown for follow-up operations.
State Query During Shutdown & Listen Reset
src/js/node/_http_server.ts
address() returns null once kServerClosed is set; kRealListen resets kServerClosed at the start of a new listen generation.
Runtime Shutdown Logic
src/runtime/server/server.zig
stopFromJS parses abrupt/graceful intent and may call stop(true) when only app remains; disposeFromJS runs when app exists; stopListening upgrades graceful-to-abrupt by force-closing app and setting terminated.
Regression Tests
test/js/node/http/*
Adds tests: an in-process test that verifies in-flight keep-alive sockets are force-closed after close()closeAllConnections()unref(), a re-listen-during-close test, and an end-to-end subprocess test ensuring teardown exits cleanly.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: preserving the server reference across close() to enable subsequent closeAllConnections() calls, which directly addresses the core issue described in the PR.
Description check ✅ Passed The description provides comprehensive coverage of the problem, the fix across multiple files, and testing approach. It exceeds the minimal template requirements with detailed explanations of both JS and Zig layer changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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 `@src/js/node/_http_server.ts`:
- Around line 81-89: The deferred close callback (emitCloseNTServer) must avoid
clearing a newly-restarted server; capture the current server handle or
generation when scheduling the all-closed promise and in emitCloseNTServer only
clear this[serverSymbol] and call emitCloseServer if the stored
handle/generation still matches the one captured at scheduling time. Update the
code paths that schedule the callback (the place that registers the all-closed
promise against this) and modify emitCloseNTServer to compare the savedHandle or
generation token against this[serverSymbol] before setting it to undefined and
calling emitCloseServer to prevent a re-listen race; reference
emitCloseNTServer, emitCloseServer, and serverSymbol when making the change.

In `@test/js/node/http/node-http.test.ts`:
- Around line 1833-1869: The test "process exits after
close()+closeAllConnections()+unref() teardown" currently uses Bun-only APIs
(Bun.spawn, bunExe, bunEnv, proc.stdout.text(), proc.exited) which breaks the
file's Node.js compatibility; either rewrite the test to use Node's
child_process APIs (e.g., spawn/exec from node:child_process and replace
bunExe/bunEnv usage with node executable and process.env) and wire up
stdout/stderr and exit handling via the ChildProcess streams/promises, or move
this test into a Bun-only test file; update references inside the test
(Bun.spawn, bunExe, bunEnv, proc.stdout.text(), proc.exited) accordingly so the
test runs under Node.js or is located in a Bun-only suite.
- Around line 1857-1873: Remove the embedded 5s watchdog: delete the
setTimeout(...) block that writes "STILL_ALIVE\n" and calls process.exit(2) (the
code using setTimeout and the "STILL_ALIVE" sentinel), and update the test
assertions to stop expecting the sentinel by removing the
expect(stdout).not.toContain("STILL_ALIVE") check; keep the subprocess
spawn/await logic (proc, stdout, exitCode) and the existing
expect(exitCode).toBe(0) and expect(stdout).toContain("TEARDOWN_DONE") so the
outer test timeout handles hangs and stderr can be used for additional
diagnostics if needed.
🪄 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: 3f3d1c1e-36ea-487e-adff-4c119943f79e

📥 Commits

Reviewing files that changed from the base of the PR and between 450072b and eeedbda.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • src/runtime/server/server.zig
  • test/js/node/http/node-http.test.ts

Comment thread src/js/node/_http_server.ts Outdated
Comment thread test/js/node/http/node-http.test.ts Outdated
Comment thread test/js/node/http/node-http.test.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/server/server.zig Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 41-51: Capture and check the subprocess stderr before asserting
exitCode to surface runtime diagnostics: await proc.stderr.text() alongside
proc.stdout.text() (e.g., const [stdout, stderr, exitCode] = await
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])) and add an
assertion such as expect(stderr).toBe("") or expect(stderr).toHaveLength(0)
(placed before expect(exitCode).toBe(0)) so any uncaught exceptions or ASAN
messages are visible in test output; update references to proc.stdout.text(),
proc.stderr.text(), and proc.exited accordingly.
🪄 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: 9cb8dd67-cbbd-4431-b2f1-8cf459cc0e86

📥 Commits

Reviewing files that changed from the base of the PR and between eeedbda and 3282dbd.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http-close-all-connections.test.ts
  • test/js/node/http/node-http.test.ts

Comment thread test/js/node/http/node-http-close-all-connections.test.ts
Comment thread test/js/node/http/node-http-close-all-connections.test.ts Outdated
Comment thread test/js/node/http/node-http.test.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI is clean on everything except windows-2019-x64-baseline-test-bun, which fails on an unrelated test.failing snapshot in test/js/node/net/double-connect.test.ts — the test is marked test.failing expecting the snapshot NOT to match, but on Windows 2019 it occasionally matches (scheduling variance in the net-server double-connect timing), flagging the suite. My diff only touches src/js/node/_http_server.ts, src/runtime/server/server.zig, and the new test/js/node/http/node-http-close-all-connections.test.ts; none touch net server. Already spent the one ci: retrigger — hit the same Windows flake. Needs a maintainer to merge.

Comment thread src/js/node/_http_server.ts
mxschmitt added a commit to mxschmitt/bun that referenced this pull request May 27, 2026
Per the Node docs, `Server.prototype.closeAllConnections()` should
forcefully close every established HTTP(S) connection but leave the
listening socket alone. Bun's wrapper was calling `server.stop(true)`,
which goes through `stop_listening` and tears down the listener, so any
caller that relied on the documented contract — Playwright's
`TestServer.reset()` between tests is the canonical case — got
ECONNREFUSED on every subsequent request.

Add a dedicated path through every layer parallel to the existing
`closeIdleConnections` plumbing (uws → libuwsockets shim → App.rs →
server host fn → class registration), then make the JS wrapper delegate
to it without touching `serverSymbol`, `kConnectionsCheckingInterval`,
or `listening`.

Related: oven-sh#30501, oven-sh#30505 (different framing of an overlapping bug).
…ctions()

Calling server.close() followed by server.closeAllConnections() is the
idiomatic way to kill both idle and in-flight HTTP connections. It's what
@azure/msal-node's LoopbackClient does at the end of an interactive token
flow.

Bun's close() nulled out the internal Bun.serve reference before the
follow-up calls could reach it, so closeAllConnections() early-returned
and any in-flight keep-alive socket (the browser tab in the msal flow)
kept the event loop alive indefinitely.

Changes:
- _http_server.ts: keep this[serverSymbol] alive past close() and clear
  it only after the allClosed promise fulfills; track 'has close been
  called' with a new kServerClosed flag so address() still returns null
  post-close, and reset the flag on re-listen. emitCloseNTServer captures
  the Bun.serve handle for its listen generation so a racing re-listen
  doesn't null out the new handle.
- server_body.rs / mod.rs: let stop_from_js(true)/dispose_from_js proceed
  when the app is still alive even after a graceful stop took the
  listener; and in stop_listening, run app.close() on the abrupt path
  even when the listener was already taken (runs before the h3 branch so
  the TERMINATED flag doesn't short-circuit it for h3 servers).

Fixes #30501
@robobun
robobun force-pushed the farm/c7da8917/fix-http-close-all-connections branch from 4c4dc72 to 7f65d17 Compare June 18, 2026 14:48
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and resolved the conflict. Since this PR was opened, main ported the HTTP server from Zig to Rust and added connection tracking (kTrackedConnections, getConnections, and a clearInterval-based close). Re-applied the fix on top:

  • _http_server.ts: reconciled my kServerClosed approach with main's new clearInterval/kTrackedConnections close path. close() and closeAllConnections() now set kServerClosed instead of nulling serverSymbol; address() checks the flag; kRealListen resets it; emitCloseNTServer clears serverSymbol only for its own listen generation.
  • The Zig stop_from_js/stop_listening changes moved to their Rust equivalents in src/runtime/server/server_body.rs and src/runtime/server/mod.rs (the abrupt app.close() now runs before the h3 branch so the TERMINATED flag doesn't short-circuit it).

Squashed into one commit. Regression tests pass with the fix, time out without it; the rest of node-http.test.ts is green except the pre-existing request via http proxy, issue#4295 (container network isolation, fails on main too).

@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

CI red lanes on this build are all unrelated flake, none touch this diff (which is limited to src/runtime/server/ and node:http):

  • test/cli/hot/hot.test.ts + bun-install-registry.test.ts — grouped under the flaky annotation, retried (hot-reload sourcemap / package manager)
  • test/js/bun/terminal/terminal.test.ts — PTY spawn timeout on x64
  • test/js/node/test/parallel/test-tls-client-destroy-soon.jsnode:tls streaming byte-count assertion on macOS aarch64

The HTTP-server lanes are green. Already used my one CI re-roll this cycle, so not pushing another empty commit. Ready for a maintainer to merge.

Comment thread src/js/node/_http_server.ts
robobun added 2 commits June 18, 2026 15:47
…lose()

Keeping serverSymbol populated past close() (so closeAllConnections() and
unref() can reach the native handle) changed the meaning of a populated
serverSymbol for three call sites that used it as a 'still listening'
proxy:

- ref(): re-pinned the event loop on a closed server, keeping the loop
  alive until GC (effectively a hang in the zero-connection case).
- setTimeout(): configured the idle timeout on the stopped handle instead
  of deferring it so the next listen() replays it onto the fresh server.
- emitListeningNextTick(): could re-announce 'listening' (and flip
  this.listening back to true) if a close() raced the deferred tick.

Gate all three on !kServerClosed, matching address()/close(). unref()
stays ungated since it is the third step of the msal
close() -> closeAllConnections() -> unref() teardown this PR enables.

Adds a ref()-after-close() regression test (hangs without the gate).
In kRealListen the flag was cleared before Bun.serve() ran. If
Bun.serve() throws (e.g. EADDRINUSE) on a re-listen after close(),
serverSymbol still points at the old draining handle; clearing
kServerClosed early would make address()/close() treat that stopped
handle as live. Reset the flag only after the handle is successfully
reassigned.
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Latest CI (build 63336) red lanes are again all unrelated flake, none touching this diff (src/runtime/server/ + node:http):

  • test/bake/deinitialization.test.ts — bake devserver (grouped under flaky, retried)
  • test/integration/next-pages/test/dev-server.test.ts — Next.js + puppeteer integration
  • test/js/node/test/parallel/test-tls-client-destroy-soon.jsnode:tls streaming byte-count assertion on macOS aarch64 (recurring, also failed the prior build)
  • test/js/bun/s3/s3.test.ts — R2/S3 large-upload timeout

HTTP-server lanes are green. The four claude[bot] review findings are addressed and all review threads resolved. Re-roll already spent, so not pushing another empty commit. Ready for a maintainer to merge.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #33394 touches the same function from the other direction and likely supersedes this PR.

The report that prompted it is that closeAllConnections() also closes the listen socket, which is not Node's contract: server.listening flips to false, new clients get ECONNREFUSED, a spurious 'close' fires, and a later close(cb) errors with ERR_SERVER_NOT_RUNNING. Node only destroys the connections and keeps accepting, which is the whole point of the API during a graceful drain.

This PR preserves this[serverSymbol] so closeAllConnections() can still reach stop(true). #33394 drops the stop(true) call instead and iterates kTrackedConnections calling socket.destroy(), the way Node does. Because it no longer consults the native handle, it also works after close(), so the msal close(); closeAllConnections(); unref() sequence from #30501 exits normally on that branch (verified against Node: both exit, main hangs) with no change to src/runtime/server/.

If that holds up in review, this one can be closed in its favour.

robobun added a commit that referenced this pull request Aug 13, 2026
…eAllConnections()

Folds in the scenarios from #30505 (issue #30501: close(); closeAllConnections();
unref() with a request in flight must let the process exit) and #33394 (every
tracked connection is destroyed synchronously and the listener stays up).
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #35839.

Status of the two halves of this PR on current main:

The msal-node teardown scenario from #30501 still reproduces on main (verified with this PR's tests against a fresh build: the two teardown tests still fail), so the close(); closeAllConnections(); unref() subprocess test from here has been folded into #35839.

@robobun robobun closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getTokenInteractive in @azure/msal-node will cause Bun to hang after the end of the script

1 participant