fetch: decide a streaming request body's framing once, reject caller framing headers it cannot honor - #42024
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review. WalkthroughStreaming request bodies now use validated framing metadata. Fetch setup passes this metadata to transport code, which enforces declared lengths and reports mismatches across HTTP/1, HTTP/2, and HTTP/3. Tests cover framing, redirects, invalid headers, and stream types. ChangesStreaming request framing
Suggested reviewers: Priority: ➖ Normal 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Reproduced on bun 1.4.3 (f42e980) against a raw With this branch the same probe shows: a Across a redirect, a 303 follow-up carries no framing header from the caller (HTTP/1.1 and HTTP/2), a 303 that arrives before the body resolves with the final response even if the dropped stream then misses its count, and a 307 still rejects as not replayable. The new This supersedes #35790 and covers the stream-body framing parts of #35786 and #35788. CI (build 116275): every test this PR adds or touches passes on all lanes (Linux, Windows x64 and aarch64, macOS x64 and aarch64). The red jobs all fail on one test, |
|
Updated 7:04 PM PT - Sep 17th, 2026
✅ @robobun, your commit 71e616a6916a9fdf32c1f77363c8b8d7f3c91ce7 passed in 🧪 To try this PR locally: bunx bun-pr 42024That installs a local version of the PR into your bun-42024 --bun |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes how fetch() frames HTTP/1.1 request bodies on the wire — a request-smuggling-adjacent path with cross-thread state — a human look would still be worthwhile.
What was reviewed:
- Framing decision flow: single decision on the JS thread, carried via
Stream::content_length, emitted bybuild_request_headers— no path re-reads raw caller headers. - Refcount balance on the new shortfall early-return in the stream-end handler;
saturating_addon the byte counter; thedetach_lifetimeonrequest_content_len_bufmirrors the existing non-streaming branch immediately below it. content_length_for_framingrejects empty/+/hex/float/joined/overflow; upgraded connections skip counting; error routes through the existingERR_HTTP_CONTENT_LENGTH_MISMATCHcode.- Test uses a local
netserver onport: 0, awaits observable conditions (no sleeps), and the shortfall assertion avoids the Windows RST race.
Extended reasoning...
Overview
This PR reworks how fetch() frames a streaming request body (ReadableStream, async generator, node Readable) over HTTP/1.1. Previously, the HTTP thread and the JS thread each inspected raw caller headers independently to decide framing, and a caller-supplied Content-Length was copied to the wire verbatim without validating that the stream would produce exactly that many bytes. The fix moves the framing decision to a single point on the JS thread (content_length_for_framing strictly parses ASCII digits fitting u64), stores it on http_request_body::Stream::content_length, and has build_request_headers announce that decision rather than the raw caller value. write_request_data counts bytes against the declared length and aborts with ERR_HTTP_CONTENT_LENGTH_MISMATCH on surplus or shortfall. Caller Transfer-Encoding is now always dropped. Four files touched: src/http/HTTPRequestBody.rs, src/http/lib.rs, src/runtime/webcore/fetch/FetchTasklet.rs, and test/js/web/fetch/fetch.test.ts.
Security risks
The change is itself a security hardening: without it, a Content-Length: 2 header on a 7-byte streaming body would leave 5 surplus bytes on a keep-alive connection where the peer parses them as the next request — classic request smuggling. The fix closes that by refusing to write past the declared length and aborting the transport (keeping the connection out of the pool). I checked that no surplus byte reaches thread_safe_stream_buffer (the mismatch check runs before the buffer append), that unparseable values fall back to chunked rather than reaching the wire, and that the abort path sets an abort reason and calls abort_task() so the connection is not reused. The remaining risk is behavioral/compat: the change alters what goes on the wire for edge-case caller headers, and interactions with HTTP/2, redirects that re-send the body, and the upgrade path deserve human eyes.
Level of scrutiny
High. HTTP/1.1 message framing is a protocol boundary where mistakes become request smuggling or desync. The change coordinates state across two threads (JS thread decides and counts; HTTP thread emits the header), adds two new FetchTasklet fields with lifecycle implications, and introduces a new abort path mid-write. The PR description is thorough and the test coverage is broad (invalid values, joined headers, all three streaming body types, Transfer-Encoding variants, blob-backed streams), but the wire-level consequences and the compat call (honoring vs. rejecting a caller Content-Length, the ERR_HTTP_CONTENT_LENGTH_MISMATCH vs. undici's UND_ERR_REQ_CONTENT_LENGTH_MISMATCH choice) are the kind of decision a maintainer should sign off on.
Other factors
No CODEOWNERS entry covers these paths. The unsafe { bun_ptr::detach_lifetime } on request_content_len_buf is identical to the pre-existing pattern for the non-streaming branch a few lines below, and the buffer is a fixed-size array field on self. The new early-return on shortfall balances the ref it holds (FetchTasklet::deref(this_ptr)) matching the neighboring is_completed() early-return. The error code reuses the existing ERR_HTTP_CONTENT_LENGTH_MISMATCH from ErrorCode.ts rather than minting a new one. The PR notes it supersedes/overlaps three other PRs and touches the same functions as two in-flight unsafe-removal PRs — merge ordering is a maintainer call. The bug hunt exited on dry_streak with no findings and no ruled-out candidates.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
The Content-Length half is right: decide once on the JS thread, count the bytes, fail with ERR_HTTP_CONTENT_LENGTH_MISMATCH and close instead of leaving surplus on a keep-alive connection. Keep all of that.
The Transfer-Encoding half needs to change. "A caller Transfer-Encoding is never forwarded" throws away the one case that is legitimate (gzip, chunked on a body the caller compressed, or plain chunked copied from an inbound request), and silently relabels the others. Accept it only when fetch can honor it:
Streaming body, not an upgrade
- Parse the caller value as a comma-separated list of codings (OWS-trimmed, case-insensitive).
- Final coding is
chunked(chunked,Chunked,gzip, chunked): put the caller's value on the wire as written, in place ofCHUNKED_ENCODED_HEADER, and chunk-frame the body as now. AContent-Lengthnext to it is not sent and not counted, which is what the PR already does. - Anything else (
identity,gzip,chunked, gzip, empty, a list we cannot parse): reject the fetch before a byte is written. We chunk-frame every streaming body ourselves, so these can only describe framing we are not producing; on 1.4.3transfer-encoding: gzipgoes out asTransfer-Encoding: gzipover a chunk-framed body. undici rejects every callerTransfer-Encoding(UND_ERR_INVALID_ARG, "invalid transfer-encoding header"), so rejecting this subset breaks nothing that runs on Node. Do not drop-and-continue here: a body the caller gzipped would arrive as plainchunkedwith nothing saying it is compressed.
Body fetch can measure (string, Blob, file): unchanged, the header is dropped and the computed Content-Length wins, as today.
HTTP/2 / upgrade: unchanged.
Where: make this the same decision as content_length_for_framing, on the JS thread in FetchTasklet where content_length is computed, and carry the result to the HTTP thread in http_request_body::Stream next to content_length (the validated caller value, or "chunked, ours"). build_request's CHUNKED_ENCODED_HEADER arm then emits what Stream says instead of continue-ing unconditionally, so the two threads still cannot disagree, which is the point of this PR.
Test table in fetch.test.ts (the block that currently expects ["transfer-encoding: chunked"] for every row):
chunked,Chunked,gzip, chunked→ that exact value on the wire, body7\r\nnr1-nr2\r\n0\r\n\r\nchunked+Content-Length: 7,chunked+Content-Length: 2→transfer-encoding: chunkedonly, nocontent-lengthline, fetch resolvesidentity,gzip,chunked, gzip,""→ fetch rejects, the origin sees no request bytes, and the next fetch to the origin works- same rows for an async generator and
Readable.from(), as theContent-Lengthcases already do
Then fix the title and the description's "A caller Transfer-Encoding is never forwarded" to match.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Two more, same theme: invalid framing headers are an error, not something to paper over.
Throw for an invalid Content-Length too. Right now content_length_for_framing returning None for abc, -1, +5, 0x5, 5.0, 5, 7, 99999999999999999999 makes the request go out as Transfer-Encoding: chunked as if the caller had said nothing. The caller said something wrong; tell them. With a streaming body, a Content-Length that is not ASCII digits fitting a u64 rejects the fetch up front (a TypeError, before the stream is pulled or a byte is written), the same as the unusable Transfer-Encoding values above. undici throws UND_ERR_INVALID_ARG "invalid content-length header" for these. Only an absent Content-Length means "chunk it for me".
So the JS-thread decision has three outcomes, not two:
- valid
Content-Length, noTransfer-Encoding→ framed by that count - no
Content-Length, or aTransfer-Encodingwhose final coding ischunked→ chunked - anything else → throw
Whitespace: 5 is fine to accept as 5 (OWS around a field value is not part of it); it then hits the mismatch path like any other wrong count.
Pass the integer to the other thread, nothing else. Stream::content_length: Option<u64> is the right shape. Make it the only thing the HTTP thread uses for framing: build_request should not read content-length or transfer-encoding out of the raw caller headers at all for a streaming body (the original_content_length capture and the CHUNKED_ENCODED_HEADER arm both go away for that case). It prints the u64 it was handed, or the chunked header it was handed. Same for the accepted Transfer-Encoding value: validated on the JS thread, handed over in Stream, emitted as given. Nothing on the HTTP thread parses or re-validates a framing header.
Test rows to flip: every invalid Content-Length currently expected to produce ["transfer-encoding: chunked"] should expect a rejected fetch, no request bytes at the origin, and a working next fetch.
373ed96 to
6c1b483
Compare
### Problem - Over HTTP/3, an aborted `fetch()` upload ends with FIN, not RESET_STREAM. The server takes the truncated body for a complete request: `await req.text()` resolves `"hello "`. HTTP/1.1 reports an abort. - With a declared `content-length`, the server's lsquic answers the short FIN with CONNECTION_CLOSE (`H3_MESSAGE_ERROR`). The next stream upload on the pooled session rejects with `TypeError: HTTP3StreamReset fetching ...` (20 to 26 of 30 rounds). - Cause: `ClientSession::fail` (`src/http/h3_client/ClientSession.rs:206`) calls `Stream::abort()` (`lsquic_stream_close`) before `detach()`. The close queues FIN and sets `STREAM_U_WRITE_DONE`, so the `reset()` in `detach()` sends nothing. ### Fix - `fail()` and `retry_or_fail()` call `detach_with(stream, true)`, the teardown behind `detach()`. They no longer close the lsquic stream first. An unfinished send half ends with `RESET_STREAM(H3_REQUEST_CANCELLED)`. A finished one closes as before. `Stream::abort()` is gone. - `us_quic_stream_reset` always ends with `lsquic_stream_close`. `lsquic_stream_maybe_reset` shuts only the read half when no RESET_STREAM is due. - Correct per RFC 9114 section 4.1.1: RESET_STREAM cancels a request. The HTTP/2 client sends RST_STREAM(CANCEL) here. - Verified: `test/js/web/fetch/fetch-http3-client.test.ts` (2 new tests, the released binary fails both) and five other HTTP/3 suites. Self-reviewed: 10 concerns, 7 addressed (Notes). ### Background - The fetch HTTP/3 client pools one `ClientSession` (one QUIC connection) per origin. Each request is a `Stream` bound to one lsquic stream. - FIN ends the send half of a QUIC stream: "this is the whole message". RESET_STREAM aborts it. - lsquic checks a declared `content-length` when it reads the FIN (`verify_cl_on_fin`). A mismatch closes the whole connection. - `fail()` is the client's error path (user abort, malformed response, decode error). `detach()` tears down every request. <details><summary>Notes</summary> **lsquic calls.** `lsquic_stream_close` runs `stream_shutdown_write`. That sets `STREAM_U_WRITE_DONE` and queues FIN when the headers are out. `lsquic_stream_maybe_reset` sends RESET_STREAM only when none of `STREAM_RST_SENT`, `STREAM_FIN_SENT`, `STREAM_U_WRITE_DONE`, `SMQF_SEND_RST` is set. With `do_close`, its other branch runs `stream_shutdown_read` only. That does not set `STREAM_U_WRITE_DONE`, does not make the connection tickable, and does not call `maybe_schedule_call_on_close`. So `us_quic_stream_reset` now calls `lsquic_stream_maybe_reset(.., 0)` and then `lsquic_stream_close`. In the reset branch this equals the old `do_close=1` (`stream_reset` ends in `lsquic_stream_close`). In the other branch it is a full close. Neither call runs a stream callback synchronously. **Shape of the Rust change.** `detach()` is now a wrapper for `detach_with(stream, false)`. `detach_with` holds the old body of `detach()`, and its reset condition is `abort || !request_body_done`. `fail()` and `retry_or_fail()` call `detach_with(stream, true)` where they called `Stream::abort()` and then `detach()`. One function unbinds, resets and frees, so no caller can leave an unbound entry in `pending` (which `on_stream_open` would bind to a new lsquic stream), and lsquic sees one close per stream. **Wire, before** (`BUN_DEBUG_lsquic=1`): ``` stream: lsquic_stream_close() called stream: have to create a separate STREAM frame with FIN flag in it conn: generated 4-byte STOP_SENDING frame (stream id: 4, error code: 256) conn: abort error: is_app: 1; error code: 270; error str: number of bytes in DATA frames of stream 4 is 6, while content-length specified of 50 [h3_client] stream_close status=0 delivered=false [h3_client] conn_close status=8 '' ``` **Wire, after:** ``` stream: reset, error code 268 event: generated RESET_STREAM: stream 4; offset 75; error code 268 event: RX RST_STREAM frame: error code 268, stream 4, offset: 75 ``` The next upload runs on the same session. **Probes by hand (debug ASAN build).** - A Buffer body aborted while the handler waits. Before (64 MB): the handler saw `complete 40702` and `complete 103029` (bytes) in 2 of 3 rounds. After (16 MB): `aborted AbortError` in 3 of 3 rounds. String and Buffer bodies take the same `abort()` path. - A server that responds without reading the body (Bun.serve sends STOP_SENDING), with and without an abort during the response: same result before and after. Each request stream gets its lsquic `on_close` on both endpoints at once, not at connection teardown. `fetchH3Internals.liveCounts()` ends at `streams: 0`. - The original 30-round repro (canary `09bb54630`: 4 to 10 of 30 ok): 30 of 30 ok after the fix. **Self-review.** The review traced the lsquic calls state by state, the Rust lifecycle, and the tests. It found no defect. Addressed: - `abort()` had a contract that only a comment stated (`detach()` must follow). The fold into `detach_with` removes the contract and the repeated unbind block. - Each new comment is one line. - Test 2 says why the follow-up has a stream body. - Test 2 asserts that the server saw `content-length: 50`. - The matcher follows the repo idiom (`rejects.toMatchObject`). Not changed: - No test separates the `do_close` change in `us_quic_stream_reset` from the old call. It matters only when lsquic already reset the send half (the peer's STOP_SENDING came first), or when FIN is already out. The difference (prompt `on_close`, connection made tickable) shows in the lsquic log only, not in public behavior. - `us_quic_on_reset(how=0)` still closes with `lsquic_stream_close` when the *peer* resets the stream in the middle of an upload. That is the same FIN from a different trigger. The peer has abandoned the stream by then, an lsquic server discards the rest with no `content-length` check, and no test with Bun.serve as the peer can observe it. The function is shared with the server role, and #40598 changes it. - The STOP_SENDING that goes out with the reset still carries `H3_NO_ERROR`, as before. **Also not changed.** - `retry_or_fail` still refuses to re-send a stream body. #42579 and #41564 change its rules. This PR removes the trigger, not that rule. - lsquic treats a `content-length` mismatch as a connection error. RFC 9114 section 4.1.2 asks for a stream error, and section 8 lets an endpoint escalate. A conforming client no longer hits it on abort. **Related PRs.** #32678 describes the same lsquic guard and fixes only the malformed-response paths with a new `fail_malformed`. A user abort still sends FIN there. #40598 is the server-side mirror (a response body that fails mid-body). Both give `us_quic_stream_reset` an error-code parameter. The textual conflict with this PR is in that one function. #42024 is where CI first showed this failure (build 115613). Whichever of the two lands second should run test 2 again, because it sends a caller `content-length` with a stream body. **Tests.** The server route reports how the request body ended and which `content-length` it saw. The client aborts only after the server has read the first chunk, so the order is fixed. Test 2 also holds a response open on the pooled session across the abort and expects its full body afterwards. Its headers are in, so the client cannot move it to another session. The test releases it only after the server has seen the upload end: released sooner, it completes before the server closes anything, and the check passes on the released binary. On its own this check fails the released binary 5 of 5 runs (`Received: "first;"`). Released binary: `"body": "complete"` and `HTTP3StreamReset`, 7 of 7 runs. Debug build: 15 of 15 runs pass. The whole file also passes with the CI runner's ASAN settings (`BUN_JSC_validateExceptionChecks=1`, `BUN_DESTRUCT_VM_ON_EXIT=1`, `detect_leaks=1`). **Suites run on the debug ASAN build:** `fetch-http3-client` (58 pass), `serve-http3` (63), `serve-protocols` (20), `fetch-http3-adversarial` (27), `fetch-http3-cold-post` (2), `fetch-http3-syscall-fault` (7). </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 0 · 4 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 2 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http3-client.test.ts" bun test v1.4.3 (09bb546) test/js/web/fetch/fetch-http3-client.test.ts: (pass) fetch protocol: http3 > GET text [27.33ms] (pass) fetch protocol: http3 > 'h3' alias [8.13ms] (pass) fetch protocol: http3 > POST echo with headers [18.70ms] (pass) fetch protocol: http3 > JSON + query string [12.31ms] (pass) fetch protocol: http3 > route params [9.91ms] (pass) fetch protocol: http3 > large response body (multi-packet) [12.93ms] (pass) fetch protocol: http3 > large request body [19.44ms] (pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [20.28ms] (pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [11.51ms] (pass) fetch protocol: http3 > status 200 [11.75ms] (pass) fetch protocol: http3 > status 204 [5.12ms] (pass) fetch protocol: http3 > status 404 [4.75ms] (pass) fetch protocol: http3 > status 500 [4.58ms] (pass) fetch protocol: http3 > HEAD has no body [11.08ms] (pass) fetch protocol: http3 > the respo ... (truncated) release without fix: all passed bun test v1.4.3-canary.1 (78ee3e0) test/js/web/fetch/fetch-http3-client.test.ts: (pass) fetch protocol: http3 > GET text [3.69ms] (pass) fetch protocol: http3 > 'h3' alias [0.99ms] (pass) fetch protocol: http3 > POST echo with headers [0.54ms] (pass) fetch protocol: http3 > JSON + query string [0.55ms] (pass) fetch protocol: http3 > route params [0.66ms] (pass) fetch protocol: http3 > large response body (multi-packet) [1.70ms] (pass) fetch protocol: http3 > large request body [27.82ms] (pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [1.67ms] (pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [2.35ms] (pass) fetch protocol: http3 > status 200 [0.74ms] (pass) fetch protocol: http3 > status 204 [0.60ms] (pass) fetch protocol: http3 > status 404 [0.53ms] (pass) fetch protocol: http3 > status 500 [0.63ms] (pass) fetch protocol: http3 > HEAD has no body [0.30ms] (pass) fetch protocol: http3 > the response to a 204 has a null body [0.77ms] (pass) fetch protocol: http3 > the response to a 304 has a null body [0.17ms] (pass) fetch protocol: http3 > the response to a HEAD request ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```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/web/fetch/fetch-http3-client.test.ts" bun test v1.4.3 (09bb546) test/js/web/fetch/fetch-http3-client.test.ts: (pass) fetch protocol: http3 > GET text [31.94ms] (pass) fetch protocol: http3 > 'h3' alias [7.56ms] (pass) fetch protocol: http3 > POST echo with headers [17.77ms] (pass) fetch protocol: http3 > JSON + query string [11.34ms] (pass) fetch protocol: http3 > route params [10.21ms] (pass) fetch protocol: http3 > large response body (multi-packet) [13.25ms] (pass) fetch protocol: http3 > large request body [19.02ms] (pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [19.79ms] (pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [11.73ms] (pass) fetch protocol: http3 > status 200 [10.20ms] (pass) fetch protocol: http3 > status 204 [4.15ms] (pass) fetch protocol: http3 > status 404 [3.56ms] (pass) fetch protocol: http3 > status 500 [3.22ms] (pass) fetch protocol: http3 > HEAD has no body [9.56ms] (pass) fetch protocol: http3 > the respo ... (truncated) release with fix: all passed $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 904ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/9] cc obj/packages/bun-usockets/src/quic.c.o [2/9] gen generated_host_exports.rs generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited [2/9] cargo bun_runtime → libbun_runtime.a �[1m�[92m Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core) �[1m�[92m Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno) �[1m�[92m Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr) �[1m�[92m Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys) �[1m�[92m Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety) �[1m�[92m Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64) �[1m�[92m Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys) �[1m�[92m Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys) �[1m�[92m Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd) �[1m�[92m Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths) �[1m�[92m Compiling�[0m bun_collections v0.0.0 (/work ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` packages/bun-usockets/src/quic.c | 8 +- src/http/h3_client/ClientSession.rs | 13 ++-- src/http/h3_client/Stream.rs | 8 +- test/js/web/fetch/fetch-http3-client.test.ts | 108 +++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) ``` </details> **gate history** · 2 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests packages/bun-usockets/src/quic.c 4 4 18 src/http/h3_client/ClientSession.rs 4 7 18 src/http/h3_client/Stream.rs 4 6 18 test/js/web/fetch/fetch-http3-client.test.ts 4 8 18 ``` </details> <!-- robobun:evidence:end -->
### Problem - Over HTTP/3, an aborted `fetch()` upload ends with FIN, not RESET_STREAM. The server takes the truncated body for a complete request: `await req.text()` resolves `"hello "`. HTTP/1.1 reports an abort. - With a declared `content-length`, the server's lsquic answers the short FIN with CONNECTION_CLOSE (`H3_MESSAGE_ERROR`). The next stream upload on the pooled session rejects with `TypeError: HTTP3StreamReset fetching ...` (20 to 26 of 30 rounds). - Cause: `ClientSession::fail` (`src/http/h3_client/ClientSession.rs:206`) calls `Stream::abort()` (`lsquic_stream_close`) before `detach()`. The close queues FIN and sets `STREAM_U_WRITE_DONE`, so the `reset()` in `detach()` sends nothing. ### Fix - `fail()` and `retry_or_fail()` call `detach_with(stream, true)`, the teardown behind `detach()`. They no longer close the lsquic stream first. An unfinished send half ends with `RESET_STREAM(H3_REQUEST_CANCELLED)`. A finished one closes as before. `Stream::abort()` is gone. - `us_quic_stream_reset` always ends with `lsquic_stream_close`. `lsquic_stream_maybe_reset` shuts only the read half when no RESET_STREAM is due. - Correct per RFC 9114 section 4.1.1: RESET_STREAM cancels a request. The HTTP/2 client sends RST_STREAM(CANCEL) here. - Verified: `test/js/web/fetch/fetch-http3-client.test.ts` (2 new tests, the released binary fails both) and five other HTTP/3 suites. Self-reviewed: 10 concerns, 7 addressed (Notes). ### Background - The fetch HTTP/3 client pools one `ClientSession` (one QUIC connection) per origin. Each request is a `Stream` bound to one lsquic stream. - FIN ends the send half of a QUIC stream: "this is the whole message". RESET_STREAM aborts it. - lsquic checks a declared `content-length` when it reads the FIN (`verify_cl_on_fin`). A mismatch closes the whole connection. - `fail()` is the client's error path (user abort, malformed response, decode error). `detach()` tears down every request. <details><summary>Notes</summary> **lsquic calls.** `lsquic_stream_close` runs `stream_shutdown_write`. That sets `STREAM_U_WRITE_DONE` and queues FIN when the headers are out. `lsquic_stream_maybe_reset` sends RESET_STREAM only when none of `STREAM_RST_SENT`, `STREAM_FIN_SENT`, `STREAM_U_WRITE_DONE`, `SMQF_SEND_RST` is set. With `do_close`, its other branch runs `stream_shutdown_read` only. That does not set `STREAM_U_WRITE_DONE`, does not make the connection tickable, and does not call `maybe_schedule_call_on_close`. So `us_quic_stream_reset` now calls `lsquic_stream_maybe_reset(.., 0)` and then `lsquic_stream_close`. In the reset branch this equals the old `do_close=1` (`stream_reset` ends in `lsquic_stream_close`). In the other branch it is a full close. Neither call runs a stream callback synchronously. **Shape of the Rust change.** `detach()` is now a wrapper for `detach_with(stream, false)`. `detach_with` holds the old body of `detach()`, and its reset condition is `abort || !request_body_done`. `fail()` and `retry_or_fail()` call `detach_with(stream, true)` where they called `Stream::abort()` and then `detach()`. One function unbinds, resets and frees, so no caller can leave an unbound entry in `pending` (which `on_stream_open` would bind to a new lsquic stream), and lsquic sees one close per stream. **Wire, before** (`BUN_DEBUG_lsquic=1`): ``` stream: lsquic_stream_close() called stream: have to create a separate STREAM frame with FIN flag in it conn: generated 4-byte STOP_SENDING frame (stream id: 4, error code: 256) conn: abort error: is_app: 1; error code: 270; error str: number of bytes in DATA frames of stream 4 is 6, while content-length specified of 50 [h3_client] stream_close status=0 delivered=false [h3_client] conn_close status=8 '' ``` **Wire, after:** ``` stream: reset, error code 268 event: generated RESET_STREAM: stream 4; offset 75; error code 268 event: RX RST_STREAM frame: error code 268, stream 4, offset: 75 ``` The next upload runs on the same session. **Probes by hand (debug ASAN build).** - A Buffer body aborted while the handler waits. Before (64 MB): the handler saw `complete 40702` and `complete 103029` (bytes) in 2 of 3 rounds. After (16 MB): `aborted AbortError` in 3 of 3 rounds. String and Buffer bodies take the same `abort()` path. - A server that responds without reading the body (Bun.serve sends STOP_SENDING), with and without an abort during the response: same result before and after. Each request stream gets its lsquic `on_close` on both endpoints at once, not at connection teardown. `fetchH3Internals.liveCounts()` ends at `streams: 0`. - The original 30-round repro (canary `09bb54630`: 4 to 10 of 30 ok): 30 of 30 ok after the fix. **Self-review.** The review traced the lsquic calls state by state, the Rust lifecycle, and the tests. It found no defect. Addressed: - `abort()` had a contract that only a comment stated (`detach()` must follow). The fold into `detach_with` removes the contract and the repeated unbind block. - Each new comment is one line. - Test 2 says why the follow-up has a stream body. - Test 2 asserts that the server saw `content-length: 50`. - The matcher follows the repo idiom (`rejects.toMatchObject`). Not changed: - No test separates the `do_close` change in `us_quic_stream_reset` from the old call. It matters only when lsquic already reset the send half (the peer's STOP_SENDING came first), or when FIN is already out. The difference (prompt `on_close`, connection made tickable) shows in the lsquic log only, not in public behavior. - `us_quic_on_reset(how=0)` still closes with `lsquic_stream_close` when the *peer* resets the stream in the middle of an upload. That is the same FIN from a different trigger. The peer has abandoned the stream by then, an lsquic server discards the rest with no `content-length` check, and no test with Bun.serve as the peer can observe it. The function is shared with the server role, and oven-sh#40598 changes it. - The STOP_SENDING that goes out with the reset still carries `H3_NO_ERROR`, as before. **Also not changed.** - `retry_or_fail` still refuses to re-send a stream body. oven-sh#42579 and oven-sh#41564 change its rules. This PR removes the trigger, not that rule. - lsquic treats a `content-length` mismatch as a connection error. RFC 9114 section 4.1.2 asks for a stream error, and section 8 lets an endpoint escalate. A conforming client no longer hits it on abort. **Related PRs.** oven-sh#32678 describes the same lsquic guard and fixes only the malformed-response paths with a new `fail_malformed`. A user abort still sends FIN there. oven-sh#40598 is the server-side mirror (a response body that fails mid-body). Both give `us_quic_stream_reset` an error-code parameter. The textual conflict with this PR is in that one function. oven-sh#42024 is where CI first showed this failure (build 115613). Whichever of the two lands second should run test 2 again, because it sends a caller `content-length` with a stream body. **Tests.** The server route reports how the request body ended and which `content-length` it saw. The client aborts only after the server has read the first chunk, so the order is fixed. Test 2 also holds a response open on the pooled session across the abort and expects its full body afterwards. Its headers are in, so the client cannot move it to another session. The test releases it only after the server has seen the upload end: released sooner, it completes before the server closes anything, and the check passes on the released binary. On its own this check fails the released binary 5 of 5 runs (`Received: "first;"`). Released binary: `"body": "complete"` and `HTTP3StreamReset`, 7 of 7 runs. Debug build: 15 of 15 runs pass. The whole file also passes with the CI runner's ASAN settings (`BUN_JSC_validateExceptionChecks=1`, `BUN_DESTRUCT_VM_ON_EXIT=1`, `detect_leaks=1`). **Suites run on the debug ASAN build:** `fetch-http3-client` (58 pass), `serve-http3` (63), `serve-protocols` (20), `fetch-http3-adversarial` (27), `fetch-http3-cold-post` (2), `fetch-http3-syscall-fault` (7). </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 0 · 4 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 2 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http3-client.test.ts" bun test v1.4.3 (09bb546) test/js/web/fetch/fetch-http3-client.test.ts: (pass) fetch protocol: http3 > GET text [27.33ms] (pass) fetch protocol: http3 > 'h3' alias [8.13ms] (pass) fetch protocol: http3 > POST echo with headers [18.70ms] (pass) fetch protocol: http3 > JSON + query string [12.31ms] (pass) fetch protocol: http3 > route params [9.91ms] (pass) fetch protocol: http3 > large response body (multi-packet) [12.93ms] (pass) fetch protocol: http3 > large request body [19.44ms] (pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [20.28ms] (pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [11.51ms] (pass) fetch protocol: http3 > status 200 [11.75ms] (pass) fetch protocol: http3 > status 204 [5.12ms] (pass) fetch protocol: http3 > status 404 [4.75ms] (pass) fetch protocol: http3 > status 500 [4.58ms] (pass) fetch protocol: http3 > HEAD has no body [11.08ms] (pass) fetch protocol: http3 > the respo ... (truncated) release without fix: all passed bun test v1.4.3-canary.1 (78ee3e0) test/js/web/fetch/fetch-http3-client.test.ts: (pass) fetch protocol: http3 > GET text [3.69ms] (pass) fetch protocol: http3 > 'h3' alias [0.99ms] (pass) fetch protocol: http3 > POST echo with headers [0.54ms] (pass) fetch protocol: http3 > JSON + query string [0.55ms] (pass) fetch protocol: http3 > route params [0.66ms] (pass) fetch protocol: http3 > large response body (multi-packet) [1.70ms] (pass) fetch protocol: http3 > large request body [27.82ms] (pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [1.67ms] (pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [2.35ms] (pass) fetch protocol: http3 > status 200 [0.74ms] (pass) fetch protocol: http3 > status 204 [0.60ms] (pass) fetch protocol: http3 > status 404 [0.53ms] (pass) fetch protocol: http3 > status 500 [0.63ms] (pass) fetch protocol: http3 > HEAD has no body [0.30ms] (pass) fetch protocol: http3 > the response to a 204 has a null body [0.77ms] (pass) fetch protocol: http3 > the response to a 304 has a null body [0.17ms] (pass) fetch protocol: http3 > the response to a HEAD request ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```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/web/fetch/fetch-http3-client.test.ts" bun test v1.4.3 (09bb546) test/js/web/fetch/fetch-http3-client.test.ts: (pass) fetch protocol: http3 > GET text [31.94ms] (pass) fetch protocol: http3 > 'h3' alias [7.56ms] (pass) fetch protocol: http3 > POST echo with headers [17.77ms] (pass) fetch protocol: http3 > JSON + query string [11.34ms] (pass) fetch protocol: http3 > route params [10.21ms] (pass) fetch protocol: http3 > large response body (multi-packet) [13.25ms] (pass) fetch protocol: http3 > large request body [19.02ms] (pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [19.79ms] (pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [11.73ms] (pass) fetch protocol: http3 > status 200 [10.20ms] (pass) fetch protocol: http3 > status 204 [4.15ms] (pass) fetch protocol: http3 > status 404 [3.56ms] (pass) fetch protocol: http3 > status 500 [3.22ms] (pass) fetch protocol: http3 > HEAD has no body [9.56ms] (pass) fetch protocol: http3 > the respo ... (truncated) release with fix: all passed $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 904ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/9] cc obj/packages/bun-usockets/src/quic.c.o [2/9] gen generated_host_exports.rs generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited [2/9] cargo bun_runtime → libbun_runtime.a �[1m�[92m Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core) �[1m�[92m Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno) �[1m�[92m Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr) �[1m�[92m Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys) �[1m�[92m Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety) �[1m�[92m Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64) �[1m�[92m Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys) �[1m�[92m Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys) �[1m�[92m Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd) �[1m�[92m Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths) �[1m�[92m Compiling�[0m bun_collections v0.0.0 (/work ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` packages/bun-usockets/src/quic.c | 8 +- src/http/h3_client/ClientSession.rs | 13 ++-- src/http/h3_client/Stream.rs | 8 +- test/js/web/fetch/fetch-http3-client.test.ts | 108 +++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) ``` </details> **gate history** · 2 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests packages/bun-usockets/src/quic.c 4 4 18 src/http/h3_client/ClientSession.rs 4 7 18 src/http/h3_client/Stream.rs 4 6 18 test/js/web/fetch/fetch-http3-client.test.ts 4 8 18 ``` </details> <!-- robobun:evidence:end -->
c286ab5 to
4dbfead
Compare
|
Removed the header parsers this PR had added (4dbfead, b035772). It now reuses what the tree already has:
Two behavior differences follow from sharing the recipient's rule, both visible in the test table:
|
…it can be honored Decide the framing of an unknown-length request body once, from a parsed value, and count the body bytes against a Content-Length the caller declared. A value no body can match is dropped and the body goes out chunked. A body that does not match a declared length fails the fetch with ERR_HTTP_CONTENT_LENGTH_MISMATCH instead of writing a corrupt frame.
…ly when it can honor it
… Transfer-Encoding that ends in chunked A streaming request body is sent under the framing its headers describe, or not at all. The decision is made once, before the request is queued: - a Content-Length that is not a u64 in ASCII digits rejects the fetch with ERR_HTTP_INVALID_HEADER_VALUE instead of falling back to chunked - a Transfer-Encoding whose final coding is chunked is forwarded as written; any other value rejects the same way - the HTTP thread no longer reads Content-Length or Transfer-Encoding out of the caller's headers for a streaming body, it prints what the Stream carries
…ch fails the request The JS thread counts a streaming body against its declared Content-Length but cannot know whether a followed redirect already dropped that body. It now reports a mismatch to the HTTP thread (WriteMessageType::LengthMismatch), which fails the request with RequestBodyLengthMismatch only while the stream is still the body being sent. After a 303 (or a 301/302 POST to GET) the report is a no-op and the fetch resolves with the final response.
…P/2 303 follow-up
…s for stream body framing - one strict Content-Length parser (bun_http_types::parse_content_length_strict), shared by the response side, the HTTP/2 server and the stream body framing - one Transfer-Encoding rule (fold_transfer_encoding over HeaderValueIterator and Encoding::from_token), shared by the response side and the stream body framing - Stream embeds StreamFraming, and the length mismatch message goes through the existing h1/h2/h3 stream body entry points
b035772 to
71e616a
Compare
Problem
fetch()cannot measure a stream body. It sent a callerContent-Lengthverbatim with the body raw behind it.Content-Length: 2over a 7-byte stream left 5 surplus bytes on a keep-alive connection, which the peer parses as the next request.abc,-1,5, 7reached the wire too, andTransfer-Encoding: gzipwent out over a chunk-framed body.build_request(src/http/lib.rs) andFetchTasklet::skip_chunked_framingeach decided the framing from the raw headers.Fix
fetch_impl, before hop 1 (StreamFraming::for_body). AContent-Lengthof ASCII digits that fitsu64frames the raw bytes. ATransfer-Encodingending inchunkedis forwarded as written. Neither means chunked. Anything else rejects withERR_HTTP_INVALID_HEADER_VALUE(aTypeError) before a byte is written.http_request_body::Stream.build_requestconsumes both caller headers on every hop and prints only what theStreamcarries.ERR_HTTP_CONTENT_LENGTH_MISMATCHonly while that body is being sent. After a 303 dropped it, the report is a no-op.describeintest/js/web/fetch/fetch.test.ts(10 of 24 fail on 1.4.3), h2 and h3 cases, redirect, keep-alive, upgrade suites.Background
ThreadSafeStreamBufferand chunk-frames it. The HTTP thread writes the head and flushes that buffer verbatim.FetchHeadersjoins duplicate rows (5, 7).Notes
ReadableStreambodynr1-nr2, raw TCP origin):content-length: 2→Content-Length: 2+nr1-nr2(5 surplus bytes). Same for an async generator andReadable.from().+5,0x5,5.0,-1,0,abc,99999999999999999999→ sent verbatim, body raw.Content-Length: 50→ peer waits.h.append('content-length','5'); h.append('content-length','7')→Content-Length: 5, 7, body raw.transfer-encoding: gzip(oridentity) → forwarded verbatim while the body was chunk-framed.Transfer-Encoding:chunked,Chunked,gzip, chunkedgo out exactly as written over7\r\nnr1-nr2\r\n0\r\n\r\n. AContent-Lengthnext to one is neither sent nor counted (it must still be a count).identity,gzip,chunked, gzip,chunked, chunked,gzip; q=1, chunked,br2, chunked,""reject. The rule is the one the response side already applies to aTransfer-Encodingit receives (fold_transfer_encoding:HeaderValueIterator+Encoding::from_token): a comma-separated, OWS-trimmed, case-insensitive list of codings bun knows, wherechunkedmust be the final coding (RFC 9112 section 6.1). Like a recipient, it skips empty list elements, sogzip,, chunkedcounts asgzip, chunked.Content-Length:7is honored as before.2,0,5(OWS is not part of the value) reject withERR_HTTP_CONTENT_LENGTH_MISMATCHbefore a body byte is written.50rejects when the stream ends.abc,+5,0x5,5.0,-1,"",5, 7,7, 7,99999999999999999999reject up front.build_requestconsumes the caller'sContent-LengthandTransfer-Encodingon every hop, streaming or not, so a 303 follow-up goes out as a bareGETfor every accepted form (Content-Length: 7,gzip, chunked, no header), over HTTP/1.1 and HTTP/2. Validation runs once, infetch_impl, before hop 1. 307/308 still reject with "Request body is a ReadableStream and cannot be replayed for this redirect".LengthMismatchon the same queue asData/End. The HTTP thread fails the request only iforiginal_request_bodyis still thatStream(HTTP/1.1close_and_fail, h2detach_with_failure, h3fail), so the order against the redirect is total and there is no flag to race on.request_stagebecomesDoneonly after anEndmessage with the buffer flushed, and the JS thread sendsEndonly when the count equals the declared length (a short stream reports a mismatch instead, a surplus chunk never enters the buffer). So an early 3xx with unsent declared bytes closes the hop-1 socket, asis_request_fully_sent()already required.Content-Length. An upgrade request keeps its header handling (its "body" is the tunnel, its bytes are not counted and its headers are not validated).node:http's client writes over its own socket and does not use this path.Transfer-Encodingthat does not end inchunkedon a stream body now rejects on those protocols too. Before, their encoders dropped the header silently. An acceptedTransfer-Encodingis still dropped by those encoders, and a validContent-Lengthis forwarded and counted.content-lengthandContent-Length) now rejects, becauseFetchHeadersjoins them into7, 7.Transfer-Encoding: gzipon a stream body rejects on every protocol.gzip, chunkedis forwarded as written.Content-Length: abcrejects even next toTransfer-Encoding: chunked. A stream that never ends underContent-Length: 0rejects withERR_HTTP_CONTENT_LENGTH_MISMATCHat its first chunk.ERR_HTTP_INVALID_HEADER_VALUE(aTypeError, with Node's messageInvalid value "abc" for header "Content-Length") andERR_HTTP_CONTENT_LENGTH_MISMATCHboth exist already (node:httpandNodeHTTPResponsethrow them for the same conditions), so no new code is added. undici's names areUND_ERR_INVALID_ARGandUND_ERR_REQ_CONTENT_LENGTH_MISMATCH.1*DIGITContent-Lengthparser existed twice (inline inhandle_response_metadataand privately in the HTTP/2 server,h2/connection.rs). It is now one function,bun_http_types::parse_content_length_strict, used by both and byStreamFraming. TheTransfer-Encodingloop moved out ofhandle_response_metadataintofold_transfer_encodingso the request side shares it.StreamFraming::transfer_encoding(embedded inStream) is aStringPointerinto the client'sheader_buf(the same buffer every other caller header is printed from), so nothing is copied or allocated for it and theStreamstays bitwise-copyable across threads.CL: 5, chunks 3+4) writes only the first 3 bytes, then fails. An infinitepull()source withCL: 10stops after 4 pulls and itscancel()runs. An async generator'sfinallyruns. A peer that replies after exactlyCLbytes gets a resolved fetch and never sees the surplus.CL: 0with an empty stream succeeds. After a mismatch the next fetch to the same origin uses a fresh connection.Bun.servehandler forwardingreq.headersandreq.bodyupstream): the inboundContent-Lengthis honored and a 256 KB body streams through with the exact count. An inbound chunked request is forwarded with itsTransfer-Encoding: chunked.test/js/web/fetch/fetch.test.ts"bounds memory when a handler forwards req.body to a stalled target" measures 260 to 265 MB against a 256 MB debug threshold in my container, with and without this diff (main'ssrc/gives 260 MB). It is not related.is_node_http_clientis always false on main now (fetch_impl::<true>has no caller), so the flag and the lenient bodylessContent-Lengthbranch it guards inbuild_requestare dead.no test proof · iteration 8 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch.test.ts, test/js/web/fetch/fetch-http3-client.test.ts