From 8850d3e4ffac0819b6b019ae3c69482fd8356ad7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:43:48 +0000 Subject: [PATCH 1/4] http3: deliver every header block that lsquic decodes while a stream is read lsquic 4.9 decodes a header block that follows another one inside lsquic_stream_read, and that call returns 0 at the end of the stream with the block still queued. us_quic_on_read (Bun.serve and fetch) and the node:quic on_stream_read claimed a block only before they read, so they delivered the FIN and the block was lost: a final response with no body behind two 1xx responses, or trailers right behind the request headers. Both readers now claim every queued block before the FIN. A node:quic client also let lsquic queue two decoded blocks. One read then decoded the final response behind a claimed 1xx and returned its DATA, so the body reached JS ahead of onheaders. The node:quic engine now queues one block, as a server already does. --- packages/bun-usockets/src/node_quic_shim.c | 5 + packages/bun-usockets/src/quic.c | 39 ++++--- src/runtime/node/quic/stream.rs | 97 +++++++++------- test/js/node/quic/quic-stream.test.ts | 114 +++++++++++++++++++ test/js/web/fetch/fetch-http3-client.test.ts | 55 +++++++++ 5 files changed, 254 insertions(+), 56 deletions(-) diff --git a/packages/bun-usockets/src/node_quic_shim.c b/packages/bun-usockets/src/node_quic_shim.c index 05de1583669a..ed596008d677 100644 --- a/packages/bun-usockets/src/node_quic_shim.c +++ b/packages/bun-usockets/src/node_quic_shim.c @@ -432,6 +432,11 @@ void us_nq_settings_init(struct lsquic_engine_settings *s, int is_server, * grant while that stream has unsent data (lsquic_qdh_arm_if_unsent). */ s->es_qpack_enc_max_size = 0; s->es_qpack_enc_max_blocked = 0; + /* One decoded header block at a time, so lsquic parses nothing past a + * block that the stream has not claimed. A client's default is 2: one + * lsquic_stream_read then decodes the final response behind a claimed 1xx + * and returns its DATA, and the body reaches JS ahead of `onheaders`. */ + s->es_max_header_sets = 1; } #define NQ_SET(field, ctype) \ diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 6570280afdbf..7756498e498c 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -673,27 +673,29 @@ static lsquic_stream_ctx_t *us_quic_on_new_stream(void *if_ctx, lsquic_stream_t return (lsquic_stream_ctx_t *) s; } +/* Hands the next header block lsquic has decoded to on_stream_headers. + * Returns 1 for a block, 0 for none, -1 if on_stream_headers closed the stream. */ +static int us_quic_deliver_hset(lsquic_stream_t *stream, us_quic_stream_t *s) { + struct us_quic_hset *hset = (struct us_quic_hset *) lsquic_stream_get_hset(stream); + if (!hset) return 0; + us_quic_hset_finalize(hset); + us_quic_hset_free(s->hset); + s->hset = hset; + s->headers_delivered = 1; + if (s->ctx->on_stream_headers) s->ctx->on_stream_headers(s); + return s->stream ? 1 : -1; +} + static void us_quic_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { us_quic_stream_t *s = (us_quic_stream_t *) h; us_quic_socket_context_t *ctx = s->ctx; /* lsquic queues a fresh hset for every HEADERS block (1xx interims, * the final response, trailers). lsquic_stream_get_hset returns the - * next undelivered one and lsquic_stream_read won't drain DATA past - * an unconsumed hset, so re-dispatch on_stream_headers each time - * instead of latching after the first. */ - { - struct us_quic_hset *hset = (struct us_quic_hset *) lsquic_stream_get_hset(stream); - if (hset) { - us_quic_hset_finalize(hset); - us_quic_hset_free(s->hset); - s->hset = hset; - s->headers_delivered = 1; - if (ctx->on_stream_headers) ctx->on_stream_headers(s); - /* on_stream_headers may have closed us */ - if (!s->stream) return; - } - } + * next undelivered one and lsquic_stream_read fails while one is + * queued, so re-dispatch on_stream_headers each time instead of + * latching after the first. */ + if (us_quic_deliver_hset(stream, s) < 0) return; ssize_t r; while ((r = lsquic_stream_read(stream, ctx->read_buf, US_QUIC_READ_BUF)) > 0) { @@ -702,6 +704,13 @@ static void us_quic_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { if (!s->stream) return; } if (r == 0 && !s->fin_delivered) { + /* The read that reaches the end of the stream returns 0 even when it + * decoded a header block on its way there: a final response with no + * body right behind a 1xx, or trailers. Nothing reads this stream + * after the FIN below, so that block has to come out first. */ + int delivered; + while ((delivered = us_quic_deliver_hset(stream, s)) > 0) {} + if (delivered < 0) return; s->fin_delivered = 1; lsquic_stream_wantread(stream, 0); lsquic_stream_shutdown(stream, 0); diff --git a/src/runtime/node/quic/stream.rs b/src/runtime/node/quic/stream.rs index 1d02595586f4..393757b6a2a2 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -1023,49 +1023,57 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: } // SAFETY: `ctx` is the live QuicStream we returned from on_new_stream. let qs = unsafe { &*ctx.cast::() }; - if let Some(hset) = stream.take_header_set() { - let pairs = hset.pairs(); - /// RFC 9114 §8.1 H3_MESSAGE_ERROR — malformed message (a request - /// carrying :status). Matches lsquic's `HEC_MESSAGE_ERROR` - /// (lsquic_hq.h:82); 0x105 is H3_FRAME_UNEXPECTED, a different code. - const H3_MESSAGE_ERROR: u64 = 0x10e; - let has_status = pairs - .as_chunks::<2>() - .0 - .iter() - .find(|kv| kv[0] == b":status") - .map(|kv| kv[1].len() == 3 && kv[1][0] == b'1'); - // A :status in a request is malformed: node's nghttp3 resets the - // stream (RFC 9114 §4.1.2), and routing it to `oninfo` would leave - // the request unanswered until the idle timeout. - let peer_is_client = qs.session_ref().is_some_and(|s| s.is_server()); - if peer_is_client && has_status.is_some() { - if let Some(s) = qs.ls() { - // reset() only ends the read side when the peer already - // FIN'd/RST'd, so STOP_SENDING is what stops a malformed - // request streaming a body into a stream nothing will answer. - s.reset(H3_MESSAGE_ERROR); - s.stop_sending(H3_MESSAGE_ERROR); + // Hands every header block lsquic has decoded to the session. Returns + // false if a malformed block reset the stream. + let claim_header_sets = || -> bool { + while let Some(hset) = stream.take_header_set() { + let pairs = hset.pairs(); + /// RFC 9114 §8.1 H3_MESSAGE_ERROR — malformed message (a request + /// carrying :status). Matches lsquic's `HEC_MESSAGE_ERROR` + /// (lsquic_hq.h:82); 0x105 is H3_FRAME_UNEXPECTED, a different code. + const H3_MESSAGE_ERROR: u64 = 0x10e; + let has_status = pairs + .as_chunks::<2>() + .0 + .iter() + .find(|kv| kv[0] == b":status") + .map(|kv| kv[1].len() == 3 && kv[1][0] == b'1'); + // A :status in a request is malformed: node's nghttp3 resets the + // stream (RFC 9114 §4.1.2), and routing it to `oninfo` would leave + // the request unanswered until the idle timeout. + let peer_is_client = qs.session_ref().is_some_and(|s| s.is_server()); + if peer_is_client && has_status.is_some() { + if let Some(s) = qs.ls() { + // reset() only ends the read side when the peer already + // FIN'd/RST'd, so STOP_SENDING is what stops a malformed + // request streaming a body into a stream nothing will answer. + s.reset(H3_MESSAGE_ERROR); + s.stop_sending(H3_MESSAGE_ERROR); + } + qs.mark_reset(H3_MESSAGE_ERROR); + return false; + } + // 1xx interim responses are HINTS (RFC 9114 §4.1). + let is_interim = has_status.unwrap_or(false); + let kind = if is_interim { + QUIC_STREAM_HEADERS_KIND_HINTS + } else if qs.mark_headers_received() { + QUIC_STREAM_HEADERS_KIND_INITIAL + } else { + QUIC_STREAM_HEADERS_KIND_TRAILING + }; + if let Some(session) = qs.session_ref() { + session.push_event(SessionEvent::StreamHeaders { + stream: ctx.cast(), + pairs, + kind, + }); } - qs.mark_reset(H3_MESSAGE_ERROR); - return; - } - // 1xx interim responses are HINTS (RFC 9114 §4.1). - let is_interim = has_status.unwrap_or(false); - let kind = if is_interim { - QUIC_STREAM_HEADERS_KIND_HINTS - } else if qs.mark_headers_received() { - QUIC_STREAM_HEADERS_KIND_INITIAL - } else { - QUIC_STREAM_HEADERS_KIND_TRAILING - }; - if let Some(session) = qs.session_ref() { - session.push_event(SessionEvent::StreamHeaders { - stream: ctx.cast(), - pairs, - kind, - }); } + true + }; + if !claim_header_sets() { + return; } if stream.received_early_data() { qs.with_state(|s| s.received_early_data = 1); @@ -1082,6 +1090,13 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: got_any = true; } 0 => { + // The read that reaches the end of the stream returns 0 even + // when it decoded a header block on its way there (a final + // response with no body behind a 1xx, or trailers). Nothing + // reads this stream after the FIN, so claim that block first. + if !claim_header_sets() { + break; + } qs.push_inbound(&[], true); stream.want_read(false); let write_done = qs.with_state(|s| s.fin_sent != 0 || s.write_ended != 0); diff --git a/test/js/node/quic/quic-stream.test.ts b/test/js/node/quic/quic-stream.test.ts index 26013c512508..2848d22b27d0 100644 --- a/test/js/node/quic/quic-stream.test.ts +++ b/test/js/node/quic/quic-stream.test.ts @@ -180,6 +180,120 @@ describe("HTTP/3 header encoding", () => { }); }); +// lsquic decodes a header block that follows another one while the stream is +// read, not when its bytes arrive. The peer in these tests writes its header +// blocks in one call, so they share one STREAM frame and that is where the +// later blocks are decoded. +describe("HTTP/3 header blocks that follow another header block", () => { + const encoder = new TextEncoder(); + const request = (path: string) => ({ + ":method": "POST", + ":path": path, + ":scheme": "https", + ":authority": "localhost", + }); + const connectTo = async (server: { address: unknown }) => { + const client = await connect(server.address, { + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 1 }, + }); + await client.opened; + return client; + }; + + // Everything the client sees on one stream, in order. + async function responseEvents(client: any, path: string, trailers?: Record) { + const events: string[] = []; + const stream = await client.createBidirectionalStream({ + oninfo: (headers: Record) => events.push("info " + headers[":status"]), + onheaders: (headers: Record) => events.push("headers " + headers[":status"]), + }); + stream.closed.catch(() => {}); + stream.sendHeaders(request(path), { terminal: trailers === undefined }); + if (trailers) stream.sendTrailers(trailers); + for await (const batch of stream) { + for (const chunk of batch) events.push("data " + Buffer.from(chunk).toString("latin1")); + } + events.push("end"); + return events; + } + + test("a client gets two interim responses and the final response in order", async () => { + await using server = await listen( + async serverSession => { + serverSession.onstream = (stream: any) => { + stream.closed.catch(() => {}); + }; + await serverSession.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 1 }, + onheaders(this: any, headers: Record) { + if (headers[":path"] === "/warm") { + this.sendHeaders({ ":status": "200" }, { terminal: true }); + return; + } + this.sendInformationalHeaders({ ":status": "100" }); + this.sendInformationalHeaders({ ":status": "103", link: "; rel=preload" }); + if (headers[":path"] === "/no-body") { + this.sendHeaders({ ":status": "204" }, { terminal: true }); + return; + } + this.sendHeaders({ ":status": "200" }); + this.writer.writeSync(encoder.encode("hello")); + this.writer.endSync(); + }, + }, + ); + const client = await connectTo(server); + // lsquic holds one unsent header block per stream, and it holds the first + // block of a new connection: the server can send three only on a used one. + await responseEvents(client, "/warm"); + + const events = { noBody: await responseEvents(client, "/no-body"), body: await responseEvents(client, "/body") }; + client.close(); + expect(events).toEqual({ + noBody: ["info 100", "info 103", "headers 204", "end"], + body: ["info 100", "info 103", "headers 200", "data hello", "end"], + }); + }); + + test("a server gets the trailers that follow the request headers", async () => { + let requestTrailers: Record | undefined; + await using server = await listen( + async serverSession => { + serverSession.onstream = (stream: any) => { + stream.closed.catch(() => {}); + }; + await serverSession.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 1 }, + onheaders(this: any) { + this.sendHeaders({ ":status": "200" }, { terminal: true }); + }, + ontrailers(this: any, trailers: Record) { + requestTrailers = trailers; + }, + }, + ); + const client = await connectTo(server); + await responseEvents(client, "/warm"); + + // The server queues the trailers event in the lsquic callback that reads + // the request, ahead of the packet that carries its response. + const response = await responseEvents(client, "/trailers", { "x-checksum": "abc123" }); + client.close(); + expect({ response, requestTrailers: { ...requestTrailers } }).toEqual({ + response: ["headers 200", "end"], + requestTrailers: { "x-checksum": "abc123" }, + }); + }); +}); + describe("verifyClient", () => { test("a server requiring a client certificate never surfaces streams from a client that presented none", async () => { let announcedStreams = 0; diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index 09ba9c885628..c2ecef58cf51 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -1,6 +1,8 @@ import { gunzipSync, gzipSync, type Server } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir, tls } from "harness"; +import { createPrivateKey } from "node:crypto"; +import { listen } from "node:quic"; // In-process server with `http1: false` so the build under test binds UDP only. // A fetch that silently fell back to HTTP/1.1 would get ECONNREFUSED, which @@ -742,6 +744,59 @@ describe("aborted upload", () => { }); }); +// A response can carry any number of 1xx header blocks ahead of the final one +// (RFC 9114 section 4.1). Bun.serve sends one at most, so a node:quic server is +// the origin here. It writes every block in one call, so they reach the client +// in one STREAM frame. +describe("interim responses ahead of the final response", () => { + const encoder = new TextEncoder(); + const listenOrigin = () => + listen( + async (session: any) => { + session.onstream = (stream: any) => stream.closed.catch(() => {}); + await session.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [createPrivateKey(tls.key)], certs: [Buffer.from(tls.cert)] } }, + transportParams: { maxIdleTimeout: 5 }, + onheaders(this: any, received: Record) { + if (received[":path"] === "/warm") { + this.sendHeaders({ ":status": "200" }, { terminal: true }); + return; + } + this.sendInformationalHeaders({ ":status": "100" }); + this.sendInformationalHeaders({ ":status": "103", link: "; rel=preload" }); + if (received[":path"] === "/no-body") { + this.sendHeaders({ ":status": "204", "x-final": "yes" }, { terminal: true }); + return; + } + this.sendHeaders({ ":status": "200", "x-final": "yes" }); + this.writer.writeSync(encoder.encode("hello")); + this.writer.endSync(); + }, + }, + ); + + test.each([ + ["/no-body", { status: 204, final: "yes", body: "" }], + ["/body", { status: 200, final: "yes", body: "hello" }], + ])("100 and 103, then the final response of %s", async (path, expected) => { + const origin = await listenOrigin(); + try { + const url = `https://127.0.0.1:${origin.address.port}`; + // lsquic holds one unsent header block per stream, and it holds the first + // block of a new connection: the server can send three only on a used one. + expect((await fetch(url + "/warm", h3)).status).toBe(200); + + const res = await fetch(url + path, h3); + expect({ status: res.status, final: res.headers.get("x-final"), body: await res.text() }).toEqual(expected); + } finally { + // Not close(): it waits for the session that fetch() keeps in its pool. + await origin.destroy(); + } + }); +}); + // Stale-session retry: a request bound on session A when A's conn closes // (GOAWAY/CONNECTION_CLOSE) must transparently retry on a fresh session // instead of surfacing HTTP3StreamReset. reusePort lets B bind the same From 8a7eecba67e17e76d91c8258e74507236f0133ab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:29:01 +0000 Subject: [PATCH 2/4] test(http3): drop the warm-up requests from the header block tests A node:quic server runs onheaders after the lsquic tick that read the request. By then the QPACK encoder stream is out, so lsquic takes three header blocks on the first request of a connection too. The tests fail on main and pass on 1.4.2 without the warm-up. --- test/js/node/quic/quic-stream.test.ts | 10 ---------- test/js/web/fetch/fetch-http3-client.test.ts | 11 +---------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/test/js/node/quic/quic-stream.test.ts b/test/js/node/quic/quic-stream.test.ts index 2848d22b27d0..2c270be0a631 100644 --- a/test/js/node/quic/quic-stream.test.ts +++ b/test/js/node/quic/quic-stream.test.ts @@ -231,10 +231,6 @@ describe("HTTP/3 header blocks that follow another header block", () => { sni: { "*": { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, onheaders(this: any, headers: Record) { - if (headers[":path"] === "/warm") { - this.sendHeaders({ ":status": "200" }, { terminal: true }); - return; - } this.sendInformationalHeaders({ ":status": "100" }); this.sendInformationalHeaders({ ":status": "103", link: "; rel=preload" }); if (headers[":path"] === "/no-body") { @@ -248,10 +244,6 @@ describe("HTTP/3 header blocks that follow another header block", () => { }, ); const client = await connectTo(server); - // lsquic holds one unsent header block per stream, and it holds the first - // block of a new connection: the server can send three only on a used one. - await responseEvents(client, "/warm"); - const events = { noBody: await responseEvents(client, "/no-body"), body: await responseEvents(client, "/body") }; client.close(); expect(events).toEqual({ @@ -281,8 +273,6 @@ describe("HTTP/3 header blocks that follow another header block", () => { }, ); const client = await connectTo(server); - await responseEvents(client, "/warm"); - // The server queues the trailers event in the lsquic callback that reads // the request, ahead of the packet that carries its response. const response = await responseEvents(client, "/trailers", { "x-checksum": "abc123" }); diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index c2ecef58cf51..8b5f451222e3 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -760,10 +760,6 @@ describe("interim responses ahead of the final response", () => { sni: { "*": { keys: [createPrivateKey(tls.key)], certs: [Buffer.from(tls.cert)] } }, transportParams: { maxIdleTimeout: 5 }, onheaders(this: any, received: Record) { - if (received[":path"] === "/warm") { - this.sendHeaders({ ":status": "200" }, { terminal: true }); - return; - } this.sendInformationalHeaders({ ":status": "100" }); this.sendInformationalHeaders({ ":status": "103", link: "; rel=preload" }); if (received[":path"] === "/no-body") { @@ -783,12 +779,7 @@ describe("interim responses ahead of the final response", () => { ])("100 and 103, then the final response of %s", async (path, expected) => { const origin = await listenOrigin(); try { - const url = `https://127.0.0.1:${origin.address.port}`; - // lsquic holds one unsent header block per stream, and it holds the first - // block of a new connection: the server can send three only on a used one. - expect((await fetch(url + "/warm", h3)).status).toBe(200); - - const res = await fetch(url + path, h3); + const res = await fetch(`https://127.0.0.1:${origin.address.port}${path}`, h3); expect({ status: res.status, final: res.headers.get("x-final"), body: await res.text() }).toEqual(expected); } finally { // Not close(): it waits for the session that fetch() keeps in its pool. From c446afddc71ac8725307b5b6ae05f5b0f1c1138d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:35:31 +0000 Subject: [PATCH 3/4] http3: shorten the comments in the header block readers --- packages/bun-usockets/src/node_quic_shim.c | 6 ++---- packages/bun-usockets/src/quic.c | 5 +---- src/runtime/node/quic/stream.rs | 8 ++------ 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/bun-usockets/src/node_quic_shim.c b/packages/bun-usockets/src/node_quic_shim.c index ed596008d677..95b40de6d9ce 100644 --- a/packages/bun-usockets/src/node_quic_shim.c +++ b/packages/bun-usockets/src/node_quic_shim.c @@ -432,10 +432,8 @@ void us_nq_settings_init(struct lsquic_engine_settings *s, int is_server, * grant while that stream has unsent data (lsquic_qdh_arm_if_unsent). */ s->es_qpack_enc_max_size = 0; s->es_qpack_enc_max_blocked = 0; - /* One decoded header block at a time, so lsquic parses nothing past a - * block that the stream has not claimed. A client's default is 2: one - * lsquic_stream_read then decodes the final response behind a claimed 1xx - * and returns its DATA, and the body reaches JS ahead of `onheaders`. */ + /* lsquic then parses nothing past an unclaimed header block. A client's + * default of 2 lets the body reach JS ahead of the `onheaders` before it. */ s->es_max_header_sets = 1; } diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 7756498e498c..0320976d519d 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -704,10 +704,7 @@ static void us_quic_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { if (!s->stream) return; } if (r == 0 && !s->fin_delivered) { - /* The read that reaches the end of the stream returns 0 even when it - * decoded a header block on its way there: a final response with no - * body right behind a 1xx, or trailers. Nothing reads this stream - * after the FIN below, so that block has to come out first. */ + /* lsquic returns 0 even if this read decoded one more header block. */ int delivered; while ((delivered = us_quic_deliver_hset(stream, s)) > 0) {} if (delivered < 0) return; diff --git a/src/runtime/node/quic/stream.rs b/src/runtime/node/quic/stream.rs index 393757b6a2a2..767fb97b86cb 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -1023,8 +1023,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: } // SAFETY: `ctx` is the live QuicStream we returned from on_new_stream. let qs = unsafe { &*ctx.cast::() }; - // Hands every header block lsquic has decoded to the session. Returns - // false if a malformed block reset the stream. + // Returns false if a malformed header block reset the stream. let claim_header_sets = || -> bool { while let Some(hset) = stream.take_header_set() { let pairs = hset.pairs(); @@ -1090,10 +1089,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: got_any = true; } 0 => { - // The read that reaches the end of the stream returns 0 even - // when it decoded a header block on its way there (a final - // response with no body behind a 1xx, or trailers). Nothing - // reads this stream after the FIN, so claim that block first. + // lsquic returns 0 even if this read decoded one more header block. if !claim_header_sets() { break; } From c46ce62a5806e1c351ba84267bcac4b5f5f23da2 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 15 Sep 2026 22:16:40 -0700 Subject: [PATCH 4/4] http3: flush a header block when no body bytes follow it (#42867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem - `Bun.serve({ http3: true })` sends its automatic `100 Continue` only with the final response. A client that waits for the 100 waits its full timeout: curl with `Expect: 100-continue` takes 1.01 s over `--http3-only`, 7 ms over HTTP/1.1 and HTTP/2. - `fetch()` over HTTP/3 with a streamed request body does not reach the server until the first chunk exists, so a body whose first chunk waits for the server never gets there. HTTP/1.1 sends the request headers at once. - Cause: `lsquic_stream_send_headers` only buffers. `us_quic_stream_send_informational` (`packages/bun-usockets/src/quic.c:1125`) and `us_quic_stream_send_headers` without `end_stream` (`quic.c:1136`) leave the flush to the next body write. ### Fix - Both writers call `us_quic_flush_from_on_write`, which asks for `on_write`. `us_quic_on_write` flushes after `on_stream_writable`. - Correct because `on_write` runs in the write phase of the same `process_conns`, after lsquic wrote a held-back block. A response that the handler wrote at once is behind the 100 by then. Both still leave in one STREAM frame. - Verified: `test/js/bun/http/serve-http3.test.ts` (2 new) and `test/js/web/fetch/fetch-http3-client.test.ts` (1 new) time out without it. Also `fetch-http3-*`, `serve-protocols`, `test/js/node/quic/`. - Self-reviewed: 7 concerns raised, 7 addressed (Notes). ### Background - lsquic buffers stream writes until a packet is full, the stream ends, or a flush. `process_conns` runs read callbacks (the request handlers), then write callbacks. - On a new connection lsquic holds a header block back until its QPACK encoder stream is out. A flush then does nothing. lsquic calls `on_write` right after it writes it. - Stacked on #42860: without it, a 100 in its own packet makes a reader lose a final response with no body.
Notes **No user reported this.** It was found while #42805 was in work. Reach: the experimental `http3: true`, a client that sets `Expect` itself over HTTP/3 and waits (curl does, `fetch()` does not), and a handler that reads the body before it answers. The cost is the client's expect timeout on each request, and the request still succeeds. The second writer costs more: a `fetch()` body whose first chunk depends on the server hangs until the idle timeout and rejects with `HTTP3StreamReset`. **curl 8.14.1, 64 KB POST with `Expect: 100-continue`, three runs each:** | | `--http3-only` | `--http2` | `--http1.1` | | --- | --- | --- | --- | | canary 09bb54630 | 1.012 s, 1.012 s, 1.010 s | 7 ms, 6 ms, 7 ms | 6 ms, 5 ms, 7 ms | | this PR (debug build) | 0.337 s, 0.021 s, 0.017 s | 11 ms | 10 to 13 ms | Without `Expect`, `--http3-only` takes 8 ms on canary. The first debug-build run includes the first handshake of that process. **Why the flush is in `on_write` and not in the two writers.** - On the first request of a connection, `lsquic_qeh_write_headers` returns `QWH_PARTIAL` and `send_headers_ietf` stashes the block. `sm_n_buffered` is 0, so `lsquic_stream_flush` logs `flushing 0 bytes: noop`. `on_write_header_wrapper` writes the block later and then calls the user's `on_write`. The second server test covers this (lsquic debug log: `stashed 4 bytes of header block`, once per run). - A handler that answers in the same call writes its response in the read phase. A flush at send time puts the 100 in its own STREAM frame ahead of it. A flush in `on_write` keeps today's single frame (`generated STREAM frame: stream 4, offset: 0, size: 55, fin: 1`). **The flag is taken before the callback.** The `fetch()` client sends its request from inside `on_stream_writable`. A block sent there is flushed by the next `on_write`: lsquic calls it at once because want-write is set again, or after it wrote a held-back block. No user of `quic.c` clears want-write. **Cost on the response path.** A response that ends in the same call cancels the want-write in `stream_shutdown_write`, so no extra `on_write` runs. A streamed response gets one extra `on_write`, and `drain()` does nothing there without backpressure. **Other details.** - `pending_write_bytes++` in `us_quic_stream_send_informational` covers a call from outside an lsquic callback (`uws_h3_res_write_continue`). `us_quic_stream_send_headers` already bumps it. - lsquic calls `on_close` and destroys a stream only from the service phase of its tick, so the stream is valid after `on_stream_writable` returns. A flush on a stream that the callback shut down is a no-op (`EBADF`). - Response headers are not a third site. `Bun.serve` has no HTTP/3 caller of `flushHeaders()`, and it sends response headers with the first chunk on HTTP/1.1 too (701 ms on both for a first chunk that is 700 ms late). **#42805 (for #33082).** The two PRs change different functions and pass together: merged locally, `serve-http3` has 74 passes. Its body says the fix for the late 100 "needs this retry path". That is the case where the handler answers while lsquic still holds the 100 back: lsquic refuses the final block there, with and without this PR. This PR does not change that case. If the maintainers take the alternative that #42805 offers and remove the automatic 100 for HTTP/3, the 1xx half of this PR has no caller left. The request-headers half still stands. **`node:quic`** has its own header writer with the same gap when a stream is opened before the handshake completes. It is tracked in #42866 and not changed here. The new-connection test takes `stream.writer` before `sendHeaders`, as the `server.stop()` test in the same file does, so it does not depend on that bug. **With #42860 and this PR**, a busy `node:quic` client that receives the 100 and a later 204 in one batch gets `info 100`, `headers 204` (3 of 3 runs). Without #42860 it lost `headers 204` against this server (0 of 3). **Suites run on the debug build:** `serve-http3` (65), `fetch-http3-client` (61), `fetch-http3-adversarial` (27), `fetch-http3-cold-post` (2), `fetch-http3-syscall-fault` (7), `serve-protocols` (20), `quic-stream` (6), `quic-endpoint` (7), `quic-sni` (6), and the other files that use HTTP/3: `serve-http2-lifecycle` (23), `serve-direct-readable-stream` (149), `body-stream` (9086). With the sources of #42860, the three new tests time out. `fetch-backpressure.test.ts` does not pass on the debug build in my container: the HTTP/3 receive-backpressure tests time out and the S3 tests fail. It fails the same way there with the sources of `origin/main`, and it passes in CI.
--- **[human-review]** gate passed · iteration 1 · 3 files touched
fails on main (without fix) ```console ASAN without fix: 3 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts" "test/js/web/fetch/fetch-http3-client.test.ts" bun test v1.4.3 (09bb54630) test/js/bun/http/serve-http3.test.ts: (node:409468) ExperimentalWarning: quic is an experimental feature and might change at any time (Use `bun-debug --trace-warnings ...` to show where the warning was created) (pass) Bun.serve HTTP/3 > basic GET [1697.81ms] (pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1688.75ms] (pass) Bun.serve HTTP/3 > 204 with no body [1692.65ms] (pass) Bun.serve HTTP/3 > query string is preserved [1952.17ms] (pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1773.99ms] (pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1830.79ms] (pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1738.99ms] (pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1689.01ms] (pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2766.35ms] (pass) Bun.serve HTTP/3 > maxReque ... (truncated) release without fix: 1 failed, 1 skipped bun test v1.4.3-canary.1 (96577add4) test/js/bun/http/serve-http3.test.ts: (node:410372) ExperimentalWarning: quic is an experimental feature and might change at any time (Use `bun --trace-warnings ...` to show where the warning was created) (pass) Bun.serve HTTP/3 > basic GET [161.08ms] (pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [139.08ms] (pass) Bun.serve HTTP/3 > 204 with no body [182.96ms] (pass) Bun.serve HTTP/3 > query string is preserved [161.93ms] (pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [155.42ms] (pass) Bun.serve HTTP/3 > concurrent requests across separate connections [146.86ms] (pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [159.51ms] (pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [148.89ms] (pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1039.78ms] (pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [150.46ms] (pass) Bun.serve HTTP/3 > unknown route returns 404 [145.06ms] (pass) Bun.serve HTTP/3 > routes: handler with :params [163.24ms] (pass) Bun.serve HTTP/3 ... (truncated) ```
passes on PR (with fix) ```console ASAN with fix: all passed $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts" "test/js/web/fetch/fetch-http3-client.test.ts" bun test v1.4.3 (09bb54630) test/js/bun/http/serve-http3.test.ts: (node:411333) ExperimentalWarning: quic is an experimental feature and might change at any time (Use `bun-debug --trace-warnings ...` to show where the warning was created) (pass) Bun.serve HTTP/3 > basic GET [2068.94ms] (pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1988.03ms] (pass) Bun.serve HTTP/3 > 204 with no body [1811.93ms] (pass) Bun.serve HTTP/3 > query string is preserved [2127.37ms] (pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [2005.83ms] (pass) Bun.serve HTTP/3 > concurrent requests across separate connections [2007.58ms] (pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [2383.24ms] (pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2323.66ms] (pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2927.11ms] (pass) Bun.serve HTTP/3 > maxReque ... (truncated) release with fix: 1 skipped $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 1127ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/10] cc obj/packages/bun-usockets/src/node_quic_shim.c.o [2/10] cc obj/packages/bun-usockets/src/quic.c.o [3/10] gen generated_host_exports.rs generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 243 extern-C blocks audited [3/10] cargo bun_runtime → libbun_runtime.a  Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)  Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)  Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)  Compiling bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)  Compiling bun_safety v0.0.0 (/workspace/bun/src/safety)  Compiling bun_base64 v0.0.0 (/workspace/bun/src/base64)  Compiling bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)  Compiling bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)  Compiling bun_zstd v0.0.0 (/workspace/bun/src/zstd)  Compiling bun_picohttp v0.0.0 (/workspace/bun/ ... (truncated) ```
diff hotspot ``` packages/bun-usockets/src/quic.c | 24 ++++++++- test/js/bun/http/serve-http3.test.ts | 73 ++++++++++++++++++++++++++++ test/js/web/fetch/fetch-http3-client.test.ts | 32 ++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) ```
**gate history** · 2 passed · 0 rejected · iteration 1
evidence per changed file ``` file reads edits tests packages/bun-usockets/src/quic.c 17 21 67 test/js/bun/http/serve-http3.test.ts 4 9 46 test/js/web/fetch/fetch-http3-client.test.ts 3 8 41 ```
--- packages/bun-usockets/src/quic.c | 24 ++++++- test/js/bun/http/serve-http3.test.ts | 73 ++++++++++++++++++++ test/js/web/fetch/fetch-http3-client.test.ts | 32 +++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 0320976d519d..93312a46b7db 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -126,6 +126,8 @@ struct us_quic_stream_s { struct us_quic_hset *hset; int headers_delivered; int fin_delivered; + /* Set by us_quic_flush_from_on_write, cleared by on_write. */ + int flush_on_write; /* ext follows */ }; @@ -718,7 +720,13 @@ static void us_quic_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { static void us_quic_on_write(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { us_quic_stream_t *s = (us_quic_stream_t *) h; lsquic_stream_wantwrite(stream, 0); + /* Taken before the callback: a block that on_stream_writable sends (the + * client's request) is for the next on_write, see the helper. */ + int flush = s->flush_on_write; + s->flush_on_write = 0; if (s->ctx->on_stream_writable) s->ctx->on_stream_writable(s); + /* Last, so that it also covers what on_stream_writable wrote. */ + if (flush) lsquic_stream_flush(stream); } static void us_quic_on_close(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { @@ -1128,6 +1136,14 @@ void us_quic_stream_want_write(us_quic_stream_t *s, int want) { if (s->stream) lsquic_stream_wantwrite(s->stream, want); } +/* lsquic_stream_send_headers only buffers, and a flush is a no-op while lsquic + * holds the block back (new connection). on_write runs after lsquic wrote it, + * and after a handler that answered at once put its response behind a 1xx. */ +static void us_quic_flush_from_on_write(us_quic_stream_t *s) { + s->flush_on_write = 1; + lsquic_stream_wantwrite(s->stream, 1); +} + int us_quic_stream_send_informational(us_quic_stream_t *s, const char *status3) { if (!s->stream) return -1; char buf[10]; @@ -1136,7 +1152,12 @@ int us_quic_stream_send_informational(us_quic_stream_t *s, const char *status3) struct lsxpack_header xh; lsxpack_header_set_offset2(&xh, buf, 0, 7, 7, 3); lsquic_http_headers_t lh = { .count = 1, .headers = &xh }; - return lsquic_stream_send_headers(s->stream, &lh, 0); + int r = lsquic_stream_send_headers(s->stream, &lh, 0); + if (r == 0) { + us_quic_flush_from_on_write(s); + s->ctx->pending_write_bytes++; + } + return r; } int us_quic_stream_send_headers(us_quic_stream_t *s, @@ -1181,6 +1202,7 @@ int us_quic_stream_send_headers(us_quic_stream_t *s, if (buf != stackbuf) us_free(buf); if (xh != stackh) us_free(xh); if (end_stream && r == 0) lsquic_stream_shutdown(s->stream, 1); + if (!end_stream && r == 0) us_quic_flush_from_on_write(s); /* Mark the context dirty so drainQuicIfNecessary picks up header-only * responses (204/304) that never call us_quic_stream_write. */ if (r == 0) s->ctx->pending_write_bytes += (unsigned int) total + 1; diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 30a75bca9f84..167d7e677d24 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1762,6 +1762,79 @@ describe("Bun.serve HTTP/3 request validation", () => { }); }); +// RFC 9110 section 10.1.1: a client that sent Expect: 100-continue can hold the +// request content until it has the 100. The handler answers only when it has +// all of the content, so the client ends the request only if the 100 arrived +// on its own. The client waits for the event, with no timer: a server that +// keeps the 100 until the final response makes the test time out. +describe.concurrent("Bun.serve HTTP/3 sends the automatic 100 Continue ahead of the final response", () => { + async function exchange(server: { port: number }, { handshakeFirst }: { handshakeFirst: boolean }) { + // A session or a stream that ends before a response fails the test with + // its reason. Once a response is in, these rejections do nothing. + const firstResponse = Promise.withResolvers(); + const endedEarly = (what: string) => () => firstResponse.reject(new Error(`${what} closed before a response`)); + await using endpoint = new QuicEndpoint(); + const client = await connect(`127.0.0.1:${server.port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror: firstResponse.reject, + }); + client.closed.then(endedEarly("the session"), firstResponse.reject); + if (handshakeFirst) await client.opened; + + const seen: string[] = []; + const stream = await client.createBidirectionalStream({ + oninfo(received: Record) { + seen.push("info " + received[":status"]); + firstResponse.resolve(); + }, + onheaders(received: Record) { + seen.push("headers " + received[":status"]); + firstResponse.resolve(); + }, + }); + stream.closed.then(endedEarly("the stream"), firstResponse.reject); + const writer = stream.writer; + stream.sendHeaders(requestHeaders("/", { ":method": "POST", expect: "100-continue" })); + await firstResponse.promise; + seen.push("client sends the content"); + writer.writeSync(new TextEncoder().encode("request-content")); + writer.endSync(); + + let body = ""; + for await (const batch of stream as AsyncIterable) { + for (const chunk of batch) body += Buffer.from(chunk).toString("latin1"); + } + if (!client.destroyed) client.close().catch(() => {}); + return [...seen, "body " + body]; + } + + const expected = ["info 100", "client sends the content", "headers 200", "body content:request-content"]; + const serve = () => + Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + fetch: async req => new Response("content:" + (await req.text())), + }); + + test("on a connection that completed its handshake", async () => { + await using server = serve(); + expect(await exchange(server, { handshakeFirst: true })).toEqual(expected); + }); + + // The request leaves with the client's handshake. The server then still has + // the first byte of its QPACK encoder stream to send, and lsquic holds the 100 + // header block back until that byte is out. + test("on the first request of a connection, sent with the handshake", async () => { + await using server = serve(); + expect(await exchange(server, { handshakeFirst: false })).toEqual(expected); + }); +}); + // The HTTP/3 twin of the HTTP/1 cases in websocket-server.test.ts: ws.close() // runs close() before it returns, and a request handler that calls it must still // run to completion before the nextTick and promise callbacks it queued. The diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index 8b5f451222e3..1fb89f7fc5df 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -590,6 +590,38 @@ describe("fetch protocol: http3", () => { expect(assembled.endsWith("[end]")).toBe(true); }); + // The first chunk waits until the handler runs, so the request headers have + // to leave on their own. A client that holds them until it has body bytes + // never reaches the handler, and the test times out. The two requests cover + // a new connection and a reused one. + test("bidi: the request headers leave before the first body chunk exists", async () => { + let handlerRuns = Promise.withResolvers(); + using origin = Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + async fetch(req) { + handlerRuns.resolve(); + return new Response("got:" + (await req.text())); + }, + }); + const bodies: string[] = []; + for (const chunk of ["first-connection", "same-connection"]) { + handlerRuns = Promise.withResolvers(); + const body = new ReadableStream({ + async pull(ctrl) { + await handlerRuns.promise; + ctrl.enqueue(chunk); + ctrl.close(); + }, + }); + const res = await fetch(`https://127.0.0.1:${origin.port}/`, { ...h3, method: "POST", body }); + bodies.push(await res.text()); + } + expect(bodies).toEqual(["got:first-connection", "got:same-connection"]); + }); + test("bidi: type:direct on both sides", async () => { const piece = Buffer.alloc(4096, "D"); const body = new ReadableStream({