Skip to content

Bun.serve: close listen socket before destroying app in deinit()#32543

Open
robobun wants to merge 2 commits into
mainfrom
farm/1fa5afb4/fix-server-deinit-listen-socket
Open

Bun.serve: close listen socket before destroying app in deinit()#32543
robobun wants to merge 2 commits into
mainfrom
farm/1fa5afb4/fix-server-deinit-listen-socket

Conversation

@robobun

@robobun robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Found by fuzzer (fingerprint 777577b59557fdfd).

What

When Bun.serve({ tls, http3: true }) binds TCP successfully but the UDP bind for QUIC fails on the last retry attempt, listen() throws and tears the server down via NewServer::deinit(). At that point the TCP listen socket is still linked in the HttpContext socket group, so ~TemplatedApp -> httpContext->free() -> us_socket_group_deinit():

  • aborts on the head_listen_sockets != NULL assertion in debug/ASAN builds
  • leaks the bound listen socket and its fd in release builds (the listen fd stays in the epoll set with ls->accept_group pointing into freed memory)

The fuzzer hit this via { port: 0, http3: <truthy>, serverName: "..." } (serverName alone promotes to an HTTPS server, so HAS_H3 is true); under REPRL load enough UDP ports get occupied that the 3-attempt retry loop eventually exhausts.

Fix

Close any remaining TCP/H3 listen socket in deinit() before destroying the uws app. This matches what stop_listening() would have done and covers every synchronous Self::deinit(this) caller in the listen() error path.

Repro

Deterministic: occupy a UDP port, then Bun.serve({ tls, http3: true, port: <that port> }). Since the port is non-zero, max_attempts = 1; TCP listen succeeds, UDP bind fails, exception is thrown, deinit() runs with the listener still linked.

Before:

