Skip to content

server: close TCP listener before App::destroy in deinit - #32540

Closed
robobun wants to merge 1 commit into
mainfrom
farm/85a2d0c8/http3-listen-failure-deinit
Closed

robobun wants to merge 1 commit into
mainfrom
farm/85a2d0c8/http3-listen-failure-deinit

Conversation

@robobun

@robobun robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

When Bun.serve with http3: true fails the QUIC UDP bind after the TCP listen has already succeeded, listen() throws and calls Self::deinit(this) directly. At that point the TCP listen socket is still linked into the uWS app's socket group, so App::destroy -> ~TemplatedApp -> httpContext->free() -> us_socket_group_deinit trips the head_listen_sockets == NULL assert.

Close the listener in deinit before destroying the app. In the normal teardown paths self.listener has already been taken by stop_listening (and deinit_if_we_can gates on !has_listener()), so this only fires on the listen-failure error path. us_listen_socket_close is idempotent on already-closed sockets.

Repro: hold the UDP port before Bun.serve({ port, http3: true, tls }) so the TCP bind succeeds but the H3 bind fails. Added as a regression test in serve-http3.test.ts.

Found by Fuzzilli (fingerprint 897bba1f930ab927).

When Bun.serve with http3: true fails the UDP bind after the TCP listen
has already succeeded, listen() throws and calls Self::deinit directly.
The TCP listen socket is still linked into the app's socket group at
that point, so App::destroy -> ~TemplatedApp -> httpContext->free() ->
us_socket_group_deinit asserts head_listen_sockets == NULL.

Close the listener in deinit before destroying the app. In the normal
teardown paths self.listener has already been taken by stop_listening,
so this only fires on the listen-failure path.
@robobun

robobun commented Jun 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:36 AM PT - Jun 20th, 2026

❌ @robobun, your commit 6b14be5 has 1 failures in Build #63651 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32540

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

bun-32540 --bun

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff18aaeb-1f7d-4b4d-85a9-79ca1f1a8846

📥 Commits

Reviewing files that changed from the base of the PR and between 8841747 and 6b14be5.

📒 Files selected for processing (2)
  • src/runtime/server/mod.rs
  • test/js/bun/http/serve-http3.test.ts

Walkthrough

NewServer::deinit is extended to close any remaining H1 TCP listener before destroying the uWS app, fixing a us_socket_group_deinit assertion that fires when H3 UDP binding fails after a TCP listener has already been linked into the app's socket group. A regression test reproduces this path and asserts a clean exit.

Changes

H3 TCP listener cleanup on UDP bind failure

Layer / File(s) Summary
TCP listener teardown fix and regression test
src/runtime/server/mod.rs, test/js/bun/http/serve-http3.test.ts
NewServer::deinit takes the optional listener field and closes it via its opaque handle before NewApp::destroy, covering the path where the H3 UDP bind fails after the TCP listener is already registered. The regression test forces this failure path by binding a UDP socket to the same port, then asserts the subprocess emits the expected error, omits the internal assertion text, and exits with code 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: closing the TCP listener before app destruction in the deinit function.
Description check ✅ Passed The description addresses both template sections, explaining what the PR does and how it was verified through the added regression test.
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.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. server: close TCP listener when H3 UDP bind fails #32539 - Also fixes the uWS us_socket_group_deinit assertion crash when H3 UDP bind fails after TCP listen succeeds, with the same fix (closing the TCP listener before App::destroy) and same test file

🤖 Generated with Claude Code

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #32539 (opened 9 minutes earlier, same root cause, different Fuzzilli fingerprint). Closing in favor of that one.

The two fixes differ slightly: #32539 closes the TCP listener at the H3-bind-failure site in listen(); this PR closes it in deinit() before App::destroy. Both are correct for the only path that currently reaches deinit with a live listener. Leaving this branch up in case the more defensive deinit-side placement is preferred.

