Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/bun-usockets/src/quic.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
96 changes: 91 additions & 5 deletions test/js/bun/http/serve-http3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -337,6 +342,7 @@ describe("Bun.serve HTTP/3", () => {
});
console.error("PORT=" + server.port);
process.stdin.on("data", () => {});
${STOP_ON_STDIN_END}
`;
await withCustomServer(script, async port => {
// 256 KB body via ReadableStream → no Content-Length on the wire.
Expand Down Expand Up @@ -395,6 +401,7 @@ describe("Bun.serve HTTP/3", () => {
});
console.error("PORT=" + server.port);
process.stdin.on("data", () => {});
${STOP_ON_STDIN_END}
`;
await withCustomServer(script, async port => {
expect(await fetchH3(port, "/anything").then(r => r.text())).toBe("from-route");
Expand Down Expand Up @@ -838,7 +845,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(() => {});
Expand Down Expand Up @@ -867,6 +878,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => {
console.error("RELOADED");
}
});
${STOP_ON_STDIN_END}
`;
await withCustomServer(script, async (port, send, waitForStderr) => {
expect(await fetchH3(port, "/old").then(r => r.text())).toBe("old-route");
Expand All @@ -879,6 +891,78 @@ 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.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);
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");
// 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.
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(2000),
});
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.
Expand Down Expand Up @@ -1016,6 +1100,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => {
});
console.error("PORT=" + server.port);
process.stdin.on("data", () => {});
${STOP_ON_STDIN_END}
`;
await withCustomServer(script, async port => {
// Warm the QUIC connection so the /hang stream is actually bound
Expand Down Expand Up @@ -1074,6 +1159,7 @@ describe("Bun.serve HTTP/3 production", () => {
});
console.error("PORT=" + server.port);
process.stdin.on("data", () => {});
${STOP_ON_STDIN_END}
`;
await withCustomServer(script, async port => {
const res = await fetchH3(port, "/");
Expand Down
Loading