bun-debug: packages/bun-usockets/src/context.c:67: void us_socket_group_deinit(struct us_socket_group_t *): Assertion `group->head_listen_sockets == ((void*)0)' failed.

After: clean Failed to listen on UDP port N for HTTP/3 error, and the TCP port is released (verified by re-binding it in the test).

When Bun.serve({ tls, http3: true }) binds TCP successfully but the
UDP bind for QUIC fails on the last retry attempt, listen() throws
and tears the server down via NewServer::deinit(). The TCP listen
socket is still linked in the HttpContext socket group at that point,
so ~TemplatedApp -> httpContext->free() -> us_socket_group_deinit()
aborts on the head_listen_sockets != NULL assertion (debug/ASAN) or
leaks the bound listen socket and its fd (release).

Close any remaining TCP/H3 listen socket in deinit() before destroying
the uws app. This matches what stop_listening() would have done and
covers every synchronous deinit() caller in the listen() error path.
@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: a3146095-e283-45e0-910e-eec193b996fa

📥 Commits

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

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

Walkthrough

NewServer::deinit is updated to explicitly take and close the h3_listener handle before destroying h3_app when HAS_H3 is enabled. A new test verifies this fix by spawning a child process that occupies a UDP port, starts an HTTP/3 server, stops it, and confirms no TCP socket leak via exclusive re-bind.

Changes

H3 Listener Leak Fix and Regression Test

Layer / File(s) Summary
h3_listener close in deinit + UDP bind failure test
src/runtime/server/mod.rs, test/js/bun/http/serve-http3.test.ts
NewServer::deinit now calls h3_listener.take().close() before destroying h3_app. The new test http3 UDP bind failure after TCP listen succeeds does not crash spawns a child process that pre-occupies a UDP port, attempts Bun.serve({ http3: true }), calls s.stop(), then re-binds an exclusive TCP server on the same port to confirm no listener leak; the child must print the expected UDP error and exit with code 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately describes the main change: closing the listen socket before destroying the app in the deinit() function.
Description check ✅ Passed The description is comprehensive, covering the root cause, fix details, repro steps, and before/after behavior, though it doesn't strictly follow the template sections.
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.

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:53 AM PT - Jun 20th, 2026

@robobun, your commit c77d8ae09684eeb442597ae474e525d46dd5cad8 passed in Build #63662! 🎉


🧪   To try this PR locally:

bunx bun-pr 32543

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

bun-32543 --bun

@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 TCP listen socket not being closed when HTTP/3 UDP bind fails, but closes the listener in the retry loop rather than in deinit()
  2. server: close TCP listener when HTTP/3 UDP bind fails #32542 - Same fix as server: close TCP listener when H3 UDP bind fails #32539 (close TCP listener when HTTP/3 UDP bind fails), with a more thorough test verifying port re-binding

🤖 Generated with Claude Code

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Same crash as #32539 / #32542 but fixed at a different layer: those close the TCP listener inside the H3 retry loop (at the one call site that leaves it orphaned today); this one closes it inside NewServer::deinit() right before NewApp::destroy().

The deinit() placement covers every synchronous Self::deinit(this) caller in listen() (and any future one) rather than the single H3-retry path, and the existing comment in deinit() already notes that stop_listening may not have run. Happy to close whichever two aren't wanted.

@claude claude 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.

LGTM — targeted fuzzer-found crash fix that mirrors stop_listening()'s cleanup, with a deterministic regression test.

Extended reasoning...

Overview

This PR adds listen-socket cleanup to NewServer::deinit() in src/runtime/server/mod.rs (~11 lines) so that any still-linked TCP/H3 listen socket is closed before the uws app is destroyed. It also adds a regression test in test/js/bun/http/serve-http3.test.ts that occupies a UDP port, calls Bun.serve({ tls, http3: true, port }), asserts a clean throw, and verifies the TCP port is released by re-binding it.

Correctness

The new .take() + opaque_deref_mut(...).close() calls are byte-for-byte the same idiom stop_listening() already uses for these handles (lines ~1533 and ~1571). In the normal lifecycle (stop_listeningschedule_deinitdeinit), both listener and h3_listener are already None, so the added blocks are no-ops; they only fire on the synchronous Self::deinit(this) listen-failure paths in listen(), which is exactly the bug surface. .take() makes the close idempotent and ordering (close listener before NewApp::destroy) matches the PR description's invariant about us_socket_group_deinit's head_listen_sockets == NULL assert.

Security risks

None. This is teardown ordering on an error path — no input parsing, auth, crypto, or trust-boundary changes. If anything it removes a release-build hazard (leaked fd with a dangling accept_group pointer).

Level of scrutiny

Low-to-moderate. Small, mechanical, follows established patterns in the same file, and is covered by a precise subprocess test that checks both the debug-assert path (no SIGABRT, signalCode: null) and the release-leak path (TCP rebind succeeds). No CODEOWNERS entry covers these files.

Other factors

No prior reviewer comments to address; bug-hunting system found nothing. The fuzzer fingerprint and before/after repro in the description are concrete.

@claude claude 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.

I didn't find any issues with the change itself — the take() + close() pattern is idempotent with stop_listening() and the ordering before NewApp::destroy() is correct — but with #32539 / #32542 fixing the same crash at a different layer, a human should pick which approach lands.

Extended reasoning...

Overview

This PR adds defensive cleanup in NewServer::deinit() (src/runtime/server/mod.rs:1855-1871): before destroying the uws h3_app and app, it now take()s and close()s any remaining h3_listener / listener. This fixes a fuzzer-found crash where TCP listen succeeds but the HTTP/3 UDP bind fails on the last retry, leaving the TCP listen socket linked in the HttpContext socket group when ~TemplatedApp runs (debug assert / release leak). A regression test in test/js/bun/http/serve-http3.test.ts deterministically reproduces this by occupying a UDP port and verifying the TCP port is released afterward.

Security risks

None identified. This is teardown ordering on an error path; no auth, crypto, or untrusted-input parsing is touched. The added close() calls use the same bun_opaque::opaque_deref_mut(...).close() pattern already used in stop_listening() (lines 1533, 1571), and Option::take() guarantees no double-close when stop_listening() already ran.

Level of scrutiny

Medium-high. The diff is small and mechanically mirrors existing code, but it sits in the server lifecycle/FFI destruction path where ordering bugs manifest as UAF or fd leaks. I traced the call sites: in the normal path, stop_listening() has already take()'d both listeners so deinit()'s new blocks are no-ops; in the synchronous Self::deinit(this) listen-failure callers (e.g. lines 2491, 2520, 2563, 2845), stop_listening() never ran, which is exactly the case this covers.

Other factors

The main reason I'm not approving is that two sibling PRs (#32539, #32542) fix the same crash by closing the listener inside the H3 retry loop instead of in deinit(), and the author has explicitly asked which approach is preferred. That's a maintainer decision (centralized-in-deinit vs. close-at-the-call-site), not something a bot should resolve by approving one of the three.

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