@robobun robobun closed this Jun 20, 2026
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), stderr, exitCode, signalCode: proc.signalCode }).toEqual({
stdout: expect.stringContaining("threw:Failed to listen on UDP port"),
stderr: expect.not.stringContaining("head_listen_sockets"),

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.

🟡 nit: stderr: expect.not.stringContaining("head_listen_sockets") is the anti-pattern CLAUDE.md:102 calls out — release CI builds compile the assert out so this string never appears even on regression, and in debug an abort already fails exitCode: 0/signalCode: null. The positive assertions (stdout contains threw:Failed to listen on UDP port, exitCode 0, signalCode null) fully prove the fix; if you want stderr in the failure diff for diagnostics, stderr: expect.anything() keeps that without the vacuous check.

Extended reasoning...

What this is

CLAUDE.md line 102 states: "NEVER write tests that check for no "panic" or "uncaught exception" or similar in the test output. These tests will never fail in CI." The new assertion stderr: expect.not.stringContaining("head_listen_sockets") checks for the absence of an assert-failure message in stderr — exactly the pattern the rule targets.

Why the check is dead weight

Walk through both build modes against a regression (TCP listener still linked when App::destroy runs):

  • Debug build: us_socket_group_deinit hits assert(head_listen_sockets == NULL), the process aborts with SIGABRT. In the test's toEqual, exitCode: 0 fails (received non-zero / null) and signalCode: null fails (received "SIGABRT"). The stderr matcher would also fail, but it is never the sole failing key — it is strictly redundant with the exit/signal checks.
  • Release build (CI): the assert() is compiled out, so the string head_listen_sockets is never written to stderr regardless of whether the listener was unlinked. expect.not.stringContaining("head_listen_sockets") is vacuously true. (Whether the regression manifests some other way in release is irrelevant to this matcher — it cannot observe it.)

There is no execution path where this stderr matcher is the assertion that catches the regression. The load-bearing assertions are stdout: expect.stringContaining("threw:Failed to listen on UDP port") (proves Bun.serve threw the expected error and control returned to JS), exitCode: 0, and signalCode: null (prove no abort/crash).

Addressing the "it's there for diagnostics" argument

One could argue stderr is included in the toEqual object so that when the test does fail (e.g. exitCode mismatch), the jest diff prints the actual stderr — which is in the spirit of CLAUDE.md:104 ("expect(stdout).toBe(...) BEFORE expect(exitCode)... gives you a more useful error message"). That's a fair reason to keep stderr as a key in the object. But it doesn't justify the matcher: jest's toEqual diff shows the full received object including the actual stderr value regardless of which asymmetric matcher passed on the expected side. stderr: expect.anything() (or expect.any(String)) preserves the diagnostic-in-diff behavior identically, without encoding a check that reads as if it would catch the assert in CI when it cannot.

Why it's worth flagging despite being minor

The test is not vacuous overall — the other three assertions are correct and will catch a regression — so this is a nit, not a bug. But it's new code that matches a repo-documented anti-pattern verbatim, and these tend to get cargo-culted into future tests. Dropping the line (or swapping the matcher for expect.anything()) costs nothing and keeps the test honest about what it actually verifies.

Suggested fix

Either remove the stderr key entirely, or change it to stderr: expect.anything() if you want stderr surfaced in the failure diff. The latter is probably nicer here given the test spawns a subprocess.

Comment on lines +489 to +495
const dgram = require("dgram");
const net = require("net");
const tcp = net.createServer();
await new Promise(r => tcp.listen(0, "127.0.0.1", r));
const port = tcp.address().port;
tcp.close();
await new Promise(r => tcp.on("close", r));

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.

🟡 The TCP net.createServer() / tcp.listen(0) / tcp.close() dance is unnecessary — TCP and UDP port namespaces are independent, so you can bind UDP directly with udp.bind(0, "127.0.0.1", resolve), read udp.address().port, and pass that to Bun.serve. This drops a race window (the freed TCP port can be grabbed before Bun.serve re-binds it) and removes a second one (the specific UDP port might already be in use), aligning with CLAUDE.md's "always use port: 0" guidance.

Extended reasoning...

What the bug is

The new regression test needs a port where (a) UDP is already held by us so Bun.serve's H3 bind fails, and (b) TCP is free so Bun.serve's TCP listen succeeds first. The current implementation achieves this by:

  1. Binding a TCP server on port 0 → kernel picks port N
  2. Closing the TCP server
  3. Binding a UDP socket on port N
  4. Calling Bun.serve({ port: N, http3: true, ... })

Steps 1–2 are unnecessary. Because TCP and UDP have independent port namespaces, the test can simply:

  1. Bind a UDP socket on port 0 → kernel picks port N (UDP is now held)
  2. Call Bun.serve({ port: N, http3: true, ... })

Why the current approach is strictly worse

The current code has two race windows:

  • Race A (TCP): Between tcp.close() and Bun.serve()'s TCP listen, another process on the CI host can grab TCP port N. Bun.serve then fails with an error that does not contain "Failed to listen on UDP port", so the expect.stringContaining("threw:Failed to listen on UDP port") assertion fails.
  • Race B (UDP): udp.bind(port, ...) binds a specific port N. Since the kernel only guaranteed N was free for TCP, UDP port N might already be in use, causing udp.bind to reject and the test to fail before Bun.serve is even called.

The simplified approach eliminates Race B entirely (kernel-assigned udp.bind(0) cannot collide) and leaves only the unavoidable "is TCP port N free" check — which is no worse than Race A, since a kernel-assigned UDP ephemeral port is no more likely to collide on TCP than a just-freed TCP port is to be reclaimed.

Step-by-step proof

Consider a busy CI host:

  1. tcp.listen(0) → kernel assigns port 54321 (TCP).
  2. tcp.close() → TCP 54321 is released back to the ephemeral pool.
  3. Another test process on the same machine calls listen(0) and the kernel hands it TCP 54321.
  4. Our udp.bind(54321) succeeds (UDP namespace is separate).
  5. Bun.serve({ port: 54321 }) tries TCP listen first → EADDRINUSE on TCP, throws something like "Failed to start server. Is port 54321 in use?".
  6. stdout is "threw:Failed to start server...", which does not contain "Failed to listen on UDP port" → test fails flakily.

With the simplified version, step 2–3 never happen because we never touched TCP 54321; it's just a random ephemeral port that's overwhelmingly likely to be free.

Suggested fix

const dgram = require("dgram");
const udp = dgram.createSocket("udp4");
await new Promise((resolve, reject) => {
  udp.on("error", reject);
  udp.bind(0, "127.0.0.1", resolve);
});
const port = udp.address().port;
try {
  Bun.serve({ port, hostname: "127.0.0.1", tls: ..., http3: true, fetch: () => new Response("x") });
  console.log("unexpected-success");
} catch (e) {
  console.log("threw:" + e.message);
}
udp.close();

This removes the net import and 4 lines of setup while reducing flake surface. CLAUDE.md: "Do not write flaky tests" / "Always use port: 0" — the current approach effectively passes a recycled (just-freed) port to the TCP bind.

Impact

Practical flake probability is very low (~28k ephemeral ports, sub-millisecond window), so this is a code-quality nit rather than a blocker — but it's a strict simplification with no downside.

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.

1 participant