diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index eedf3207ec3f..b20563930f31 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) }; } } + // listen() may reach here after TCP bind succeeded but H3 bind + // failed; close the TCP listener so `us_socket_group_deinit` (via + // `~TemplatedApp` below) sees an empty `head_listen_sockets`. + if let Some(listener) = this_ref.listener.take() { + // 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..11cc4fb98c65 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1077,3 +1077,64 @@ describe("Bun.serve HTTP/3 production", () => { // (HttpContext.h / Http3Context.h call writeContinue before routing); a // curl --expect100-timeout assertion was flaky enough to drop here. }); + +test("Bun.serve http3 UDP bind failure releases the TCP listen socket", async () => { + // When the TCP bind succeeds but the HTTP/3 UDP bind on the same port + // fails, Bun.serve must close the TCP listen socket before tearing down + // the uws App. Otherwise the fd leaks (observable as EADDRINUSE below) + // and debug builds abort in us_socket_group_deinit() on a non-empty + // head_listen_sockets list. + const script = ` + const net = require("net"); + const dgram = require("dgram"); + const tcp = net.createServer(); + await new Promise(r => tcp.listen(0, "127.0.0.1", r)); + const port = tcp.address().port; + const udp = dgram.createSocket({ type: "udp4", reuseAddr: false }); + await new Promise((res, rej) => { udp.once("error", rej); udp.bind(port, "127.0.0.1", res); }); + await new Promise(r => tcp.close(r)); + let threw = false; + try { + const s = Bun.serve({ + port, + hostname: "127.0.0.1", + tls: ${JSON.stringify(tls)}, + http3: true, + fetch: () => new Response("x"), + }); + s.stop(true); + } catch (e) { + threw = true; + console.log("THREW:" + e.message); + } + udp.close(); + if (!threw) { + console.log("NOTHROW"); + } else { + const tcp2 = net.createServer(); + try { + await new Promise((res, rej) => { + tcp2.once("error", rej); + tcp2.listen(port, "127.0.0.1", res); + }); + console.log("REBIND:OK"); + tcp2.close(); + } catch (e) { + console.log("REBIND:" + e.code); + } + } + `; + 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"), "REBIND:OK"], + stderr: expect.not.stringContaining("Assertion"), + signalCode: null, + }); + expect(exitCode).toBe(0); +});