From 21b8e11b0d8da05cf80698f27b8de7c021508383 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:43:46 +0000 Subject: [PATCH 1/2] Bun.serve: close listen socket before destroying app in deinit() 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. --- src/runtime/server/mod.rs | 11 ++++++ test/js/bun/http/serve-http3.test.ts | 55 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index eedf3207ec3f..aa8a633d5607 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1853,11 +1853,22 @@ impl NewServer { // config / on_clienterror / h3_alt_svc / dev_server / plugins) is // handled by the heap::take drop below — see `impl Drop for NewServer`. if Self::HAS_H3 { + if let Some(h3l) = this_ref.h3_listener.take() { + // S008: `h3::ListenSocket` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3l).close(); + } if let Some(h3a) = this_ref.h3_app.take() { // SAFETY: live H3::App handle owned by this server. unsafe { uws_sys::h3::App::destroy(h3a) }; } } + if let Some(listener) = this_ref.listener.take() { + // The listen socket is still linked in the app's HttpContext group; + // `~TemplatedApp` only deinit()'s the group (asserting the listen + // list is empty), so unlink it here before destroying the app. + // S012: `app::ListenSocket` is a ZST opaque — safe deref. + bun_opaque::opaque_deref_mut(listener).close(); + } if let Some(app) = this_ref.app.take() { // SAFETY: live uws App handle owned by this server. unsafe { uws_sys::NewApp::::destroy(app) }; diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index f1a91d69a711..40c067102f47 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -425,6 +425,61 @@ describe("Bun.serve HTTP/3", () => { expect(exitCode).not.toBe(0); }); + test("http3 UDP bind failure after TCP listen succeeds does not crash", async () => { + // Occupy a UDP port, then Bun.serve({ tls, http3: true }) on that port. + // The TCP listen succeeds and links a listen socket into the HttpContext + // group; the UDP bind fails and the server is torn down. The teardown + // must close the TCP listen socket before destroying the uws app: debug + // builds abort on the us_socket_group_deinit() head_listen_sockets + // assert, release builds leak the bound TCP socket (observed below by + // re-binding the port). + const script = ` + const dgram = require("node:dgram"); + const net = require("node:net"); + const udp = dgram.createSocket("udp4"); + udp.bind(0, "127.0.0.1", () => { + const port = udp.address().port; + let err; + try { + const s = Bun.serve({ + port, + hostname: "127.0.0.1", + tls: ${JSON.stringify(tls)}, + http3: true, + fetch: () => new Response("x"), + }); + s.stop(); + } catch (e) { + err = e; + } + console.log(err ? "threw: " + err.message : "no error"); + const tcp = net.createServer(); + tcp.on("error", e => { + console.log("tcp-rebind: " + e.code); + udp.close(); + }); + tcp.listen({ port, host: "127.0.0.1", exclusive: true }, () => { + console.log("tcp-rebind: ok"); + tcp.close(); + udp.close(); + }); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), stderr, signalCode: proc.signalCode }).toEqual({ + stdout: [expect.stringContaining("threw: Failed to listen on UDP port"), "tcp-rebind: ok"], + stderr: "", + signalCode: null, + }); + expect(exitCode).toBe(0); + }); + test("static route (Response value) is mirrored onto H3", async () => { await withServer(async port => { const res = await fetchH3(port, "/static"); From c77d8ae09684eeb442597ae474e525d46dd5cad8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:17:00 +0000 Subject: [PATCH 2/2] ci: retrigger