From 21bd2e55c96020bdf2785dcec8bca577b952e275 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:39:54 +0000 Subject: [PATCH 1/9] Bun.serve(http3): reset the stream when a response body fails mid-body A Response body stream that errors after the status and some bytes went out reaches RequestContext::force_close(). For HTTP/1 that closes the socket without the terminating chunk. For HTTP/3 it called us_quic_stream_close(), which queues a FIN, and in HTTP/3 a FIN marks a complete message. The client received the truncated body as a complete 200 response. uws_h3_res_force_close now sends RESET_STREAM(H3_INTERNAL_ERROR) through us_quic_stream_reset, which takes the HTTP/3 error code as a parameter. The H3 fetch client's stream-close callback no longer treats a stream that closed without a FIN as the end of the body: it fails the request with HTTP3StreamReset when the peer reset the stream and with ConnectionClosed when the connection went away under it. --- packages/bun-usockets/src/quic.c | 27 +++--- packages/bun-usockets/src/quic.h | 14 ++- src/http/h3_client/ClientSession.rs | 37 ++++++-- src/http/h3_client/callbacks.rs | 6 +- src/uws_sys/libuwsockets_h3.cpp | 6 +- src/uws_sys/quic.rs | 1 + src/uws_sys/quic/Stream.rs | 28 +++++- test/js/bun/http/serve-http3.test.ts | 93 ++++++++++++++++++++ test/js/web/fetch/fetch-http3-client.test.ts | 55 ++++++++++++ 9 files changed, 245 insertions(+), 22 deletions(-) diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 62f14539cfec..96cfbf794033 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -120,6 +120,7 @@ struct us_quic_stream_s { struct us_quic_hset *hset; int headers_delivered; int fin_delivered; + int peer_reset; /* ext follows */ }; @@ -671,7 +672,10 @@ static void us_quic_on_reset(lsquic_stream_t *stream, lsquic_stream_ctx_t *h, in * read half stays open so the response/request that arrived * alongside STOP_SENDING is still delivered. Closing here * would drop it. */ - if (h && stream && how == 0) lsquic_stream_close(stream); + if (h && stream && how == 0) { + ((us_quic_stream_t *) h)->peer_reset = 1; + lsquic_stream_close(stream); + } } /* ───── public API ───── */ @@ -1081,15 +1085,18 @@ void us_quic_stream_close(us_quic_stream_t *s) { /* From lsquic_stream.h (not in the public header). */ void lsquic_stream_maybe_reset(struct lsquic_stream *, uint64_t error_code, int); -/* Abort the send half with RESET_STREAM(H3_REQUEST_CANCELLED) instead of - * FIN. lsquic_stream_close/shutdown queue FIN after the buffered tail, - * which is a protocol error if a content-length was advertised and the - * client is abandoning the upload short — the server's lsquic will - * CONNECTION_CLOSE on the mismatch (RFC 9114 §4.1.2). RESET_STREAM is - * the wire-level "I'm cancelling this send" and lets the server treat it - * as a stream-level cancellation rather than a malformed message. */ -void us_quic_stream_reset(us_quic_stream_t *s) { - if (s->stream) lsquic_stream_maybe_reset(s->stream, 0x10C, 1); +/* lsquic_stream_close/shutdown queue FIN after the buffered tail. For a + * client abandoning an upload short of its advertised content-length that + * is a protocol error the server answers with CONNECTION_CLOSE (RFC 9114 + * §4.1.2); for a server whose response body failed it is a complete-looking + * truncated message. RESET_STREAM is the wire-level "this send is aborted" + * and lets the peer treat it as a stream-level failure. */ +void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code) { + if (s->stream) lsquic_stream_maybe_reset(s->stream, error_code, 1); +} + +int us_quic_stream_peer_reset(us_quic_stream_t *s) { + return s->peer_reset; } int us_quic_stream_has_unacked(us_quic_stream_t *s) { diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index d64c230b6941..85e606359660 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -143,7 +143,19 @@ void us_quic_stream_shutdown(us_quic_stream_t *s); void us_quic_stream_flush(us_quic_stream_t *s); void us_quic_stream_shutdown_read(us_quic_stream_t *s); void us_quic_stream_close(us_quic_stream_t *s); -void us_quic_stream_reset(us_quic_stream_t *s); + +/* HTTP/3 error codes (RFC 9114 §8.1) for us_quic_stream_reset. */ +#define US_H3_INTERNAL_ERROR 0x102 +#define US_H3_REQUEST_CANCELLED 0x10C + +/* Abort the send half with RESET_STREAM(error_code) and close the stream. + * us_quic_stream_close/shutdown queue a FIN, and in HTTP/3 a FIN marks a + * complete message; RESET_STREAM is the only way to tell the peer that the + * bytes it has are not the whole message. */ +void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code); +/* Non-zero once the peer has sent RESET_STREAM for the half we read from. + * The stream then closes without on_stream_data ever reporting fin. */ +int us_quic_stream_peer_reset(us_quic_stream_t *s); int us_quic_stream_has_unacked(us_quic_stream_t *s); void *us_quic_stream_ext(us_quic_stream_t *s); diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index 2db226ad2f9a..27c2a144aebe 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -188,7 +188,7 @@ impl ClientSession { // is the correct "I'm abandoning this send half" so lsquic reaps // the stream instead of leaking it on the pooled session. if !request_body_done { - qs.reset(); + qs.reset(quic::H3ErrorCode::RequestCancelled); } } st.qstream = None; @@ -288,10 +288,37 @@ impl ClientSession { false } - /// Runs from inside lsquic's process_conns via on_stream_{headers,data,close}. - /// `done` = the lsquic stream is gone; deliver whatever is buffered then - /// detach. Mirrors H2's `ClientSession.deliverStream` so the HTTPClient state - /// machine sees the same call sequence regardless of transport. + /// The lsquic stream is gone and no FIN reached `on_stream_data` (a FIN + /// detaches the stream inside `deliver`, so `on_stream_close` never sees + /// an attached stream after one). The peer sent RESET_STREAM, or the + /// connection closed under the stream. Before the response headers this + /// is the stale-session race that `deliver` retries. After them the body + /// is truncated: the H3 twin of an HTTP/1 close without the last chunk, + /// and of H2's RST_STREAM mid-body (`HTTP2StreamReset`). + pub(crate) fn on_stream_closed(&mut self, stream: *mut Stream, peer_reset: bool) { + let st = stream_mut(stream); + let Some(client_ptr) = st.client else { + return self.detach(stream); + }; + if !st.headers_delivered { + return self.deliver(stream, true); + } + let err = if client_mut(client_ptr).signals.get(Signal::Aborted) { + crate::Error::Aborted + } else if peer_reset { + crate::Error::HTTP3StreamReset + } else { + crate::Error::ConnectionClosed + }; + self.fail(stream, err); + } + + /// Runs from inside lsquic's process_conns via on_stream_{headers,data} + /// and, before the response headers arrive, `on_stream_closed`. `done` = + /// the peer's FIN arrived (or the stream is gone before headers); deliver + /// whatever is buffered then detach. Mirrors H2's + /// `ClientSession.deliverStream` so the HTTPClient state machine sees the + /// same call sequence regardless of transport. pub(crate) fn deliver(&mut self, stream: *mut Stream, done: bool) { let st = stream_mut(stream); let Some(client_ptr) = st.client else { diff --git a/src/http/h3_client/callbacks.rs b/src/http/h3_client/callbacks.rs index 31dfefbd3d16..b891f2851650 100644 --- a/src/http/h3_client/callbacks.rs +++ b/src/http/h3_client/callbacks.rs @@ -306,13 +306,15 @@ extern "C" fn on_stream_writable(s: *mut quic::Stream) { extern "C" fn on_stream_close(s: *mut quic::Stream) { let s = qstream_arg(s); let Some(stream) = stream_of(s) else { return }; + let peer_reset = s.peer_reset(); *s.ext::() = None; stream.qstream = None; bun_core::scoped_log!( h3_client, - "stream_close status={} delivered={}", + "stream_close status={} delivered={} peer_reset={}", stream.status_code, stream.headers_delivered, + peer_reset, ); - stream.session_mut().deliver(stream, true); + stream.session_mut().on_stream_closed(stream, peer_reset); } diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index fa06529014b0..327ed6f41e43 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -116,10 +116,14 @@ void uws_h3_res_end_stream(uws_h3_res_t* res, bool close_connection) r->sendTerminatingChunk(close_connection); } +/* Server-side failure after the response started (a body stream errored, + * a file read failed). HTTP/1 closes the socket without the terminating + * chunk; the HTTP/3 equivalent is RESET_STREAM, since a FIN would hand the + * client the truncated body as a complete message. */ void uws_h3_res_force_close(uws_h3_res_t* res) { ((Http3Response*)res)->clearOnWritableAndAborted(); - us_quic_stream_close((us_quic_stream_t*)res); + us_quic_stream_reset((us_quic_stream_t*)res, US_H3_INTERNAL_ERROR); } bool uws_h3_res_try_end(uws_h3_res_t* res, const char* bytes, size_t len, size_t total_len, bool close) diff --git a/src/uws_sys/quic.rs b/src/uws_sys/quic.rs index 53adc9c8ae67..1f58f16652ee 100644 --- a/src/uws_sys/quic.rs +++ b/src/uws_sys/quic.rs @@ -22,6 +22,7 @@ pub mod stream; pub use self::context::Context; pub use self::pending_connect::PendingConnect; pub use self::socket::Socket; +pub use self::stream::H3ErrorCode; pub use self::stream::Stream; pub use self::header::Header; diff --git a/src/uws_sys/quic/Stream.rs b/src/uws_sys/quic/Stream.rs index ef565b63ac60..4db88cc717db 100644 --- a/src/uws_sys/quic/Stream.rs +++ b/src/uws_sys/quic/Stream.rs @@ -11,6 +11,17 @@ bun_opaque::opaque_ffi! { pub struct Stream; } +/// HTTP/3 error codes (RFC 9114 §8.1) carried by `RESET_STREAM`. Mirrors the +/// `US_H3_*` constants in `quic.h`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u64)] +pub enum H3ErrorCode { + /// The HTTP stack failed while producing the message. + InternalError = 0x102, + /// The request or its response is cancelled by the sender. + RequestCancelled = 0x10C, +} + // `Stream` is an `opaque_ffi!` ZST (`UnsafeCell<[u8; 0]>`), so `&mut Stream` is // ABI-identical to a non-null `*mut Stream` with no `noalias`/`readonly` // attribute. Shims taking only the handle + value types are `safe fn`; the @@ -19,7 +30,8 @@ unsafe extern "C" { safe fn us_quic_stream_socket(s: &mut Stream) -> *mut Socket; safe fn us_quic_stream_shutdown(s: &mut Stream); safe fn us_quic_stream_close(s: &mut Stream); - safe fn us_quic_stream_reset(s: &mut Stream); + safe fn us_quic_stream_reset(s: &mut Stream, error_code: u64); + safe fn us_quic_stream_peer_reset(s: &mut Stream) -> c_int; safe fn us_quic_stream_header_count(s: &mut Stream) -> c_uint; safe fn us_quic_stream_header(s: &mut Stream, i: c_uint) -> *const Header; safe fn us_quic_stream_ext(s: &mut Stream) -> *mut c_void; @@ -52,8 +64,18 @@ impl Stream { us_quic_stream_close(self) } - pub fn reset(&mut self) { - us_quic_stream_reset(self) + /// Abort the send half with `RESET_STREAM(error_code)` and close the + /// stream. Unlike [`Stream::close`], the peer does not see a FIN, which in + /// HTTP/3 would mark the bytes sent so far as a complete message. + pub fn reset(&mut self, error_code: H3ErrorCode) { + us_quic_stream_reset(self, error_code as u64) + } + + /// True once the peer has sent `RESET_STREAM` for the half we read from. + /// `on_stream_close` then follows without `on_stream_data` ever reporting + /// `fin`. + pub fn peer_reset(&mut self) -> bool { + us_quic_stream_peer_reset(self) != 0 } pub fn header_count(&mut self) -> c_uint { diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index d0ce2e64e30c..1ed3009c5ae1 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1451,6 +1451,99 @@ describe("Bun.serve HTTP/3 request validation", () => { }); }); +// A Response body stream that fails after the status and some bytes went out +// cannot be replaced by error(). The transport has to tell the client that the +// message is incomplete: HTTP/1 closes the socket without the last chunk. In +// HTTP/3 a FIN is a complete message, so the stream has to end with +// RESET_STREAM(H3_INTERNAL_ERROR) instead. +describe("Bun.serve HTTP/3 response body error after the first chunk", () => { + const script = ` + const tls = ${JSON.stringify(tls)}; + const server = Bun.serve({ + port: 0, tls, http3: true, http1: false, + routes: { + "/ok": () => new Response("ok"), + "/mid-body-error": () => { + let pulls = 0; + return new Response(new ReadableStream({ + async pull(c) { + // Yield to the event loop so the previous chunk is on the wire + // before the next one (or the error) is produced. + await Bun.sleep(10); + if (pulls++ < 2) c.enqueue(new Uint8Array(1024).fill(65)); + else c.error(new Error("boom")); + }, + })); + }, + }, + }); + console.error("PORT=" + server.port); + process.stdin.on("data", () => {}); + ${STOP_ON_STDIN_END} + `; + + // Raw HTTP/3 client: reports how the response stream ended on the wire. + async function h3ReadBody(port: number, path: string) { + await using endpoint = new QuicEndpoint(); + const client = await connect(`127.0.0.1:${port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + }); + await client.opened; + let status = ""; + const stream = await client.createBidirectionalStream({ + headers: requestHeaders(path), + onheaders(received: Record) { + status = received[":status"]; + }, + }); + stream.closed.catch(() => {}); + // A reset discards body bytes the reader has not consumed yet (RFC 9000 + // section 3.2), so only how the stream ended is deterministic. + let end = "fin"; + try { + for await (const _ of stream as AsyncIterable) { + } + } catch (e) { + end = `${(e as Error & { code?: string }).code}: ${(e as Error).message}`; + } + client.close().catch(() => {}); + return { status, end }; + } + + test("fetch() sees the status, then the body read rejects", async () => { + await withCustomServer(script, async (port, _send, waitForStderr) => { + const res = await fetchH3(port, "/mid-body-error"); + const body = await res.text().then( + text => ({ resolved: text.length }), + (e: Error & { code?: string }) => ({ rejected: e.code }), + ); + // The handler's error still reaches the server's error reporting. + await waitForStderr(/boom/); + // Only the one stream was reset; the connection stays usable. + const after = await fetchH3(port, "/ok"); + expect({ status: res.status, body, after: await after.text() }).toEqual({ + status: 200, + body: { rejected: "HTTP3StreamReset" }, + after: "ok", + }); + }); + }); + + test("the stream ends with RESET_STREAM(H3_INTERNAL_ERROR), not FIN", async () => { + await withCustomServer(script, async port => { + const result = await h3ReadBody(port, "/mid-body-error"); + expect(result).toEqual({ + status: "200", + end: "ERR_QUIC_STREAM_RESET: The QUIC stream was reset by the peer with error code 258", + }); + }); + }); +}); + // 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 2e6d98d0c48e..3a0221a26110 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -1,6 +1,10 @@ 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 { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { listen, QuicError } 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 @@ -671,6 +675,57 @@ test("retries on a fresh session when a pooled session is stale (port reuse)", a } }); +// In HTTP/3 only a FIN completes a message. A response stream that goes away +// after the headers without one (the server sent RESET_STREAM, or the +// connection closed) carries a truncated body, and the body read must fail +// the way HTTP2StreamReset does for h2 instead of resolving with the bytes +// received so far. +describe("response stream ends without FIN", () => { + const keysDir = join(import.meta.dir, "..", "..", "node", "test", "fixtures", "keys"); + const key = createPrivateKey(readFileSync(join(keysDir, "agent1-key.pem"))); + const cert = readFileSync(join(keysDir, "agent1-cert.pem")); + + // A node:quic HTTP/3 server whose response is "200", the body "partial", + // then `end(stream)`. The body is flushed before `end` runs: the server + // yields to the event loop, which is where lsquic packetizes writes. + async function fetchFromServerThat(end: (stream: any, session: any) => void) { + await using server = await listen( + async session => { + session.onstream = (stream: any) => stream.closed.catch(() => {}); + await session.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 1 }, + async onheaders(this: any) { + this.sendHeaders({ ":status": "200" }); + this.writer.writeSync(new TextEncoder().encode("partial")); + await Bun.sleep(10); + end(this, this.session); + }, + }, + ); + const res = await fetch(`https://127.0.0.1:${server.address.port}/`, h3); + const body = await res.text().then( + text => ({ resolved: text }), + (e: Error & { code?: string }) => ({ rejected: e.code }), + ); + return { status: res.status, body }; + } + + test("RESET_STREAM mid-body rejects the body read with HTTP3StreamReset", async () => { + const result = await fetchFromServerThat(stream => + stream.destroy(new QuicError("body failed", { errorCode: 0x102 })), + ); + expect(result).toEqual({ status: 200, body: { rejected: "HTTP3StreamReset" } }); + }); + + test("CONNECTION_CLOSE mid-body rejects the body read with ECONNRESET", async () => { + const result = await fetchFromServerThat((_stream, session) => session.close({ code: 0x100, type: "application" })); + expect(result).toEqual({ status: 200, body: { rejected: "ECONNRESET" } }); + }); +}); + // Subprocess so the experimental flag is process-scoped and the in-process // server above (http1: false) doesn't interfere — this server keeps http1 on // so the first fetch goes over TCP and reads Alt-Svc. From c79e21794e85c0490037cd343775208d70f984d2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:10:28 +0000 Subject: [PATCH 2/9] test(http3): order the mid-body failure on the client's read, not a sleep The fixture servers now fail the body only after the client requests /release, which it does once it holds the status and the first chunk. The node:quic reader asserts the RESET_STREAM error code through the stream's closed promise instead of the error message text. --- test/js/bun/http/serve-http3.test.ts | 62 ++++++++++++++------ test/js/web/fetch/fetch-http3-client.test.ts | 37 ++++++++---- 2 files changed, 70 insertions(+), 29 deletions(-) diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 1ed3009c5ae1..7a0c2f2fe382 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1457,21 +1457,30 @@ describe("Bun.serve HTTP/3 request validation", () => { // HTTP/3 a FIN is a complete message, so the stream has to end with // RESET_STREAM(H3_INTERNAL_ERROR) instead. describe("Bun.serve HTTP/3 response body error after the first chunk", () => { + // The body source sends one chunk and then waits. It errors only once the + // client, which by then holds the status and that chunk, requests + // /release. No timing assumption orders the wire. const script = ` const tls = ${JSON.stringify(tls)}; + const { promise: released, resolve: release } = Promise.withResolvers(); const server = Bun.serve({ port: 0, tls, http3: true, http1: false, routes: { "/ok": () => new Response("ok"), + "/release": () => { + release(); + return new Response("released"); + }, "/mid-body-error": () => { let pulls = 0; return new Response(new ReadableStream({ async pull(c) { - // Yield to the event loop so the previous chunk is on the wire - // before the next one (or the error) is produced. - await Bun.sleep(10); - if (pulls++ < 2) c.enqueue(new Uint8Array(1024).fill(65)); - else c.error(new Error("boom")); + if (pulls++ === 0) { + c.enqueue(new Uint8Array(1024).fill(65)); + return; + } + await released; + c.error(new Error("boom")); }, })); }, @@ -1482,7 +1491,10 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { ${STOP_ON_STDIN_END} `; - // Raw HTTP/3 client: reports how the response stream ended on the wire. + const release = (port: number) => fetchH3(port, "/release").then(r => r.text()); + + // Raw HTTP/3 client: reads the first chunk, asks the server to fail the + // body, and reports how the response stream ended on the wire. async function h3ReadBody(port: number, path: string) { await using endpoint = new QuicEndpoint(); const client = await connect(`127.0.0.1:${port}`, { @@ -1500,34 +1512,46 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { status = received[":status"]; }, }); - stream.closed.catch(() => {}); - // A reset discards body bytes the reader has not consumed yet (RFC 9000 - // section 3.2), so only how the stream ended is deterministic. - let end = "fin"; + // `closed` rejects with the RESET_STREAM error code when the peer resets. + const closed = stream.closed.then( + () => "fin", + (e: Error & { code?: string; errorCode?: bigint }) => ({ code: e.code, errorCode: Number(e.errorCode) }), + ); + let firstChunkReceived = false; + let read = "fin"; try { for await (const _ of stream as AsyncIterable) { + if (!firstChunkReceived) { + firstChunkReceived = true; + await release(port); + } } } catch (e) { - end = `${(e as Error & { code?: string }).code}: ${(e as Error).message}`; + read = (e as Error & { code?: string }).code ?? "unknown"; } + const result = { status, firstChunkReceived, read, closed: await closed }; client.close().catch(() => {}); - return { status, end }; + return result; } test("fetch() sees the status, then the body read rejects", async () => { await withCustomServer(script, async (port, _send, waitForStderr) => { const res = await fetchH3(port, "/mid-body-error"); - const body = await res.text().then( - text => ({ resolved: text.length }), + const reader = res.body!.getReader(); + const first = await reader.read(); + await release(port); + const rest = await reader.read().then( + ({ done }) => ({ resolved: done ? "done" : "chunk" }), (e: Error & { code?: string }) => ({ rejected: e.code }), ); // The handler's error still reaches the server's error reporting. await waitForStderr(/boom/); // Only the one stream was reset; the connection stays usable. - const after = await fetchH3(port, "/ok"); - expect({ status: res.status, body, after: await after.text() }).toEqual({ + const after = await fetchH3(port, "/ok").then(r => r.text()); + expect({ status: res.status, firstChunkReceived: !first.done, rest, after }).toEqual({ status: 200, - body: { rejected: "HTTP3StreamReset" }, + firstChunkReceived: true, + rest: { rejected: "HTTP3StreamReset" }, after: "ok", }); }); @@ -1538,7 +1562,9 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { const result = await h3ReadBody(port, "/mid-body-error"); expect(result).toEqual({ status: "200", - end: "ERR_QUIC_STREAM_RESET: The QUIC stream was reset by the peer with error code 258", + firstChunkReceived: true, + read: "ERR_QUIC_STREAM_RESET", + closed: { code: "ERR_QUIC_APPLICATION_ERROR", errorCode: 258 }, }); }); }); diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index 3a0221a26110..5679fce84b18 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -685,10 +685,12 @@ describe("response stream ends without FIN", () => { const key = createPrivateKey(readFileSync(join(keysDir, "agent1-key.pem"))); const cert = readFileSync(join(keysDir, "agent1-cert.pem")); - // A node:quic HTTP/3 server whose response is "200", the body "partial", - // then `end(stream)`. The body is flushed before `end` runs: the server - // yields to the event loop, which is where lsquic packetizes writes. + // A node:quic HTTP/3 server whose response is "200" and the body "partial". + // It runs `end(stream, session)` only once the client, which by then holds + // the status and that chunk, requests /release. No timing assumption orders + // the wire. async function fetchFromServerThat(end: (stream: any, session: any) => void) { + const { promise: released, resolve: release } = Promise.withResolvers(); await using server = await listen( async session => { session.onstream = (stream: any) => stream.closed.catch(() => {}); @@ -697,32 +699,45 @@ describe("response stream ends without FIN", () => { { sni: { "*": { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, - async onheaders(this: any) { + async onheaders(this: any, headers: Record) { this.sendHeaders({ ":status": "200" }); + if (headers[":path"] === "/release") { + this.writer.endSync(); + release(); + return; + } this.writer.writeSync(new TextEncoder().encode("partial")); - await Bun.sleep(10); + await released; end(this, this.session); }, }, ); - const res = await fetch(`https://127.0.0.1:${server.address.port}/`, h3); - const body = await res.text().then( - text => ({ resolved: text }), + const base = `https://127.0.0.1:${server.address.port}`; + const res = await fetch(`${base}/`, h3); + const reader = res.body!.getReader(); + const first = await reader.read(); + // Its response can be lost to the CONNECTION_CLOSE case, so it is not + // awaited: the first stream's read is what reports the outcome. + fetch(`${base}/release`, h3) + .then(r => r.text()) + .catch(() => {}); + const rest = await reader.read().then( + ({ done }) => ({ resolved: done ? "done" : "chunk" }), (e: Error & { code?: string }) => ({ rejected: e.code }), ); - return { status: res.status, body }; + return { status: res.status, first: new TextDecoder().decode(first.value), rest }; } test("RESET_STREAM mid-body rejects the body read with HTTP3StreamReset", async () => { const result = await fetchFromServerThat(stream => stream.destroy(new QuicError("body failed", { errorCode: 0x102 })), ); - expect(result).toEqual({ status: 200, body: { rejected: "HTTP3StreamReset" } }); + expect(result).toEqual({ status: 200, first: "partial", rest: { rejected: "HTTP3StreamReset" } }); }); test("CONNECTION_CLOSE mid-body rejects the body read with ECONNRESET", async () => { const result = await fetchFromServerThat((_stream, session) => session.close({ code: 0x100, type: "application" })); - expect(result).toEqual({ status: 200, body: { rejected: "ECONNRESET" } }); + expect(result).toEqual({ status: 200, first: "partial", rest: { rejected: "ECONNRESET" } }); }); }); From 405ec97a0099b3b59bd818df1955d1d4ffa6cafe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:14:37 +0000 Subject: [PATCH 3/9] http3: shorten the doc comments on the stream reset helpers --- src/http/h3_client/ClientSession.rs | 22 +++++++++------------- src/uws_sys/libuwsockets_h3.cpp | 7 +++---- src/uws_sys/quic/Stream.rs | 15 +++++---------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index 27c2a144aebe..a3d6bc7033db 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -288,13 +288,11 @@ impl ClientSession { false } - /// The lsquic stream is gone and no FIN reached `on_stream_data` (a FIN - /// detaches the stream inside `deliver`, so `on_stream_close` never sees - /// an attached stream after one). The peer sent RESET_STREAM, or the - /// connection closed under the stream. Before the response headers this - /// is the stale-session race that `deliver` retries. After them the body - /// is truncated: the H3 twin of an HTTP/1 close without the last chunk, - /// and of H2's RST_STREAM mid-body (`HTTP2StreamReset`). + /// The lsquic stream closed without a FIN: a delivered FIN detaches the + /// stream inside `deliver`, so a stream still attached here got none. + /// Before the response headers, `deliver` retries the stale-session race. + /// After them the body is truncated and the request fails, as H2 does + /// with `HTTP2StreamReset`. pub(crate) fn on_stream_closed(&mut self, stream: *mut Stream, peer_reset: bool) { let st = stream_mut(stream); let Some(client_ptr) = st.client else { @@ -313,12 +311,10 @@ impl ClientSession { self.fail(stream, err); } - /// Runs from inside lsquic's process_conns via on_stream_{headers,data} - /// and, before the response headers arrive, `on_stream_closed`. `done` = - /// the peer's FIN arrived (or the stream is gone before headers); deliver - /// whatever is buffered then detach. Mirrors H2's - /// `ClientSession.deliverStream` so the HTTPClient state machine sees the - /// same call sequence regardless of transport. + /// Runs from inside lsquic's process_conns via on_stream_{headers,data}, + /// and via `on_stream_closed` before the response headers. `done` = the + /// peer's FIN arrived; deliver whatever is buffered then detach. Mirrors + /// H2's `ClientSession.deliverStream`. pub(crate) fn deliver(&mut self, stream: *mut Stream, done: bool) { let st = stream_mut(stream); let Some(client_ptr) = st.client else { diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index 327ed6f41e43..eebc73ac2e52 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -116,10 +116,9 @@ void uws_h3_res_end_stream(uws_h3_res_t* res, bool close_connection) r->sendTerminatingChunk(close_connection); } -/* Server-side failure after the response started (a body stream errored, - * a file read failed). HTTP/1 closes the socket without the terminating - * chunk; the HTTP/3 equivalent is RESET_STREAM, since a FIN would hand the - * client the truncated body as a complete message. */ +/* The body failed after the response started. A FIN would deliver the + * truncated body as a complete message; RESET_STREAM is the HTTP/3 form of + * HTTP/1's close without the terminating chunk. */ void uws_h3_res_force_close(uws_h3_res_t* res) { ((Http3Response*)res)->clearOnWritableAndAborted(); diff --git a/src/uws_sys/quic/Stream.rs b/src/uws_sys/quic/Stream.rs index 4db88cc717db..db624358bd94 100644 --- a/src/uws_sys/quic/Stream.rs +++ b/src/uws_sys/quic/Stream.rs @@ -11,14 +11,11 @@ bun_opaque::opaque_ffi! { pub struct Stream; } -/// HTTP/3 error codes (RFC 9114 §8.1) carried by `RESET_STREAM`. Mirrors the -/// `US_H3_*` constants in `quic.h`. +/// HTTP/3 error codes (RFC 9114 §8.1) for `RESET_STREAM`; the `US_H3_*` values in `quic.h`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u64)] pub enum H3ErrorCode { - /// The HTTP stack failed while producing the message. InternalError = 0x102, - /// The request or its response is cancelled by the sender. RequestCancelled = 0x10C, } @@ -64,16 +61,14 @@ impl Stream { us_quic_stream_close(self) } - /// Abort the send half with `RESET_STREAM(error_code)` and close the - /// stream. Unlike [`Stream::close`], the peer does not see a FIN, which in - /// HTTP/3 would mark the bytes sent so far as a complete message. + /// `RESET_STREAM(error_code)` and close. Unlike [`Stream::close`] the peer + /// sees no FIN, which in HTTP/3 would mark the message complete. pub fn reset(&mut self, error_code: H3ErrorCode) { us_quic_stream_reset(self, error_code as u64) } - /// True once the peer has sent `RESET_STREAM` for the half we read from. - /// `on_stream_close` then follows without `on_stream_data` ever reporting - /// `fin`. + /// The peer sent `RESET_STREAM` for our read half. `on_stream_close` then + /// follows with no `fin` from `on_stream_data`. pub fn peer_reset(&mut self) -> bool { us_quic_stream_peer_reset(self) != 0 } From 1256a9c71885a7d133f66bff1732062fddef94f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:30:26 +0000 Subject: [PATCH 4/9] http3 client: re-send a request after a pre-header reset only for H3_REQUEST_REJECTED The stream-close callback now carries the peer's RESET_STREAM error code. Before the response headers, the client re-sends the request once only when the connection went away under it or the code is H3_REQUEST_REJECTED (0x10B), the one code that promises the server did no application processing. Any other reset fails the request with HTTP3StreamReset, as the h2 client retries only REFUSED_STREAM. The HTTP/3 error codes now live in one place, the Rust H3ErrorCode enum; uws_h3_res_force_close takes the code from its Rust caller. --- packages/bun-usockets/src/quic.c | 8 ++- packages/bun-usockets/src/quic.h | 14 +++--- src/http/h3_client/ClientSession.rs | 18 ++++--- src/http/h3_client/callbacks.rs | 2 +- src/uws_sys/h3.rs | 6 ++- src/uws_sys/libuwsockets_h3.cpp | 9 ++-- src/uws_sys/quic/Stream.rs | 15 +++--- test/js/web/fetch/fetch-http3-client.test.ts | 51 ++++++++++++++++++++ 8 files changed, 93 insertions(+), 30 deletions(-) diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 96cfbf794033..bc6fae0bf52f 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -121,6 +121,7 @@ struct us_quic_stream_s { int headers_delivered; int fin_delivered; int peer_reset; + uint64_t peer_reset_code; /* ext follows */ }; @@ -673,7 +674,9 @@ static void us_quic_on_reset(lsquic_stream_t *stream, lsquic_stream_ctx_t *h, in * alongside STOP_SENDING is still delivered. Closing here * would drop it. */ if (h && stream && how == 0) { - ((us_quic_stream_t *) h)->peer_reset = 1; + us_quic_stream_t *s = (us_quic_stream_t *) h; + s->peer_reset = 1; + s->peer_reset_code = lsquic_stream_get_error_code(stream); lsquic_stream_close(stream); } } @@ -1095,7 +1098,8 @@ void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code) { if (s->stream) lsquic_stream_maybe_reset(s->stream, error_code, 1); } -int us_quic_stream_peer_reset(us_quic_stream_t *s) { +int us_quic_stream_peer_reset(us_quic_stream_t *s, uint64_t *code) { + *code = s->peer_reset_code; return s->peer_reset; } diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index 85e606359660..49765afa02fd 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -144,18 +144,16 @@ void us_quic_stream_flush(us_quic_stream_t *s); void us_quic_stream_shutdown_read(us_quic_stream_t *s); void us_quic_stream_close(us_quic_stream_t *s); -/* HTTP/3 error codes (RFC 9114 §8.1) for us_quic_stream_reset. */ -#define US_H3_INTERNAL_ERROR 0x102 -#define US_H3_REQUEST_CANCELLED 0x10C - /* Abort the send half with RESET_STREAM(error_code) and close the stream. * us_quic_stream_close/shutdown queue a FIN, and in HTTP/3 a FIN marks a * complete message; RESET_STREAM is the only way to tell the peer that the - * bytes it has are not the whole message. */ + * bytes it has are not the whole message. error_code is an application + * (RFC 9114 §8.1) code; the caller picks it. */ void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code); -/* Non-zero once the peer has sent RESET_STREAM for the half we read from. - * The stream then closes without on_stream_data ever reporting fin. */ -int us_quic_stream_peer_reset(us_quic_stream_t *s); +/* Non-zero once the peer has sent RESET_STREAM for the half we read from, + * with its error code in *code. The stream then closes without + * on_stream_data ever reporting fin. */ +int us_quic_stream_peer_reset(us_quic_stream_t *s, uint64_t *code); int us_quic_stream_has_unacked(us_quic_stream_t *s); void *us_quic_stream_ext(us_quic_stream_t *s); diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index a3d6bc7033db..43ff79639fdb 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -290,20 +290,26 @@ impl ClientSession { /// The lsquic stream closed without a FIN: a delivered FIN detaches the /// stream inside `deliver`, so a stream still attached here got none. - /// Before the response headers, `deliver` retries the stale-session race. - /// After them the body is truncated and the request fails, as H2 does - /// with `HTTP2StreamReset`. - pub(crate) fn on_stream_closed(&mut self, stream: *mut Stream, peer_reset: bool) { + /// `peer_reset` is the code of the peer's RESET_STREAM, if it sent one. + /// + /// Before the response headers, `deliver` retries the request once when + /// the connection went away under it (the stale-session race) or the + /// server promised it did no application processing (H3_REQUEST_REJECTED, + /// RFC 9114 section 8.1). Any other reset may have reached the handler, so + /// the request is not re-sent, as H2 retries only REFUSED_STREAM. After + /// the headers the body is truncated and the request fails. + pub(crate) fn on_stream_closed(&mut self, stream: *mut Stream, peer_reset: Option) { let st = stream_mut(stream); let Some(client_ptr) = st.client else { return self.detach(stream); }; - if !st.headers_delivered { + let retryable = peer_reset.is_none_or(|c| c == quic::H3ErrorCode::RequestRejected as u64); + if !st.headers_delivered && retryable { return self.deliver(stream, true); } let err = if client_mut(client_ptr).signals.get(Signal::Aborted) { crate::Error::Aborted - } else if peer_reset { + } else if peer_reset.is_some() { crate::Error::HTTP3StreamReset } else { crate::Error::ConnectionClosed diff --git a/src/http/h3_client/callbacks.rs b/src/http/h3_client/callbacks.rs index b891f2851650..71d767f24e88 100644 --- a/src/http/h3_client/callbacks.rs +++ b/src/http/h3_client/callbacks.rs @@ -311,7 +311,7 @@ extern "C" fn on_stream_close(s: *mut quic::Stream) { stream.qstream = None; bun_core::scoped_log!( h3_client, - "stream_close status={} delivered={} peer_reset={}", + "stream_close status={} delivered={} peer_reset={:?}", stream.status_code, stream.headers_delivered, peer_reset, diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index caabd299d636..cde8c73abcee 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -203,8 +203,10 @@ impl Response { let ip = unsafe { bun_core::ffi::slice(ip_ptr, len) }; Some(SocketAddress::new(ip, port, is_ipv6)) } + /// The body failed after the response started: `RESET_STREAM`, the + /// HTTP/3 form of HTTP/1's close without the terminating chunk. pub(crate) fn force_close(&mut self) { - c::uws_h3_res_force_close(self) + c::uws_h3_res_force_close(self, crate::quic::H3ErrorCode::InternalError as u64) } pub(crate) fn on_writable(&mut self, _handler: H, ud: *mut UD) @@ -691,7 +693,7 @@ mod c { pub(super) safe fn uws_h3_res_state(res: &mut Response) -> State; pub(super) fn uws_h3_res_end(res: *mut Response, p: *const u8, n: usize, close: bool); pub(super) safe fn uws_h3_res_end_stream(res: &mut Response, close: bool); - pub(super) safe fn uws_h3_res_force_close(res: &mut Response); + pub(super) safe fn uws_h3_res_force_close(res: &mut Response, error_code: u64); pub(super) fn uws_h3_res_try_end( res: *mut Response, p: *const u8, diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index eebc73ac2e52..f1f87b3a563e 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -116,13 +116,12 @@ void uws_h3_res_end_stream(uws_h3_res_t* res, bool close_connection) r->sendTerminatingChunk(close_connection); } -/* The body failed after the response started. A FIN would deliver the - * truncated body as a complete message; RESET_STREAM is the HTTP/3 form of - * HTTP/1's close without the terminating chunk. */ -void uws_h3_res_force_close(uws_h3_res_t* res) +/* RESET_STREAM(error_code), not FIN: in HTTP/3 a FIN would deliver the + * bytes sent so far as a complete message. */ +void uws_h3_res_force_close(uws_h3_res_t* res, uint64_t error_code) { ((Http3Response*)res)->clearOnWritableAndAborted(); - us_quic_stream_reset((us_quic_stream_t*)res, US_H3_INTERNAL_ERROR); + us_quic_stream_reset((us_quic_stream_t*)res, error_code); } bool uws_h3_res_try_end(uws_h3_res_t* res, const char* bytes, size_t len, size_t total_len, bool close) diff --git a/src/uws_sys/quic/Stream.rs b/src/uws_sys/quic/Stream.rs index db624358bd94..68d425c8718b 100644 --- a/src/uws_sys/quic/Stream.rs +++ b/src/uws_sys/quic/Stream.rs @@ -11,11 +11,13 @@ bun_opaque::opaque_ffi! { pub struct Stream; } -/// HTTP/3 error codes (RFC 9114 §8.1) for `RESET_STREAM`; the `US_H3_*` values in `quic.h`. +/// HTTP/3 error codes (RFC 9114 §8.1) carried by `RESET_STREAM`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u64)] pub enum H3ErrorCode { InternalError = 0x102, + /// The server did no application processing; the request can be re-sent. + RequestRejected = 0x10B, RequestCancelled = 0x10C, } @@ -28,7 +30,7 @@ unsafe extern "C" { safe fn us_quic_stream_shutdown(s: &mut Stream); safe fn us_quic_stream_close(s: &mut Stream); safe fn us_quic_stream_reset(s: &mut Stream, error_code: u64); - safe fn us_quic_stream_peer_reset(s: &mut Stream) -> c_int; + safe fn us_quic_stream_peer_reset(s: &mut Stream, code: &mut u64) -> c_int; safe fn us_quic_stream_header_count(s: &mut Stream) -> c_uint; safe fn us_quic_stream_header(s: &mut Stream, i: c_uint) -> *const Header; safe fn us_quic_stream_ext(s: &mut Stream) -> *mut c_void; @@ -67,10 +69,11 @@ impl Stream { us_quic_stream_reset(self, error_code as u64) } - /// The peer sent `RESET_STREAM` for our read half. `on_stream_close` then - /// follows with no `fin` from `on_stream_data`. - pub fn peer_reset(&mut self) -> bool { - us_quic_stream_peer_reset(self) != 0 + /// The error code of the `RESET_STREAM` the peer sent for our read half. + /// `on_stream_close` then follows with no `fin` from `on_stream_data`. + pub fn peer_reset(&mut self) -> Option { + let mut code = 0u64; + (us_quic_stream_peer_reset(self, &mut code) != 0).then_some(code) } pub fn header_count(&mut self) -> c_uint { diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index 5679fce84b18..1eefd2f8008e 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -739,6 +739,57 @@ describe("response stream ends without FIN", () => { const result = await fetchFromServerThat((_stream, session) => session.close({ code: 0x100, type: "application" })); expect(result).toEqual({ status: 200, first: "partial", rest: { rejected: "ECONNRESET" } }); }); + + // RESET_STREAM before the response headers. Only H3_REQUEST_REJECTED (0x10B) + // promises that the server did no application processing (RFC 9114 section + // 8.1), so only that code may re-send the request, as h2 retries only + // REFUSED_STREAM. Any other code fails the request without a second copy. + async function postToServerThatResetsWith(errorCode: number) { + const requests: string[] = []; + await using server = await listen( + async session => { + session.onstream = (stream: any) => stream.closed.catch(() => {}); + await session.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 1 }, + onheaders(this: any, headers: Record) { + requests.push(`${headers[":method"]} ${headers[":path"]}`); + if (requests.length === 1) { + this.destroy(new QuicError("rejected", { errorCode })); + return; + } + this.sendHeaders({ ":status": "200" }); + this.writer.writeSync(new TextEncoder().encode("second attempt")); + this.writer.endSync(); + }, + }, + ); + const result = await fetch(`https://127.0.0.1:${server.address.port}/submit`, { + ...h3, + method: "POST", + body: "payload", + }).then( + async res => ({ resolved: `${res.status} ${await res.text()}` }), + (e: Error & { code?: string }) => ({ rejected: e.code }), + ); + return { result, requests }; + } + + test("RESET_STREAM(H3_REQUEST_REJECTED) before the headers re-sends the request once", async () => { + expect(await postToServerThatResetsWith(0x10b)).toEqual({ + result: { resolved: "200 second attempt" }, + requests: ["POST /submit", "POST /submit"], + }); + }); + + test("RESET_STREAM(H3_INTERNAL_ERROR) before the headers fails without a second copy of the request", async () => { + expect(await postToServerThatResetsWith(0x102)).toEqual({ + result: { rejected: "HTTP3StreamReset" }, + requests: ["POST /submit"], + }); + }); }); // Subprocess so the experimental flag is process-scoped and the in-process From 4aaf797798260f3d73709b0b7477da0a022b483a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:32:46 +0000 Subject: [PATCH 5/9] http3 client: shorten the on_stream_closed doc comment --- src/http/h3_client/ClientSession.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index 43ff79639fdb..93fc508b7dd9 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -288,16 +288,13 @@ impl ClientSession { false } - /// The lsquic stream closed without a FIN: a delivered FIN detaches the - /// stream inside `deliver`, so a stream still attached here got none. - /// `peer_reset` is the code of the peer's RESET_STREAM, if it sent one. - /// - /// Before the response headers, `deliver` retries the request once when - /// the connection went away under it (the stale-session race) or the - /// server promised it did no application processing (H3_REQUEST_REJECTED, - /// RFC 9114 section 8.1). Any other reset may have reached the handler, so - /// the request is not re-sent, as H2 retries only REFUSED_STREAM. After - /// the headers the body is truncated and the request fails. + /// The lsquic stream closed without a FIN (a delivered FIN detaches the + /// stream inside `deliver`). `peer_reset` is the peer's RESET_STREAM code. + /// Before the response headers `deliver` re-sends the request once, but + /// only when no reset arrived (stale session) or the code is + /// H3_REQUEST_REJECTED, the one code that promises the server did no + /// application processing (RFC 9114 section 8.1; h2 retries only + /// REFUSED_STREAM). Otherwise the request fails. pub(crate) fn on_stream_closed(&mut self, stream: *mut Stream, peer_reset: Option) { let st = stream_mut(stream); let Some(client_ptr) = st.client else { From 60c8aed7685a784528cc58f1b64983d0e872845b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:00:42 +0000 Subject: [PATCH 6/9] test(http3): cover a body that fails before its first chunk; do not pin the node:quic iterator error --- test/js/bun/http/serve-http3.test.ts | 56 ++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index c5823ffbb114..c626e81b721e 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1891,18 +1891,19 @@ describe("Bun.serve HTTP/3 request validation", () => { }); }); -// A Response body stream that fails after the status and some bytes went out -// cannot be replaced by error(). The transport has to tell the client that the -// message is incomplete: HTTP/1 closes the socket without the last chunk. In -// HTTP/3 a FIN is a complete message, so the stream has to end with +// A Response body stream that fails after the status is committed cannot be +// replaced by error(). The transport has to tell the client that the message +// is incomplete: HTTP/1 closes the socket without the last chunk. In HTTP/3 a +// FIN is a complete message, so the stream has to end with // RESET_STREAM(H3_INTERNAL_ERROR) instead. -describe("Bun.serve HTTP/3 response body error after the first chunk", () => { - // The body source sends one chunk and then waits. It errors only once the - // client, which by then holds the status and that chunk, requests - // /release. No timing assumption orders the wire. +describe("Bun.serve HTTP/3 response body error", () => { + // The body source of /mid-body-error sends one chunk and then waits. It + // errors only once the client, which by then holds the status and that + // chunk, requests /release. No timing assumption orders the wire. const script = ` const tls = ${JSON.stringify(tls)}; const { promise: released, resolve: release } = Promise.withResolvers(); + let failedBeforeFirstChunk = 0; const server = Bun.serve({ port: 0, tls, http3: true, http1: false, routes: { @@ -1924,6 +1925,16 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { }, })); }, + "/error-before-first-chunk": () => { + failedBeforeFirstChunk++; + return new Response(new ReadableStream({ + async pull(c) { + await Promise.resolve(); + c.error(new Error("boom")); + }, + })); + }, + "/error-before-first-chunk/count": () => new Response(String(failedBeforeFirstChunk)), }, }); console.error("PORT=" + server.port); @@ -1958,7 +1969,9 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { (e: Error & { code?: string; errorCode?: bigint }) => ({ code: e.code, errorCode: Number(e.errorCode) }), ); let firstChunkReceived = false; - let read = "fin"; + // Which error the iterator throws depends on whether the stream is + // already closed when it pulls again, so only a clean end is recorded. + let bodyEndedCleanly = true; try { for await (const _ of stream as AsyncIterable) { if (!firstChunkReceived) { @@ -1966,15 +1979,15 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { await release(port); } } - } catch (e) { - read = (e as Error & { code?: string }).code ?? "unknown"; + } catch { + bodyEndedCleanly = false; } - const result = { status, firstChunkReceived, read, closed: await closed }; + const result = { status, firstChunkReceived, bodyEndedCleanly, closed: await closed }; client.close().catch(() => {}); return result; } - test("fetch() sees the status, then the body read rejects", async () => { + test("after the first chunk: fetch() sees the status, then the body read rejects", async () => { await withCustomServer(script, async (port, _send, waitForStderr) => { const res = await fetchH3(port, "/mid-body-error"); const reader = res.body!.getReader(); @@ -1997,17 +2010,30 @@ describe("Bun.serve HTTP/3 response body error after the first chunk", () => { }); }); - test("the stream ends with RESET_STREAM(H3_INTERNAL_ERROR), not FIN", async () => { + test("after the first chunk: the stream ends with RESET_STREAM(H3_INTERNAL_ERROR), not FIN", async () => { await withCustomServer(script, async port => { const result = await h3ReadBody(port, "/mid-body-error"); expect(result).toEqual({ status: "200", firstChunkReceived: true, - read: "ERR_QUIC_STREAM_RESET", + bodyEndedCleanly: false, closed: { code: "ERR_QUIC_APPLICATION_ERROR", errorCode: 258 }, }); }); }); + + // The reset says that the handler ran (H3_INTERNAL_ERROR, not + // H3_REQUEST_REJECTED), so fetch() must not send the request a second time. + test("before the first chunk: fetch() rejects and the handler runs once", async () => { + await withCustomServer(script, async port => { + const outcome = await fetchH3(port, "/error-before-first-chunk", { method: "POST", body: "payload" }).then( + res => ({ resolved: res.status }), + (e: Error & { code?: string }) => ({ rejected: e.code }), + ); + const handlerRuns = await fetchH3(port, "/error-before-first-chunk/count").then(r => r.text()); + expect({ outcome, handlerRuns }).toEqual({ outcome: { rejected: "HTTP3StreamReset" }, handlerRuns: "1" }); + }); + }); }); // RFC 9110 section 10.1.1: a client that sent Expect: 100-continue can hold the From 66d69628ed36f8490c4ea6cea5003200e510e4fd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:35:48 +0000 Subject: [PATCH 7/9] quic: read the peer's RESET_STREAM from lsquic instead of the stream struct us_quic_stream_peer_reset now asks lsquic (lsquic_stream_reset_received, lsquic_stream_get_error_code) while the stream is live, so us_quic_stream_s carries no new field. Keeps the existing comments on us_quic_stream_reset and ClientSession::deliver as they are on main. --- packages/bun-usockets/src/quic.c | 14 +++++++------- packages/bun-usockets/src/quic.h | 14 +++++--------- src/http/h3_client/ClientSession.rs | 8 ++++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 63ce50b9287d..ffc7c07eab3a 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -1244,13 +1244,13 @@ void us_quic_stream_close(us_quic_stream_t *s) { /* From lsquic_stream.h (not in the public header). */ void lsquic_stream_maybe_reset(struct lsquic_stream *, uint64_t error_code, int); -/* Abort the send half with RESET_STREAM(error_code) instead of FIN. - * lsquic_stream_close/shutdown queue FIN after the buffered tail. For a - * client abandoning an upload short of its advertised content-length that - * is a protocol error the server answers with CONNECTION_CLOSE (RFC 9114 - * §4.1.2); for a server whose response body failed it is a complete-looking - * truncated message. RESET_STREAM is the wire-level "this send is aborted" - * and lets the peer treat it as a stream-level failure. +/* Abort the send half with RESET_STREAM(error_code) instead of + * FIN. lsquic_stream_close/shutdown queue FIN after the buffered tail, + * which is a protocol error if a content-length was advertised and the + * client is abandoning the upload short — the server's lsquic will + * CONNECTION_CLOSE on the mismatch (RFC 9114 §4.1.2). RESET_STREAM is + * the wire-level "I'm cancelling this send" and lets the server treat it + * as a stream-level cancellation rather than a malformed message. * Sends nothing once lsquic_stream_close/shutdown has run, so call it first. */ void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code) { if (!s->stream) return; diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index c6161adc147f..354c1f2fc685 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -144,16 +144,12 @@ void us_quic_stream_flush(us_quic_stream_t *s); void us_quic_stream_shutdown_read(us_quic_stream_t *s); void us_quic_stream_close(us_quic_stream_t *s); -/* Abort the send half with RESET_STREAM(error_code) and close the stream. - * us_quic_stream_close/shutdown queue a FIN, and in HTTP/3 a FIN marks a - * complete message; RESET_STREAM is the only way to tell the peer that the - * bytes it has are not the whole message. error_code is an application - * (RFC 9114 §8.1) code; the caller picks it. */ +/* RESET_STREAM(error_code) instead of FIN: in HTTP/3 a FIN marks the bytes + * sent so far as a complete message. */ void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code); -/* Non-zero once the peer has sent RESET_STREAM for the half we read from, - * with its error code in *code. The stream then closes without - * on_stream_data ever reporting fin. Read from lsquic, so it answers only - * while the stream is live, up to and including on_stream_close. */ +/* Non-zero, with the code in *code, once the peer has sent RESET_STREAM for + * the half we read from. Answers only while the stream is live, up to and + * including on_stream_close. */ int us_quic_stream_peer_reset(us_quic_stream_t *s, uint64_t *code); int us_quic_stream_has_unacked(us_quic_stream_t *s); diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index 43331ca42b17..ee191bfe5b91 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -350,10 +350,10 @@ impl ClientSession { self.fail(stream, err); } - /// Runs from inside lsquic's process_conns via on_stream_{headers,data}, - /// and via `on_stream_closed` before the response headers. `done` = the - /// peer's FIN arrived; deliver whatever is buffered then detach. Mirrors - /// H2's `ClientSession.deliverStream`. + /// Runs from inside lsquic's process_conns via on_stream_{headers,data,close}. + /// `done` = the lsquic stream is gone; deliver whatever is buffered then + /// detach. Mirrors H2's `ClientSession.deliverStream` so the HTTPClient state + /// machine sees the same call sequence regardless of transport. pub(crate) fn deliver(&mut self, stream: *mut Stream, done: bool) { let st = stream_mut(stream); let Some(client_ptr) = st.client else { From 7908e1133a5f49368f98723a08b35f6123b95e13 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:14:12 +0000 Subject: [PATCH 8/9] test(http3): a request rejected with H3_REQUEST_REJECTED during its body upload is re-sent once --- test/js/web/fetch/fetch-http3-client.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index edd4f848b22a..d3a3d2138890 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -1014,7 +1014,7 @@ describe("response stream ends without FIN", () => { // promises that the server did no application processing (RFC 9114 section // 8.1), so only that code may re-send the request, as h2 retries only // REFUSED_STREAM. Any other code fails the request without a second copy. - async function postToServerThatResetsWith(errorCode: number) { + async function postToServerThatResetsWith(errorCode: number, body: string | Uint8Array = "payload") { const requests: string[] = []; const origin = await listenOrigin(function (headers) { requests.push(`${headers[":method"]} ${headers[":path"]}`); @@ -1030,7 +1030,7 @@ describe("response stream ends without FIN", () => { const result = await fetch(`https://127.0.0.1:${origin.address.port}/submit`, { ...h3, method: "POST", - body: "payload", + body, }).then( async res => ({ resolved: `${res.status} ${await res.text()}` }), (e: Error & { code?: string }) => ({ rejected: e.code }), @@ -1048,6 +1048,15 @@ describe("response stream ends without FIN", () => { }); }); + // The reset arrives while the client still sends the body, so its own send + // half is open. The code the client reads must still be the server's. + test("RESET_STREAM(H3_REQUEST_REJECTED) during the body upload re-sends the request once", async () => { + expect(await postToServerThatResetsWith(0x10b, Buffer.alloc(8 * 1024 * 1024, "a"))).toEqual({ + result: { resolved: "200 second attempt" }, + requests: ["POST /submit", "POST /submit"], + }); + }); + test("RESET_STREAM(H3_INTERNAL_ERROR) before the headers fails without a second copy of the request", async () => { expect(await postToServerThatResetsWith(0x102)).toEqual({ result: { rejected: "HTTP3StreamReset" }, From e19699ec59cc5e5261b6c978684588c80c753f69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:42:18 +0000 Subject: [PATCH 9/9] test(http3): reject the uploading request with RESET_STREAM alone, without STOP_SENDING --- test/js/web/fetch/fetch-http3-client.test.ts | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index d3a3d2138890..71a12b1d4bb8 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -1014,12 +1014,12 @@ describe("response stream ends without FIN", () => { // promises that the server did no application processing (RFC 9114 section // 8.1), so only that code may re-send the request, as h2 retries only // REFUSED_STREAM. Any other code fails the request without a second copy. - async function postToServerThatResetsWith(errorCode: number, body: string | Uint8Array = "payload") { + async function postToServerThatResetsWith(reset: (stream: any) => void, body: string | Uint8Array = "payload") { const requests: string[] = []; const origin = await listenOrigin(function (headers) { requests.push(`${headers[":method"]} ${headers[":path"]}`); if (requests.length === 1) { - this.destroy(new QuicError("rejected", { errorCode })); + reset(this); return; } this.sendHeaders({ ":status": "200" }); @@ -1041,24 +1041,29 @@ describe("response stream ends without FIN", () => { } } + // destroy() sends STOP_SENDING next to RESET_STREAM, both with the code. + const destroyWith = (errorCode: number) => (stream: any) => stream.destroy(new QuicError("rejected", { errorCode })); + test("RESET_STREAM(H3_REQUEST_REJECTED) before the headers re-sends the request once", async () => { - expect(await postToServerThatResetsWith(0x10b)).toEqual({ + expect(await postToServerThatResetsWith(destroyWith(0x10b))).toEqual({ result: { resolved: "200 second attempt" }, requests: ["POST /submit", "POST /submit"], }); }); - // The reset arrives while the client still sends the body, so its own send - // half is open. The code the client reads must still be the server's. - test("RESET_STREAM(H3_REQUEST_REJECTED) during the body upload re-sends the request once", async () => { - expect(await postToServerThatResetsWith(0x10b, Buffer.alloc(8 * 1024 * 1024, "a"))).toEqual({ + // RESET_STREAM alone, while the client still sends the body: its own send + // half is open and no STOP_SENDING repeats the code. The code the client + // reads at close must still be the server's. + test("RESET_STREAM(H3_REQUEST_REJECTED) alone during the body upload re-sends the request once", async () => { + const resetOnly = (stream: any) => stream.resetStream(0x10b); + expect(await postToServerThatResetsWith(resetOnly, Buffer.alloc(8 * 1024 * 1024, "a"))).toEqual({ result: { resolved: "200 second attempt" }, requests: ["POST /submit", "POST /submit"], }); }); test("RESET_STREAM(H3_INTERNAL_ERROR) before the headers fails without a second copy of the request", async () => { - expect(await postToServerThatResetsWith(0x102)).toEqual({ + expect(await postToServerThatResetsWith(destroyWith(0x102))).toEqual({ result: { rejected: "HTTP3StreamReset" }, requests: ["POST /submit"], });