http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3)#29831
http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3)#29831robobun wants to merge 46 commits into
Conversation
A res.body.getReader() that never reads currently lets one origin fill
memory at ~8 MiB/RTT: ClientSession.replenishWindow() credited the
per-stream receive window as soon as DATA was handed to
handleResponseBody, regardless of whether the ByteStream reader ever
drained it.
Now the per-stream credit is gated on bytes the JS reader has actually
pulled out of the ByteStream, the same as undici and Go net/http2:
- ByteStream reports delivered bytes via a new drain_handler on the
ReadableStream Source (set by FetchTasklet alongside cancel_handler).
- FetchTasklet posts {async_http_id, bytes} to the HTTP thread via a
new scheduleResponseBodyConsumed queue (sibling to the existing
scheduleResponseBodyDrain wake).
- ClientSession.consumeResponseBodyByHttpId bumps Stream.consumed_bytes
and runs replenishWindow.
- replenishWindow credits min(consumed_bytes, unacked_bytes) for
streaming consumers so a decompressed body can't over-credit; the
clamp also absorbs any bytes credited receipt-based before
getReader() switched the mode. Buffering consumers (await res.text()
etc., response_body_streaming false) keep receipt-based crediting so
the transfer isn't throttled.
- Connection-level credit stays receipt-based so one stalled stream
doesn't starve siblings.
Tests (raw-frame server, serial describe): a reader that never calls
read() sees zero per-stream WINDOW_UPDATE while the conn-level one
still fires; a reader that drains past the 8 MiB threshold sees the
per-stream credit released. DATA is PING-paced in 384 KiB batches to
avoid a pre-existing uSockets-TLS quirk where a mid-on_data
socket.write can drop buffered ssl_read_input (openssl.c:1815).
|
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:
WalkthroughThis PR introduces per-reader byte-consumption tracking and backpressure control across HTTP/2, HTTP/1.1, and HTTP/3 fetch response streams. When JS code drains fetch response bodies via ReadableStream, consumed bytes are reported back to the HTTP transport layer, enabling sockets and protocol streams to pause and resume reads based on buffered body thresholds. ChangesFetch Response Stream Backpressure
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
fetch-http2-client.test.ts is one big describe.concurrent of ~60 debug-build subprocess spawns; on constrained ASAN hosts several of those already sit near the 5s default timeout. Adding the two 12 MiB backpressure cases to the same file makes the gate see unrelated concurrent timeouts as "failures with the fix applied". Give them their own file with a self-contained raw-frame server (only the HEADERS/PING/WINDOW_UPDATE parsing these tests need) so the gate runs exactly two serial tests: one that fails without the fix, one sanity case that passes either way.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/bun.js/webcore/fetch/FetchTasklet.zig (1)
922-930:⚠️ Potential issue | 🟠 MajorDon't clear the drain callback before the final chunk is consumed.
clearStreamCancelHandler()now removesdrain_handler, butonBodyReceived(...has_more = false)calls this before handing the last buffered bytes toByteStream.onData(). That means the tail bytes can never report actual JS consumption, so the HTTP/2 stream stays under-credited at end-of-body.🔧 Suggested fix
fn clearStreamCancelHandler(this: *FetchTasklet) void { if (this.readable_stream_ref.get(this.global_this)) |readable| { if (readable.ptr == .Bytes) { const source = readable.ptr.Bytes.parent(); source.cancel_handler = null; source.cancel_ctx = null; - source.drain_handler = null; - source.drain_ctx = null; } } }Keep drain teardown separate so the final buffered bytes can still trigger
didDrain().🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/bun.js/webcore/fetch/FetchTasklet.zig` around lines 922 - 930, The function clearStreamCancelHandler currently clears drain_handler and drain_ctx prematurely, which prevents the final chunk from reporting consumption correctly. Modify clearStreamCancelHandler in FetchTasklet to only clear cancel_handler and cancel_ctx, keeping drain_handler and drain_ctx intact. Implement a separate method to clear drain callbacks after the last buffered bytes are fully consumed, ensuring proper crediting of the HTTP/2 stream in onBodyReceived.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/http/h2_client/ClientSession.zig`:
- Around line 355-363: The current consumeResponseBodyByHttpId updates
per-stream stream.consumed_bytes with JS-delivered bytes and then calls
replenishWindow(), which lets stale decompressed-byte surplus drive
WINDOW_UPDATEs; modify consumeResponseBodyByHttpId (and corresponding logic used
by replenishWindow) to limit carried consumed balance to at most the
wire-received bytes for that stream or track a separate wire_consumed counter:
either add a stream.wire_consumed field and increment that by the actual
received DATA/WIRE bytes (and use that in replenishWindow/when deciding
WINDOW_UPDATEs) or clamp stream.consumed_bytes to the lesser of the existing
consumed balance and the total_wire_received - already_windowed_wire_consumed
before calling replenishWindow; update any places that read
stream.consumed_bytes (e.g., replenishWindow, the WINDOW_UPDATE computation) to
use the new wire-aware value so decompressing reads cannot overspend window
credits.
In `@test/js/web/fetch/fetch-http2-backpressure.test.ts`:
- Around line 9-13: Move the test cases from fetch-http2-backpressure.test.ts
into the existing fetch-http2-client.test.ts file instead of a new file; if they
must run serially, place those tests outside the existing describe.concurrent
block (e.g. put them in a top-level describe or a separate describe that is not
.concurrent) so they don’t run concurrently with the heavy TLS/debug-build
tests; ensure you import any helpers used by the tests into
fetch-http2-client.test.ts and remove the standalone
fetch-http2-backpressure.test.ts to keep tests co-located with fetch HTTP/2
specs.
---
Outside diff comments:
In `@src/bun.js/webcore/fetch/FetchTasklet.zig`:
- Around line 922-930: The function clearStreamCancelHandler currently clears
drain_handler and drain_ctx prematurely, which prevents the final chunk from
reporting consumption correctly. Modify clearStreamCancelHandler in FetchTasklet
to only clear cancel_handler and cancel_ctx, keeping drain_handler and drain_ctx
intact. Implement a separate method to clear drain callbacks after the last
buffered bytes are fully consumed, ensuring proper crediting of the HTTP/2
stream in onBodyReceived.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: fdef9a93-7969-48fe-95b0-f2e4605bfa45
📒 Files selected for processing (8)
src/bun.js/webcore/Body.zigsrc/bun.js/webcore/ByteStream.zigsrc/bun.js/webcore/ReadableStream.zigsrc/bun.js/webcore/fetch/FetchTasklet.zigsrc/http/HTTPThread.zigsrc/http/h2_client/ClientSession.zigsrc/http/h2_client/Stream.zigtest/js/web/fetch/fetch-http2-backpressure.test.ts
The reported count is post-decompression; without the clamp a compression surplus from one read would be banked and then spent crediting later wire DATA the reader hasn't touched, weakening the per-stream backpressure bound for compressed bodies by roughly the compression ratio. Capping at unacked_bytes (wire-received minus already-credited) at accumulation time keeps the bound at ratio * initial_window regardless of how large the first read was.
|
Status: ready to merge — the diff is green; the only hard CI failure is an external npm-registry breakage. Head df93330 (review-nit fix on top of the 574eedb merge with main, through #31875). Fixes #28035. All 25 review threads resolved; claude-bot's latest review found one test-diagnostics nit, addressed in df93330 ( CI triage (#61096 on current head df93330, same as #61091 on 574eedb): the only hard-failing test is Track record: build 58083 (head bf070b0) passed fully — this PR has a green full-CI run on record. #57647 was 280-green (one unrelated darwin FSEvents segfault), #56864 282-green (darwin agent infra). The one permitted CI retrigger was spent on that infra failure. What's in the diff (vs main): ~715 lines across 19 Verification: 12/12 A maintainer can merge over the @angular/cli registry breakage (or land a main-side fix for |
|
@robobun fix the comments |
…_body_streaming response_body_streaming is set by three paths that never report drained bytes via scheduleResponseBodyConsumed, so gating per-stream WINDOW_UPDATE on it wedged those streams at the 16 MiB initial window: - ignoreRemainingResponseBody() (reader.cancel() / Response GC) flips the signal so the HTTP thread stops buffering, then nulls the ByteStream drain_handler. consumed_bytes stays 0 forever; the abandoned body never drains and the h2 stream slot leaks for the session's lifetime. - S3HttpDownloadStreamingTask sets the signal at construction but has no ByteStream; downloads > 16 MiB over h2 would hang. - Body.Value.tee() (res.clone() on a Locked fetch body) calls onStartStreaming but constructed its ByteStream.Source without drain_handler, unlike toReadableStream. Add a separate body_consumption_tracked signal that is armed only in onStartStreamingHTTPResponseBodyCallback (where both toReadableStream and tee wire drain_handler immediately after) and disarmed in ignoreRemainingResponseBody. replenishWindow now gates on that signal: consumers without a consumption reporter stay receipt-based and keep draining. ignoreRemainingResponseBody also posts a sentinel scheduleResponseBodyConsumed(id, maxInt(u32)) so a server that has already exhausted the window is unstuck on the HTTP thread's next wake (replenishWindow's only other trigger is inbound DATA, which a stalled server sends none of). Body.Value.tee() now wires cancel_handler/drain_handler the same way toReadableStream does, so res.clone() over h2 reports consumption. New test: reader.cancel() after getReader() still produces a per-stream WINDOW_UPDATE once 12 MiB arrives.
…ow-update-backpressure
drain() is the drainInternalBuffer callback reached from handle.drain() in ReadableStreamInternals.ts when onStart returns .ready — which is the path taken for fetch response bodies whose onStartStreaming returned .owned (bytes that arrived between header delivery and getReader()). Those bytes were moved straight to JS via moveFromList without a didDrain() call, so scheduleResponseBodyConsumed never heard about them and unacked_bytes kept a permanent floor of up to threshold-1 (~8 MiB) for the lifetime of the stream, reducing the effective per-stream window by the same amount. onPull already reports bytes it serves from this.buffer; this covers the one remaining exit.
There was a problem hiding this comment.
All three of my earlier findings are addressed and I found nothing new on c28c8c0, but this reworks h2 per-stream flow control across the JS↔HTTP thread boundary with a new body_consumption_tracked signal — worth a human pass before merge.
Extended reasoning...
Overview
This PR changes how the experimental HTTP/2 fetch client credits the per-stream receive window: instead of sending WINDOW_UPDATE on receipt, it now gates per-stream credit on bytes actually drained by the JS ReadableStream reader. The plumbing spans ByteStream/ReadableStream.NewSource (new drain_handler), Body.Value (toReadableStream and tee() wiring), FetchTasklet (new onStreamConsumed callback + body_consumption_tracked signal management in onStartStreaming/ignoreRemainingResponseBody), HTTPThread (new scheduleResponseBodyConsumed queue + drain), Signals (new atomic), and ClientSession/Stream (new consumed_bytes counter + reworked replenishWindow). Three new wire-level tests cover stalled reader, draining reader, and reader.cancel() fallback.
Security risks
No new attack surface for untrusted input — the change governs outbound WINDOW_UPDATE based on local reader behaviour. The risk profile is the inverse: getting it wrong stalls or leaks streams. My earlier reviews found three such cases (S3/abandoned-body deadlock, res.clone() deadlock, pre-buffered drain() floor) and all three are now fixed; the body_consumption_tracked signal was introduced specifically so paths that don't report consumption (S3, ignored bodies) degrade to receipt-based instead of deadlocking.
Level of scrutiny
High. This is production-critical networking with cross-thread atomics, and the correctness depends on every path that flips body_consumption_tracked also keeping consumed_bytes fed (or disarming the signal). The fact that three real bugs surfaced across two review rounds before reaching the current state confirms it's subtle enough to warrant a maintainer's eyes on the final design — particularly the ignoreRemainingResponseBody saturate-then-disarm sequence and whether any other ByteStream delivery path was missed.
Other factors
CI on the latest commit shows a timeout in fetch-http2-client.test.ts on x64-asan, which the author has documented as a pre-existing shard-7 flake also seen on unrelated PRs; the new fetch-http2-backpressure.test.ts is not in that shard. Jarred is already engaged on the thread. All CodeRabbit and claude inline threads are resolved.
|
@robobun make this PR work for http/1 and http/3. Write extensive tests. Merge main. Make this good. |
…ow-update-backpressure
HTTP/1.1: track wire bytes handed to the JS reader vs. bytes it has drained. When the gap crosses 1 MiB, pause the socket read (us_socket_pause drops LIBUS_SOCKET_READABLE) so TCP rwnd backpressures the server; resume below 256 KiB or when the reader detaches. Skip the proxy-tunnel path (inner TLS needs the carrier readable) and never pause once the body is complete or a redirect is pending, since the next step releases the socket to the keep-alive pool. maybePauseReceive runs before progressUpdate because a final chunk makes onAsyncHTTPCallback destroy the ThreadlocalAsyncHTTP inline on the HTTP thread. HTTP/3: count bytes delivered via onStreamData; past the same high-water mark call lsquic_stream_wantread(0) so lsquic stops draining its receive buffer and withholds MAX_STREAM_DATA credit. Resume with wantread(1) once the reader catches up or detaches. Expose a process-wide body_bytes_received counter through fetchH3Internals.liveCounts() so the test can observe the cap from a subprocess (the h3 server and client can't share an lsquic engine). Both reuse the existing body_consumption_tracked signal and scheduleResponseBodyConsumed path introduced for HTTP/2. The HTTPThread drain loop now dispatches consume messages to the h1 socket handler, the h2 session, or the h3 context registry depending on where the async_http_id resolves. Rename fetch-http2-backpressure.test.ts to fetch-backpressure.test.ts and add three tests per protocol (stalled / draining / cancelled reader).
…ow-update-backpressure
uSockets' repeat-recv fast path (loop.c) keeps calling recv() in the same epoll tick while the buffer comes back full, without re-checking poll flags. A us_socket_pause() issued from an earlier on_data in that loop doesn't stop the next recv(), so the final body chunk can arrive with receive_paused already true. maybePauseReceive previously just incremented outstanding_body_bytes and returned in that branch, and sendProgressUpdateWithoutStageCheck then released a still-paused socket to the keep-alive pool. The next request that adopted it never received any data. Observed as a hang on iteration 2 of bun-serve-static.test.ts stress(access .body) under concurrency: res.body arms body_consumption_tracked and res.arrayBuffer() uses the buffer-action fast path, which is exactly the shape where the per-request accounting has to close out cleanly. maybePauseReceive now checks isDone() / is_redirect_pending before the already-paused early return and resumes if set; releaseSocket also resumes defensively so a close-delimited body or early server reply can't reach the pool paused either. Also: raise the h1 stalled / reader.cancel() pump caps from 16/8 MiB to 64 MiB — on the debian-13 CI lane loopback TCP autotuning let the whole old cap land in kernel buffers before the client's 1 MiB pause closed the window, so the server never saw a drain stall. Add a regression test for the keep-alive wedge, and update the stale h2-only doc comments on drain_handler / onStreamConsumedCallback.
For Transfer-Encoding: chunked the raw incoming_data.len includes per-chunk framing (hex size line + CRLFs) that's stripped before anything reaches JS. didDrain only reports post-decode body bytes, so the framing overhead accumulated as a permanent floor under outstanding_body_bytes; once that floor crossed receive_body_low_water any pause became permanent and a long-lived uncompressed chunked stream (SSE) would deadlock. Use the total_body_received delta in both .body and .body_chunk so the counted bytes match what JS can credit.
Each h1 pause/resume cycle costs ~17 ms (epoll_ctl/kevent + stall while the consume message round-trips). For the 4 GiB node-http-backpressure-max.test.ts draining transfer that was ~150-200 cycles ≈ 3-4 s on Linux, enough to push darwin-14-x64 past the 60 s timeout. At 4 MiB the HTTP thread can pull further ahead of a fast reader before pausing, so a tight read() loop sees ~45 cycles over 4 GiB and the overhead drops to ~8-13% (17-18 s vs 16.6 s baseline). A stalled reader now buffers up to 4 MiB per h1/h3 request before backpressure engages — still well under h2's 16 MiB local_initial_window_size. Tests adjusted: h1 stalled/cancel pump 8 MiB, draining 16 MiB; h3 stalled/cancel server serves 32 MiB with a settle bound of <10 MiB.
…ow-update-backpressure
…ixes - onData .body/.body_chunk: skip setTimeout(socket, 5) when receive_paused is already true. maybePauseReceive cleared the timeout on the false→true transition, but uSockets' repeat-recv can land more on_data calls in the same epoll tick after pauseStream(); re-arming the 5-minute timer there would time out a reader that intentionally stalls past it. - keep-alive regression test: bump body 4→8 MiB so it crosses the 4 MiB high_water and actually exercises the pause path. - draining-test comment: 'false→true edge' was backwards; those are the sites that clear receive_paused.
…-x64 fetch()'s response-body backpressure pauses the socket whenever the reader briefly falls behind; over the 4 GiB draining transfer that's ~50 pause/resume cycles (kevent change + a short stall while the consume report round-trips through the HTTP thread). On a loaded darwin-14-x64 mini that ~8-13% overhead pushed the total past 60s. The test is validating that node:http can write a 4 GiB buffer, not that the client drains it in any particular time; 120s gives room.
…ow-update-backpressure Resolve src/ restructure conflict: TestingAPIs for h3 (quicLiveCounts.bodyBytesReceived) and h1 (h1BackpressureCounts) moved to src/http_jsc/headers_jsc.zig alongside the existing H2/H3TestingAPIs, with the http.zig/H3Client.zig shims pointing there. internal-for-testing.ts $newZigFunction path updated to http/http.zig.
…28035) Two H1 tests: stalled reader behind an identity TransformStream and behind TextDecoderStream both pause the socket via maybePauseReceive, then resume on drain / cancel. The pipeTo loop waits on the TransformStream writable's readyPromise, so once the piped output stalls, onPull stops firing, didDrain stops crediting, and outstanding_body_bytes climbs past receive_body_high_water.
No callers today and Store.to() populates all-or-nothing, so no runtime effect; keeps the 'all optional pointers are null' invariant honest for the new field.
…ow-update-backpressure Conflict in src/http/http.zig with fe735f8 (#30376 idle-timer-on-open): setTimeout(socket, minutes) became setTimeout(socket) reading idle_timeout_seconds. Kept the receive_paused guards in .body/.body_chunk onData, updated consumeResponseBody's resume to the new signature, and switched maybePauseReceive's timer-clear from setTimeoutMinutes(0)+timeout(0) to setTimeout(0) which clears both the short-tick and long-minute timers.
…n-14-x64 response.body.getReader() now couples the socket read to JS consumption: the HTTP thread pauses the socket once delivered-but- not-yet-credited bytes reach 4 MiB, and the cross-thread credit lands on the next HTTP-thread loop tick. On loopback the counter can reach the threshold before the first credit even when the reader keeps up, so a 2 GiB transfer cycles ~150 pause/resume rounds. Each round is a loop wakeup + kqueue mod; overhead is ~10-20% in release. Fine everywhere except darwin-14-x64 where 30s was already marginal (passed #52915, timed out #53084). Matches the 120s bump on node-http-backpressure-max.test.ts for the same reason.
The function also clears drain_handler/drain_ctx (added in this PR); both ctx pointers reference the FetchTasklet and the UAF rationale applies equally. Doc comment now says so. The identically-named function in s3/client.zig is unrelated and still only clears cancel_handler, so its name stays.
|
This also fixes #22514 ("Out of memory while copying request body" panic in Verified locally on the HTTP/1.1 path with a firehose server + stalled reader: without this change RSS climbs ~1.3 GB/s and panics; with it the socket pauses at the high-water mark and RSS stays flat, then resumes and delivers intact data once the reader drains. |
…ow-update-backpressure
Main replaced the Zig runtime with the Rust port (.zig files are now
frozen porting references, not compiled). Resolved by porting the
backpressure feature to the Rust implementation:
- http/lib.rs: RECEIVE_BODY_{HIGH,LOW}_WATER, H1_SOCKET_{PAUSES,RESUMES},
maybe_pause_receive / consume_response_body, on_data body arms count
the body_out_str delta and skip idle-timer re-arm while paused,
defensive resume before keep-alive pool release
- http/HTTPThread.rs: queued_response_body_consumed + coalescing
schedule_response_body_consumed and drain dispatch to h1/h2/h3
- h2_client: Stream.consumed_bytes, consume_response_body_by_http_id,
replenish_window gates per-stream credit on min(consumed, unacked)
when body_consumption_tracked is armed
- h3_client: Stream.outstanding_body_bytes/read_paused, on_stream_data
counts + want_read(false) past high water, consume dispatch resumes
- webcore: ByteStream.did_drain on every JS-delivery path, Body
on_stream_consumed wiring in to_readable_stream and tee, FetchTasklet
arm/disarm + u32::MAX sentinel + clear_stream_handlers
- Signals/InternalState: body_consumption_tracked signal,
outstanding_body_bytes / receive_paused state
- uws_sys/quic/Stream.rs: want_read binding (us_quic_stream_want_read)
- http_jsc/headers_jsc.rs + dispatch_js2native.rs: h1BackpressureCounts
testing API, quicLiveCounts gains bodyBytesReceived
The .zig siblings are left at main's state per AGENTS.md (new behavior
goes only in .rs). Test updates: Bun.serve h3/h1 options renamed to
http3/http1 on main; node-http-backpressure.test.ts keeps main's
streamed-chunk rework with the 60s timeouts.
…6862; darwin aarch64 and linux rust builds passed)
There was a problem hiding this comment.
Automated review is now clean — all 23 prior inline findings resolved and nothing new on this pass. Given the scope (cross-thread flow-control wiring across h1/h2/h3, socket pause/resume around the keep-alive pool, plus the acknowledged h3 decompression-currency follow-up), this one's worth a human look before merge.
Extended reasoning...
Overview
This PR couples fetch() receive-side flow control to JS body consumption across HTTP/1.1, HTTP/2, and HTTP/3. It adds a did_drain hook to ByteStream, a new body_consumption_tracked signal, a coalescing JS→HTTP-thread consume queue in HTTPThread, and per-transport gating: h2 per-stream WINDOW_UPDATE clamped to min(consumed, unacked), h1 us_socket_pause/resume around 4 MiB / 1 MiB watermarks with idle-timer suspension and a defensive resume before keep-alive pool release, and h3 lsquic_stream_wantread toggling. ~715 lines across 19 .rs files plus a 808-line test file and two timeout bumps. The feature was originally written in Zig and re-implemented in Rust after main switched runtimes.
Security risks
None identified. No auth/crypto/permissions surface; the change is internal flow-control accounting. The new fetchInternals.h1BackpressureCounts() / fetchH3Internals.liveCounts().bodyBytesReceived are read-only process-wide counters exposed via bun:internal-for-testing.
Level of scrutiny
High. This is production-critical HTTP client code with several subtle invariants: cross-thread atomic signal ordering (arm/disarm + sentinel), uSockets repeat-recv interaction with pause_stream(), keep-alive pool hand-off of a possibly-paused socket, and byte-counting currency (post-dechunk/post-decompress vs. wire). The review history bears this out — eight distinct correctness issues were found and fixed across multiple rounds (S3/abandoned-body deadlock, tee() wiring, drain()/toBufferedValue floors, chunked-framing and Content-Encoding currency mismatches, idle-timer re-arm during repeat-recv, resume-counter symmetry on the close branch). All are resolved, but the density of edge cases argues for a maintainer pass on the final shape.
Other factors
- Jarred is already engaged (requested the h1/h3 extension and asked robobun to address comments) but hasn't signed off on the post-Rust-port revision.
- The author explicitly deferred one known issue to a follow-up (h3
outstanding_body_bytesis pre-decompression; the gzip-on-incompressible floor is theoretically reachable at multi-GiB over h3). - CI is currently blocked by darwin-x64 agent infra (build-cpp succeeded then
buildkite-agent ENOENTon artifact upload), not by this diff; a maintainer retrigger is needed regardless. - Test coverage is thorough (12 tests across protocols + keep-alive regression + two
pipeThroughtests for #28035), and adjacent suites are reported green.
…ow-update-backpressure # Conflicts: # src/http/InternalState.rs # src/runtime/webcore/ByteStream.rs
…ow-update-backpressure # Conflicts: # src/http/HTTPThread.rs # src/http/InternalState.rs
…ow-update-backpressure
…ow-update-backpressure
Couple the
fetch()client's receive-side flow control to the JS reader that's actually consumingresponse.body, so a stalledgetReader()backpressures the server instead of letting the HTTP thread buffer the full response in memory. Covers all three transports.Fixes #28035.
What changes
Shared plumbing.
ByteStreamgains adid_drain(n)hook that fires whenever bytes leave for JS (viaon_pull, the pending/pipe/buffer-action paths inon_data,drain()'s pre-buffered handoff, andto_buffered_value's fast path).FetchTaskletwires it toschedule_response_body_consumed(async_http_id, n)on the HTTP thread (coalescing per-id so a tightread()loop is one wakeup, not one per pull). A newbody_consumption_trackedsignal — distinct fromresponse_body_streaming, which is also set by S3 downloads and abandoned bodies that never report drainage — is armed inon_start_streaming_http_response_body_callbackand disarmed (with a saturatingu32::MAXsentinel consume) inignore_remaining_response_body/reader.cancel().HTTP/2 (
h2_client/ClientSession.rs,Stream.rs). Per-streamWINDOW_UPDATEis gated onmin(consumed_bytes, unacked_bytes)whenbody_consumption_trackedis set; the connection-level window stays receipt-based so sibling streams aren't starved. Decompressed byte counts are clamped to wire bytes so a compression surplus can't bank future credit.HTTP/1.1 (
http/lib.rs).on_data's body branches accumulateoutstanding_body_bytes(counted as thebody_out_strdelta — post-dechunk, post-decompress — so framing/encoding overhead can't accumulate as a floor); past a 4 MiB high-water mark the socket read is paused (us_socket_pause→ TCP rwnd shrinks) and the idle timer is cleared.consume_response_bodyresumes below 1 MiB or when the reader detaches. The proxy-tunnel path is excluded (inner TLS needs the carrier readable), the pause is skipped once the body is complete or a redirect is pending, and a defensive resume before keep-alive pool release keeps uSockets' repeat-recv fast path from handing the pool a paused socket. Process-wideH1_SOCKET_PAUSES/H1_SOCKET_RESUMEScounters are exposed viafetchInternals.h1BackpressureCounts()for the tests.HTTP/3 (
h3_client/callbacks.rs,ClientSession.rs).on_stream_datacounts delivered bytes; past the same high-water mark it callswant_read(false)(us_quic_stream_want_read) so lsquic stops draining its receive buffer and withholdsMAX_STREAM_DATAcredit.consume_response_body_by_http_idre-enables the read once the reader catches up. A process-widebody_bytes_receivedcounter is exposed throughfetchH3Internals.liveCounts()so the test can observe the cap from a subprocess.pipeThrough()(#28035)A reader stalled behind
res.body.pipeThrough(new TransformStream())is handled by the same mechanism with no additional changes: thepipeToloop waits on the TransformStream writable'sreadyPromise, so once the piped output stops being read,on_pullstops firing on the nativeByteStream,did_drainstops crediting, andoutstanding_body_bytesclimbs past the high-water mark. Previously this path letByteStream.buffergrow to the full response size (the OOM in #28035).reader.cancel()on the piped output propagates throughpipeToErrorsMustBePropagatedBackward→readableStreamCancel(res.body)→ignore_remaining_response_body, which disarmsbody_consumption_trackedand posts the sentinel consume so the paused socket resumes.Note: the full proxy scenario in the issue (
Bun.servereturningResponse(res.body.pipeThrough(t))to a slow client) has a second unbounded buffer on the send side —readStreamIntoSinkdoesn't checkHTTPServerWritablebackpressure — that this PR doesn't touch. That's a separate follow-up; the receive-side cap here is what keeps the SSE-client / stalled-reader shape bounded.Tests
test/js/web/fetch/fetch-backpressure.test.ts— three tests per protocol, plus twopipeThroughtests for #28035:getReader()without callingread(). H2: the raw-frame server sees no per-streamWINDOW_UPDATEfor the initial-window overshoot. H1:h1BackpressureCounts().pausesfires. H3:bodyBytesReceivedplateaus nearRECEIVE_BODY_HIGH_WATER.reader.cancel()disarmsbody_consumption_trackedand posts the sentinel, so the remaining body drains (H1 socket resumes, H2 falls back to receipt-based credit, H3 re-enableswant_read).pipeThrough(TransformStream)reader (H1) — pauses the socket, then drains through the transform to completion with matched pause/resume counts.pipeThrough(TextDecoderStream)reader (H1) — pauses the socket;reader.cancel()on the piped output propagates back and resumes it.res.body; res.arrayBuffer()across 480 pooled requests; the buffer-action fast path must keep pause/resume accounting exact so no pooled socket is left paused.All twelve pass with the change; without it the subprocess never observes a pause and the tests fail.
node-http-backpressure{,-max}.test.tstimeouts were raised (30→60s / 60→120s) because their 2 GiB fast-drain loops now cycle ~150 pause/resume rounds (~10-20% overhead in release), which had no headroom on the slow darwin-14-x64 runner. Adjacent suites green:fetch-http2-client.test.ts(58),fetch-http3-client.test.ts(49),bun-serve-static.test.ts(34).