Skip to content

http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3)#29831

Open
robobun wants to merge 46 commits into
mainfrom
farm/0a9cea98/h2-window-update-backpressure
Open

http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3)#29831
robobun wants to merge 46 commits into
mainfrom
farm/0a9cea98/h2-window-update-backpressure

Conversation

@robobun

@robobun robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Couple the fetch() client's receive-side flow control to the JS reader that's actually consuming response.body, so a stalled getReader() backpressures the server instead of letting the HTTP thread buffer the full response in memory. Covers all three transports.

Fixes #28035.

Note (May 2026): originally written against the Zig runtime; after main replaced it with the Rust port, the feature was re-implemented in the .rs sources as part of the 67640e5 merge. The .zig siblings are untouched (frozen porting references per AGENTS.md). Semantics are unchanged from what was reviewed; file names below use the current Rust paths.

What changes

Shared plumbing. ByteStream gains a did_drain(n) hook that fires whenever bytes leave for JS (via on_pull, the pending/pipe/buffer-action paths in on_data, drain()'s pre-buffered handoff, and to_buffered_value's fast path). FetchTasklet wires it to schedule_response_body_consumed(async_http_id, n) on the HTTP thread (coalescing per-id so a tight read() loop is one wakeup, not one per pull). A new body_consumption_tracked signal — distinct from response_body_streaming, which is also set by S3 downloads and abandoned bodies that never report drainage — is armed in on_start_streaming_http_response_body_callback and disarmed (with a saturating u32::MAX sentinel consume) in ignore_remaining_response_body / reader.cancel().

HTTP/2 (h2_client/ClientSession.rs, Stream.rs). Per-stream WINDOW_UPDATE is gated on min(consumed_bytes, unacked_bytes) when body_consumption_tracked is 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 accumulate outstanding_body_bytes (counted as the body_out_str delta — 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_body resumes 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-wide H1_SOCKET_PAUSES/H1_SOCKET_RESUMES counters are exposed via fetchInternals.h1BackpressureCounts() for the tests.

HTTP/3 (h3_client/callbacks.rs, ClientSession.rs). on_stream_data counts delivered bytes; past the same high-water mark it calls want_read(false) (us_quic_stream_want_read) so lsquic stops draining its receive buffer and withholds MAX_STREAM_DATA credit. consume_response_body_by_http_id re-enables the read once the reader catches up. A process-wide body_bytes_received counter is exposed through fetchH3Internals.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: the pipeTo loop waits on the TransformStream writable's readyPromise, so once the piped output stops being read, on_pull stops firing on the native ByteStream, did_drain stops crediting, and outstanding_body_bytes climbs past the high-water mark. Previously this path let ByteStream.buffer grow to the full response size (the OOM in #28035). reader.cancel() on the piped output propagates through pipeToErrorsMustBePropagatedBackwardreadableStreamCancel(res.body)ignore_remaining_response_body, which disarms body_consumption_tracked and posts the sentinel consume so the paused socket resumes.

Note: the full proxy scenario in the issue (Bun.serve returning Response(res.body.pipeThrough(t)) to a slow client) has a second unbounded buffer on the send side — readStreamIntoSink doesn't check HTTPServerWritable backpressure — 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 two pipeThrough tests for #28035:

  • stalled reader — a child process holds getReader() without calling read(). H2: the raw-frame server sees no per-stream WINDOW_UPDATE for the initial-window overshoot. H1: h1BackpressureCounts().pauses fires. H3: bodyBytesReceived plateaus near RECEIVE_BODY_HIGH_WATER.
  • draining reader — the same setup with the reader pulling as fast as it can; the full body arrives.
  • cancelled readerreader.cancel() disarms body_consumption_tracked and posts the sentinel, so the remaining body drains (H1 socket resumes, H2 falls back to receipt-based credit, H3 re-enables want_read).
  • stalled pipeThrough(TransformStream) reader (H1) — pauses the socket, then drains through the transform to completion with matched pause/resume counts.
  • stalled pipeThrough(TextDecoderStream) reader (H1) — pauses the socket; reader.cancel() on the piped output propagates back and resumes it.
  • keep-alive regressionres.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.ts timeouts 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).

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).
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This 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.

Changes

Fetch Response Stream Backpressure

