From 123559b8fccc2769a4843e6b590f9bee66e374d0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:16:44 +0000 Subject: [PATCH 1/3] Bun.serve(http3): send CONNECTION_CLOSE on abrupt stop of an idle connection server.stop(true) routes to us_quic_listen_socket_close(), which called lsquic_conn_close() on every live connection before closing the UDP fd. For a server connection, ietf_full_conn_ci_close only schedules SF_SEND_CONN_CLOSE when conn_ok_to_close() holds, and the tick that packs the frame (end_write in lsquic_full_conn_ietf.c) additionally requires IFC_GOAWAY_CLOSE, a received CONNECTION_CLOSE, or scheduled packets. An idle server conn has none of those, so abrupt stop closed the fd without ever telling the peer. The client's pooled session then lingers until the negotiated idle timeout. If another listener binds the same ephemeral port before that fires, fetch({protocol:'http3'}) matches and enqueues on the dead session. Non-streaming bodies recover via retry_or_fail; a ReadableStream body has already been drained into lsquic's send buffer and cannot, so it surfaces HTTP3StreamReset once the idle alarm fires. This is the mechanism behind serve-http3.test.ts intermittently failing 'POST body without Content-Length still reaches the handler' (and occasionally other tests) since the native H3 client landed. Use lsquic_conn_abort() instead, which sets IFC_ABORTED and takes the IFC_IMMEDIATE_CLOSE_FLAGS path that unconditionally packs CONNECTION_CLOSE. The test file's withCustomServer fixtures are also given stdin-end handlers so every server process goes through stop(true) on teardown. A lifecycle test pins the behaviour: let the server conn go idle, stop(true), rebind the same port in-process, and POST a ReadableStream body. --- packages/bun-usockets/src/quic.c | 14 ++++- test/js/bun/http/serve-http3.test.ts | 78 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 0c33fe798519..1fac56a72dc1 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -853,10 +853,20 @@ void us_quic_listen_socket_close(us_quic_listen_socket_t *ls) { if (!ls || !ls->udp) return; /* Send CONNECTION_CLOSE on every live conn before the fd disappears; * cooldown alone only schedules GOAWAY, which leaves peers waiting on - * in-flight streams that this abrupt close will never serve. */ + * in-flight streams that this abrupt close will never serve. + * + * lsquic_conn_abort (IFC_ABORTED -> immediate_close) is used instead of + * lsquic_conn_close: for a *server* connection, ci_close only schedules + * SF_SEND_CONN_CLOSE if conn_ok_to_close(), and even then the tick at + * lsquic_full_conn_ietf.c's end_write only packs the frame when + * IFC_GOAWAY_CLOSE is set, CONNECTION_CLOSE was received, or packets are + * already scheduled. An idle server conn satisfies none of those, so + * abrupt stop() went silent and pooled clients reused the dead session + * until idle-timeout. IFC_ABORTED takes the IFC_IMMEDIATE_CLOSE_FLAGS + * path which always packs CONNECTION_CLOSE. */ if (ls->ctx->engine) { for (us_quic_socket_t *qs = ls->ctx->conns; qs; qs = qs->next) { - if (qs->conn) lsquic_conn_close(qs->conn); + if (qs->conn) lsquic_conn_abort(qs->conn); } lsquic_engine_cooldown(ls->ctx->engine); us_quic_process(ls->ctx); diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 56d3a93cbec1..cc9321c662d4 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -337,6 +337,7 @@ describe("Bun.serve HTTP/3", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); + process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); `; await withCustomServer(script, async port => { // 256 KB body via ReadableStream → no Content-Length on the wire. @@ -395,6 +396,7 @@ describe("Bun.serve HTTP/3", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); + process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); `; await withCustomServer(script, async port => { expect(await fetchH3(port, "/anything").then(r => r.text())).toBe("from-route"); @@ -838,7 +840,11 @@ async function withCustomServer( try { await fn(port, send, waitForStderr); } finally { + // Give the script a chance to server.stop(true) (CONNECTION_CLOSE) on + // stdin end before SIGKILL, so the client's pooled session is released + // and can't collide with a later test that reuses the same port. proc.stdin?.end(); + await Promise.race([proc.exited, Bun.sleep(1000)]); proc.kill(); await proc.exited; await drain.catch(() => {}); @@ -867,6 +873,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { console.error("RELOADED"); } }); + process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); `; await withCustomServer(script, async (port, send, waitForStderr) => { expect(await fetchH3(port, "/old").then(r => r.text())).toBe("old-route"); @@ -879,6 +886,75 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }); }); + // server.stop(true) must send CONNECTION_CLOSE on every live H3 connection + // before the UDP fd is closed. Without it the client keeps the pooled + // session until the negotiated idle timeout, and if another listener later + // binds the same port, a fetch with a ReadableStream body (which can't + // retry) is placed on the dead session and fails with HTTP3StreamReset + // once the idle alarm fires. This is the root cause of the intermittent + // `POST body without Content-Length` failure across the adversarial suite. + // + // lsquic's ietf_full_conn_ci_close path only packs CONNECTION_CLOSE for a + // server conn if there are already-scheduled packets or IFC_GOAWAY_CLOSE; + // an idle conn satisfies neither, so abrupt stop used lsquic_conn_abort. + test("server.stop(true) sends CONNECTION_CLOSE on an idle H3 connection", async () => { + const script = ` + const tls = ${JSON.stringify(tls)}; + let server; + const start = port => { + server = Bun.serve({ + port, tls, http3: true, + async fetch(req) { + const body = await req.arrayBuffer(); + return new Response(body, { headers: { "x-len": String(body.byteLength) } }); + }, + }); + console.error("PORT=" + server.port); + }; + start(0); + process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); + for await (const line of console) { + if (line === "stop") { + // Let the delayed-ACK timer fire so send_ctl has nothing scheduled + // when listen_socket_close runs; that's the state in which the old + // lsquic_conn_close() path went silent. + await Bun.sleep(100); + const port = server.port; + server.stop(true); + console.error("STOPPED"); + start(port); + } + } + `; + await withCustomServer(script, async (port, send, waitForStderr) => { + // Warm the pooled client session. + expect((await fetchH3(port, "/").then(r => r.headers.get("x-len"))) ?? "").toBe("0"); + send("stop"); + await waitForStderr(/STOPPED/); + // Same process now listens again on the same port. A fresh handshake + // should complete because the client dropped the old session on + // CONNECTION_CLOSE; if it reused the dead one the POST below stalls + // until idle-timeout and then rejects with HTTP3StreamReset. + await waitForStderr(/PORT=(\d+)/); + const body = Buffer.alloc(40_000, "noCL"); + const res = await fetchH3(port, "/", { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: new ReadableStream({ + start(c) { + c.enqueue(body); + c.close(); + }, + }), + // Fail fast instead of waiting out the ~10s idle alarm; the debug + // lane's handshake is well under this on a fresh session. + signal: AbortSignal.timeout(3000), + }); + expect(res.headers.get("x-len")).toBe(String(body.length)); + expect((await res.bytes()).length).toBe(body.length); + }); + }); + // bughunt #3: server.stop() must not leave the lsquic engine pointing at a // freed listen-socket. The follow-up GET should cleanly fail to connect, // and the process must still be alive to exit 0 on its own. @@ -1016,6 +1092,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); + process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); `; await withCustomServer(script, async port => { // Warm the QUIC connection so the /hang stream is actually bound @@ -1074,6 +1151,7 @@ describe("Bun.serve HTTP/3 production", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); + process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); `; await withCustomServer(script, async port => { const res = await fetchH3(port, "/"); From 887d0bef60b8b82d25e7271c32c8670a21c7775d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:36:11 +0000 Subject: [PATCH 2/3] test(serve-http3): address review feedback - hoist the stdin-end stop(true) handler into a shared STOP_ON_STDIN_END constant and use it from every fixture script (including the original withServer fixture) so teardown stays consistent - wait on a RESTARTED marker rather than a second /PORT=/ match, which resolved against the stale startup line in waitForStderr's accumulated buffer - read the restart command via process.stdin.on('data') like the sibling scripts; the previous for-await-console reader is a separate stdin consumer so the on('end') handler never fired and the fixture was SIGKILLed after the 1s grace period --- test/js/bun/http/serve-http3.test.ts | 44 ++++++++++++++++------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index cc9321c662d4..d2877670448e 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -18,6 +18,15 @@ const fetchH3 = (port: number, path: string, init: RequestInit & { signal?: Abor // a live loopback server answers in well under this even on the ASAN lane. const DEAD_PORT_ABORT_MS = 1000; +// Every fixture server in this file is torn down on stdin close so the client's +// pooled session sees CONNECTION_CLOSE and is dropped; otherwise a killed +// process leaves the client retransmitting until lsquic's idle timeout, and if +// the OS reuses that ephemeral UDP port a later test's request gets matched +// onto the dead conn. us_quic_listen_socket_close() flushes CONNECTION_CLOSE +// synchronously during stop(true); the trailing setTimeout gives the kernel a +// tick to deliver the loopback datagram before the process exits. +const STOP_ON_STDIN_END = `process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 100); });`; + const fixture = ` import { serve } from "bun"; @@ -150,12 +159,8 @@ const server = serve({ }); console.error("PORT=" + server.port); -// Graceful stop on stdin close so the client receives CONNECTION_CLOSE and -// drops the session — otherwise a SIGKILLed server leaves the client session -// retransmitting until lsquic's idle timeout, and if the OS reuses the -// ephemeral UDP port a later test's request gets matched onto that dead conn. process.stdin.on("data", () => {}); -process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 100); }); +${STOP_ON_STDIN_END} `; async function withServer( @@ -337,7 +342,7 @@ describe("Bun.serve HTTP/3", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); - process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); + ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { // 256 KB body via ReadableStream → no Content-Length on the wire. @@ -396,7 +401,7 @@ describe("Bun.serve HTTP/3", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); - process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); + ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { expect(await fetchH3(port, "/anything").then(r => r.text())).toBe("from-route"); @@ -873,7 +878,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { console.error("RELOADED"); } }); - process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); + ${STOP_ON_STDIN_END} `; await withCustomServer(script, async (port, send, waitForStderr) => { expect(await fetchH3(port, "/old").then(r => r.text())).toBe("old-route"); @@ -912,30 +917,33 @@ describe("Bun.serve HTTP/3 lifecycle", () => { console.error("PORT=" + server.port); }; start(0); - process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); - for await (const line of console) { - if (line === "stop") { + process.stdin.setEncoding("utf8"); + process.stdin.on("data", async line => { + if (line.includes("stop")) { // Let the delayed-ACK timer fire so send_ctl has nothing scheduled // when listen_socket_close runs; that's the state in which the old // lsquic_conn_close() path went silent. await Bun.sleep(100); const port = server.port; server.stop(true); - console.error("STOPPED"); start(port); + console.error("RESTARTED"); } - } + }); + ${STOP_ON_STDIN_END} `; await withCustomServer(script, async (port, send, waitForStderr) => { // Warm the pooled client session. expect((await fetchH3(port, "/").then(r => r.headers.get("x-len"))) ?? "").toBe("0"); send("stop"); - await waitForStderr(/STOPPED/); + // waitForStderr matches against the accumulated buffer, so a second + // /PORT=/ wait would resolve on the startup line; RESTARTED is emitted + // only after the new listener is bound. + await waitForStderr(/RESTARTED/); // Same process now listens again on the same port. A fresh handshake // should complete because the client dropped the old session on // CONNECTION_CLOSE; if it reused the dead one the POST below stalls // until idle-timeout and then rejects with HTTP3StreamReset. - await waitForStderr(/PORT=(\d+)/); const body = Buffer.alloc(40_000, "noCL"); const res = await fetchH3(port, "/", { method: "POST", @@ -948,7 +956,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }), // Fail fast instead of waiting out the ~10s idle alarm; the debug // lane's handshake is well under this on a fresh session. - signal: AbortSignal.timeout(3000), + signal: AbortSignal.timeout(2000), }); expect(res.headers.get("x-len")).toBe(String(body.length)); expect((await res.bytes()).length).toBe(body.length); @@ -1092,7 +1100,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); - process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); + ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { // Warm the QUIC connection so the /hang stream is actually bound @@ -1151,7 +1159,7 @@ describe("Bun.serve HTTP/3 production", () => { }); console.error("PORT=" + server.port); process.stdin.on("data", () => {}); - process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 50); }); + ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { const res = await fetchH3(port, "/"); From 3040796702742014994913fce0bdc48a04a8b5f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:12:43 +0000 Subject: [PATCH 3/3] ci: retrigger