Layer / File(s) Summary
Data Shape & Callback Wiring
src/bun.js/webcore/Body.zig, src/bun.js/webcore/ReadableStream.zig, src/bun.js/webcore/ByteStream.zig, src/http/Signals.zig
Added PendingValue.onStreamConsumed and NewSource drain_handler/drain_ctx fields; added internal ByteStream.didDrain helper and used it in multiple delivery paths; added Signals.body_consumption_tracked atomic flag.
Core Fetch Tasklet Wiring
src/bun.js/webcore/fetch/FetchTasklet.zig
Wired onStreamConsumedCallback into toBodyValue() for Body.Value.Locked; arm/disarm signal_store.body_consumption_tracked when streaming is enabled or ignored; clear Source drain callbacks in stream teardown.
HTTP Thread Queue & Posting
src/http/HTTPThread.zig
Added queued_response_body_consumed queue, ConsumeMessage type, scheduleResponseBodyConsumed(...) to coalesce/queue consumption deltas, and drainQueuedHTTPResponseBodyConsumed() invoked from drainEvents.
HTTP Core Receive Pausing
src/http.zig, src/http/InternalState.zig
Added receive_body_high_water/receive_body_low_water constants; InternalState.outstanding_body_bytes and InternalStateFlags.receive_paused; implemented maybePauseReceive and updated onData/consume logic to pause/resume socket reads based on outstanding buffered body bytes and body_consumption_tracked.
HTTP/2 Consumption Semantics
src/http/h2_client/Stream.zig, src/http/h2_client/ClientSession.zig
Renamed/clarified unacked_bytes semantics, added consumed_bytes to Stream, and added consumeResponseBodyByHttpId to advance per-stream consumption and trigger replenishWindow() under consumption-tracked semantics.
HTTP/3 QUIC Read Control
src/http/h3_client/Stream.zig, src/http/h3_client/ClientSession.zig, src/http/h3_client/ClientContext.zig, src/deps/uws/quic/Stream.zig, src/http/H3Client.zig, src/http/h3_client/callbacks.zig
H3 Stream: added outstanding_body_bytes and read_paused; callbacks increment outstanding bytes and global body_bytes_received; added consumeResponseBodyByHttpId at session/context levels to decrement and resume reads; added Stream.wantRead() wrapper binding to control lsquic read callbacks; exposed H3 metrics.
Byte-Delivery Notifications
src/bun.js/webcore/ByteStream.zig
Ensure drain notifications are emitted when bytes are delivered to JS (pipes, buffer actions, pending buffer copies, onPull, and drain() path), suppressing zero-length reports.
Testing & Metrics
test/js/web/fetch/fetch-backpressure.test.ts, src/js/internal-for-testing.ts
Added comprehensive tests exercising HTTP/2 per-stream WINDOW_UPDATE, HTTP/1.1 socket-read pausing, and HTTP/3 lsquic wantRead pausing; extended fetchH3Internals.liveCounts to include bodyBytesReceived.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and specifically describes the main change: coupling fetch() receive backpressure to JS body consumption across HTTP/1, HTTP/2, and HTTP/3 transports, which aligns with the comprehensive changes shown in the raw summary.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description comprehensively details the feature, implementation across all three HTTP transports, test coverage, and issue resolution.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. fetch().body piped through TransformStream does not propagate backpressure #28035 - fetch().body piped through TransformStream does not propagate backpressure; the PR's drain callbacks and per-stream WINDOW_UPDATE gating directly address this for HTTP/2 connections
  2. Bun.write writing a Response from a fetch leaks memory #10686 - Bun.write writing a Response from fetch leaks memory because the response body is fully buffered; gating HTTP/2 flow control on JS consumption would bound memory
  3. Memory leak when downloading file (both @google-cloud/storage and Bun's S3) #20487 - Large file downloads via fetch (GCS/S3) cause unbounded RSS growth; per-stream receive-window gating prevents the server from sending faster than JS consumes over HTTP/2

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #28035
Fixes #10686
Fixes #20487

🤖 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Don't clear the drain callback before the final chunk is consumed.

clearStreamCancelHandler() now removes drain_handler, but onBodyReceived(...has_more = false) calls this before handing the last buffered bytes to ByteStream.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

📥 Commits

Reviewing files that changed from the base of the PR and between 4d615e8 and dc050be.

📒 Files selected for processing (8)
  • src/bun.js/webcore/Body.zig
  • src/bun.js/webcore/ByteStream.zig
  • src/bun.js/webcore/ReadableStream.zig
  • src/bun.js/webcore/fetch/FetchTasklet.zig
  • src/http/HTTPThread.zig
  • src/http/h2_client/ClientSession.zig
  • src/http/h2_client/Stream.zig
  • test/js/web/fetch/fetch-http2-backpressure.test.ts

Comment thread src/http/h2_client/ClientSession.zig Outdated
Comment thread test/js/web/fetch/fetch-http2-backpressure.test.ts Outdated
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.
@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

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 (lineReader now drains child stderr and appends it to the EOF error at all 11 call sites, replacing the three hand-rolled h3 wrappers).

CI triage (#61096 on current head df93330, same as #61091 on 574eedb): the only hard-failing test is test/cli/install/bunx.test.ts › should handle package that requires node 24 (exit 3 instead of 0, identical on debian-13-x64-asan and both windows-2019 lanes). Root cause, proven by reproducing on a pristine main checkout with a stock pre-PR bun: the test runs bunx --bun @angular/cli@latest --help against the live registry; @angular/cli@22.0.0 (published 2026-06-03) requires node ^22.22.3 || ^24.15.0 || >=26.0.0, and Bun reports 24.3.0, so Angular's version gate process.exit(3)s. Main builds that show green only avoid it when agents resolve @latest from a warm bunx cache that still has 21.x. The fix belongs on main (pin the package version in the test or bump the reported node version). All other annotations in both builds are retried-and-passed flakes on tests this PR doesn't touch (serve-body-leak, spawn-pipe-leak, bun-install workspaces, update_interactive, hot.test). Every fetch/HTTP/backpressure suite is green on every lane, including the asan lane that runs the new tests.

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 src/**/*.rs files + internal-for-testing.ts + 3 test files — h1 maybe_pause_receive/consume_response_body (4 MiB/1 MiB watermarks, keep-alive-pool safety, idle-timer handling), coalescing HTTPThread consume queue, h2 per-stream WINDOW_UPDATE gated on min(consumed, unacked), h3 want_read(false) past high water, ByteStream.did_drain + Body.on_stream_consumed + FetchTasklet arm/disarm plumbing, and the fetchInternals/fetchH3Internals testing counters. .zig files untouched per AGENTS.md.

Verification: 12/12 fetch-backpressure.test.ts (h2/h1/h3 × stalled/draining/cancelled, 2× pipeThrough for #28035, keep-alive regression) locally on this head; fetch-http2-client 58/58, fetch-http3-client 49/49, bun-serve-static 34/34, node-http-backpressure{,-max} green on prior heads in CI.

A maintainer can merge over the @angular/cli registry breakage (or land a main-side fix for bunx.test.ts first) — it is independent of this PR.

Comment thread src/http/h2_client/ClientSession.zig Outdated
Comment thread src/runtime/webcore/Body.zig Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix the comments

robobun and others added 2 commits April 28, 2026 23:42
…_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.
Comment thread src/runtime/webcore/ByteStream.zig Outdated
robobun added 2 commits April 29, 2026 01:04
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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun make this PR work for http/1 and http/3. Write extensive tests. Merge main. Make this good.

robobun added 3 commits May 1, 2026 21:09
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).
@robobun robobun changed the title http: couple per-stream h2 WINDOW_UPDATE to JS body consumption http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3) May 2, 2026
Comment thread src/bun.js/webcore/fetch/FetchTasklet.zig Outdated
Comment thread src/http.zig Outdated
robobun added 2 commits May 2, 2026 02:11
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.
robobun added 2 commits May 2, 2026 11:26
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.
Comment thread src/http/http.zig Outdated
Comment thread test/js/web/fetch/fetch-backpressure.test.ts Outdated
Comment thread test/js/web/fetch/fetch-backpressure.test.ts Outdated
robobun and others added 5 commits May 2, 2026 12:35
…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.
Comment thread src/http/Signals.zig Outdated
robobun added 3 commits May 8, 2026 23:13
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.
Comment thread src/runtime/webcore/fetch/FetchTasklet.zig Outdated
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.
@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes #22514 ("Out of memory while copying request body" panic in ByteStream.onData) — same root cause, a stalled res.body.getReader() lets the HTTP client buffer the full response in ByteStream.buffer until allocation fails.

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.

robobun and others added 3 commits May 22, 2026 13:36
…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)

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_bytes is 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 ENOENT on artifact upload), not by this diff; a maintainer retrigger is needed regardless.
  • Test coverage is thorough (12 tests across protocols + keep-alive regression + two pipeThrough tests for #28035), and adjacent suites are reported green.

…ow-update-backpressure

# Conflicts:
#	src/http/InternalState.rs
#	src/runtime/webcore/ByteStream.rs
Comment thread src/http/lib.rs Outdated
Comment thread test/js/web/fetch/fetch-backpressure.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fetch().body piped through TransformStream does not propagate backpressure

3 participants