http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3) - #29831
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:
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 |
|
Updated 5:35 PM PT - Jun 25th, 2026
❌ @Jarred-Sumner, your commit a1e272f has 3 failures in
The baseline build contains instructions not available on Static scan violations
|
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
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
|
Status: Jarred has taken the branch over for landing. Current head ff2fbf3 ("fetch: pause after the first delivered body chunk instead of at a 64K threshold") on top of his f9a8a7f squash/rewrite; CI build #64365 running. He's iterating actively (64352/64354 canceled by his subsequent pushes), so I'm not pushing to avoid stepping on maintainer work. #64354 triage (Jarred's previous head 94e6dfa, canceled)29 annotations, dominated by Prior state through 85f77c9 (before the force-push)CI #64213 (85f77c9) final triage: 1 hard-failed job (darwin-14-aarch64), one error annotation — What was in the diff (85f77c9 vs main): ~725 lines across 19 |
|
@robobun fix the comments |
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. |
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)
894-910:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftSeed backpressure with the bytes that were already buffered before streaming started.
When this path returns
.owned, Line 907 hands the prebuffered body chunk to the newByteStream, but the HTTP thread never gets told that those bytes are now outstanding against the JS reader. Their laterdidDraincallbacks will subtract from0, so h1/h2/h3 can still admit another full high-water window on top of whatever was already sitting inscheduled_response_bufferwhenres.bodywas first touched. Please enqueue that initial buffered length when arming body-consumption tracking.🤖 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 894 - 910, The prebuffered bytes in this.scheduled_response_buffer.list are returned as an owned list (via scheduled_response_buffer.toManaged) but the HTTP thread’s backpressure accounting isn't informed, so enqueue the initial buffered length into the body-consumption tracking before returning .owned; in FetchTasklet.zig update the code path that returns .owned (referencing scheduled_response_buffer, toManaged, and scheduled_response_buffer.list) to call the existing enqueue/arm function used for body-consumption (or directly increment the outstanding-bytes counter / invoke didDrain bookkeeping) with scheduled_response_buffer.items.len (or the byte length equivalent) so the HTTP layers are aware those bytes are outstanding to the JS reader.
🤖 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/bun.js/webcore/fetch/FetchTasklet.zig`:
- Around line 875-881: The current logic arms body_consumption_tracked in the
path that constructs a drain handler for Body.toReadableStream and Body.tee,
buttee() clones share a single transport receive window so per-branch reads can
incorrectly report shared bytes consumed; update the logic in FetchTasklet.zig
to detect when the Body was produced via Body.tee (or when a shared tee source
exists) and either disable/skip calling scheduleResponseBodyConsumed/from arming
signal_store.body_consumption_tracked for tee clones, or move the consumption
accounting to the shared tee source so only the slowest branch advances
transport credit; specifically adjust the code paths that install the
drain_handler and the call sites of scheduleResponseBodyConsumed and
signal_store.body_consumption_tracked.store(true, .release) (also apply same
change to the analogous block around lines 952-956) so that per-clone drain
events do not prematurely reopen transport reads for a shared tee source.
In `@test/js/web/fetch/fetch-backpressure.test.ts`:
- Around line 535-541: The settle() helper returns after only one unchanged
100ms sample; update it so it waits for two consecutive identical samples 100ms
apart before returning to match the comment and ensure wantRead(false) actually
took effect. Specifically, in settle() use received() and track two successive
samples (e.g., prev and curr) taken with a 100ms await between them and only
break/return when curr === prev; reference the settle() function and the
received() counter so the loop enforces two stable samples.
---
Outside diff comments:
In `@src/bun.js/webcore/fetch/FetchTasklet.zig`:
- Around line 894-910: The prebuffered bytes in
this.scheduled_response_buffer.list are returned as an owned list (via
scheduled_response_buffer.toManaged) but the HTTP thread’s backpressure
accounting isn't informed, so enqueue the initial buffered length into the
body-consumption tracking before returning .owned; in FetchTasklet.zig update
the code path that returns .owned (referencing scheduled_response_buffer,
toManaged, and scheduled_response_buffer.list) to call the existing enqueue/arm
function used for body-consumption (or directly increment the outstanding-bytes
counter / invoke didDrain bookkeeping) with scheduled_response_buffer.items.len
(or the byte length equivalent) so the HTTP layers are aware those bytes are
outstanding to the JS reader.
🪄 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: c20a4ad0-251d-4e28-99e8-08f373953653
📒 Files selected for processing (13)
src/bun.js/webcore/ReadableStream.zigsrc/bun.js/webcore/fetch/FetchTasklet.zigsrc/deps/uws/quic/Stream.zigsrc/http.zigsrc/http/H3Client.zigsrc/http/HTTPThread.zigsrc/http/InternalState.zigsrc/http/h3_client/ClientContext.zigsrc/http/h3_client/ClientSession.zigsrc/http/h3_client/Stream.zigsrc/http/h3_client/callbacks.zigsrc/js/internal-for-testing.tstest/js/web/fetch/fetch-backpressure.test.ts
|
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. |
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.
…enum The HTTP-thread callback now reads one AtomicU8 (AutoPause / BufferAll / Ignore) instead of a plain bool + an AtomicBool. Ignore being atomic also closes the pre-existing cross-thread read of ignore_data without the FetchTasklet mutex.
Polls RSS until stable for 60ms rather than measuring after a fixed sleep, so slow hosts wait until the server has actually flooded instead of vacuously passing.
…tion on_start_buffering had the same load-mode/store-paused race that ignore_remaining_response_body guarded — a concurrent callback past its mode load can pause after the JS-thread swap, leaving arrayBuffer() with no signal_drained to self-heal. set_body_receive_mode now does the unconditional store(false)+schedule for both transitions.
The transport now reads `signals.is_receive_paused()` (mode == Paused); the callback transitions AutoPause→Paused via CAS, JS transitions Paused→AutoPause (signal_drained) or →BufferAll/Ignore via CAS/swap. A single atomic removes the load-mode/store-paused interleaving the review flagged — the CAS either commits the pause or fails because JS already moved to a terminal mode.
A queued resume that arrives while body_receive_mode is still Paused (e.g. on_start_streaming flushing body_out_str before the reader pulls) should not briefly un-pause the socket. The keep-alive release path un-pauses unconditionally inline since the socket is leaving this request regardless of mode.
Only fetch wires body_receive_mode (via Store::to_with_backpressure()); install manifest fetches and S3 non-download ops use Store::to() which leaves it None, so they keep the once-at-end callback their handlers expect. Restores the report_progress gate in _from_multiple_packets and adds body_receive_mode.is_some() as the fetch opt-in.
When the ByteStream has no lock, no pipe, and no buffer_action after a chunk is delivered, there is nothing to apply backpressure against. Flip to BufferAll so the body completes and the FetchTasklet releases its Strong on the stream source. Fixes native-source-onclose-leak (releaseLock without cancel left the transport paused forever and the source wrapper pinned). Also: - skip maybe_pause_receive for upgraded (101) connections; the duplex protocol needs continuous reads (fetch.upgrade.test.ts). - call on_start_buffering from ValueBufferer before materialising the stream so pipe consumers (HTMLRewriter) never pause. - unref poll_ref once the fetch() promise resolves and re-ref when a body consumer attaches, so an unread Response no longer keeps the process alive indefinitely (unref-fixture-2.ts). - restore the JSC_BORROW doc on NewSource.global_this. - fetch-backpressure.test.ts: destroy tracked h2 sockets on dispose (Http2SecureServer.close() otherwise waits forever); tighten the keep-alive cancel and sequential-drain assertions.
…bound on macOS/ASAN on_writable's RequestStage::Body arm re-armed the idle timer that maybe_pause_receive had cleared, so a full-duplex request with a stalled reader would spuriously time out once the upload finished. The matching guard already exists on the on_data arms. fetch-backpressure.test.ts: subprocess RSS captures JIT warmup and lazy dylib faulting (boringssl for TLS) which on macOS and ASAN can exceed the 16 MiB body. The in-process 'server stops writing' tests prove the h1 pause without that noise, so skip the RSS bound there.
…GiB body for server-stops ByteStream::on_data's pending path now signals drained even when the delivered chunk overflowed the reader's view, so a releaseLock() in the await continuation still gets one more on_body_received to run drop_backpressure_if_unobserved with the stream unlocked. fetch-backpressure.test.ts: the subprocess RSS delta captures JIT warmup, mimalloc chunking and TLS dylib faulting, which exceeds the 16 MiB body on many CI lanes (pre-existing on c050a12); drop that assertion and rely on the in-process 'server stops writing' tests as the proof of the h1 pause. Raise that test's body to 1 GiB since some CI hosts have loopback tcp_rmem+tcp_wmem autotune approaching 256 MiB.
…y_receive_mode gate in single-packet chunked -2 arm on_start_streaming_http_response_body_callback drains scheduled_response_buffer into the new ByteStream but left body_receive_mode at Paused, so the resume it schedules was a no-op on the HTTP thread. A reader whose first on_pull found the drained buffer smaller than its view returned Pending without ever signalling, leaving the socket paused past a server FIN (fetch.stream 'can handle socket close' on darwin). Also mirror the body_receive_mode.is_some() clause in _from_single_packet's chunked -2 arm for consistency with the _from_multiple_packets sibling.
A FIN/RST that arrives while the read poll is paused is only observable through the poll, so resume_receive returning early on is_closed_or_has_error() stranded the request: no on_end/on_close ever fired and reader.read() stayed pending forever with the idle timeout disabled. Only a closed socket skips the re-arm now; an errored or shut-down one is resumed so the normal readable/EOF/error dispatch delivers the failure. Also stop the POSIX repeat-recv loop when on_data paused the socket, matching the Windows arm, so us_socket_pause from a data handler is honored within the same readable event instead of pulling the rest of the kernel buffer.
dd47c91 to
a1e272f
Compare
resume_receive_by_http_id can reach fail_all() via flush() returning Err, which tears down the session when its internal ref_scope guard drops; the subsequent drain_response_body_by_http_id would then dereference freed memory. The trigger is currently unreachable (us_socket_write clamps negatives to 0 on both TCP and SSL), so this is defense-in-depth matching the single-call sibling dispatch loops.
…complete The receive-backpressure change in #29831 made fetch pause the transport after the first body chunk unless a consumer calls on_start_buffering. Bun.write's file-destination Locked arm registered on_receive_value and overwrote locked.task without ever signalling the producer, so the transport parked at ~128 KB and resolve() never fired. The awaited promise hung forever and no file was created. Call on_start_buffering with the original producer task before replacing locked.task, mirroring ValueBufferer.
…complete The receive-backpressure change in #29831 made fetch pause the transport after the first body chunk unless a consumer calls on_start_buffering. Bun.write's file-destination Locked arm registered on_receive_value and overwrote locked.task without ever signalling the producer, so the transport parked at ~128 KB and resolve() never fired. The awaited promise hung forever and no file was created. Call on_start_buffering with the original producer task before replacing locked.task, mirroring ValueBufferer.
Stop the
fetch()HTTP thread from buffering an entire response body when nothing on the JS side is reading it. After the first body chunk is delivered to JS, the transport pauses until a consumer pulls; buffered consumers (.arrayBuffer()/.text()/etc.) opt out so they still receive the body in one go.Fixes #28035.
Mechanism
One
AtomicU8and one callback:signals::Store.body_receive_mode: AtomicU8carries aBodyReceiveMode(AutoPause/Paused/BufferAll/Ignore).FetchTasklet::callback(HTTP thread) CASesAutoPause -> Pausedwhenever it has just appended body bytes toscheduled_response_bufferwithhas_more; the transport readssignals.is_receive_paused()after the callback returns.on_start_buffering(.arrayBuffer()/.text()/etc.) sets the terminalBufferAll;ignore_remaining_response_bodysetsIgnore.NewSource<ByteStream>.drain_handler—ByteStream::signal_drained()fires it whenever bytes leave for a JS consumer (on_pullempties the buffer,drain(), the pipe / buffer-action arms ofon_data, the pending-reader fulfilment path). The handler CASesPaused -> AutoPauseand, if it won, enqueues a resume to the HTTP thread.on_start_streamingalso CASesPaused -> AutoPausewhen it drainsscheduled_response_bufferinto a newly-created stream, so the resume it schedules actually un-pauses the socket.A chunk delivered to a stream that has no reader lock, no pipe, and no buffer action flips to
BufferAllso an abandoned body still completes and the tasklet's Strong on the stream source is released.poll_refis unref'd once the fetch() promise resolves and re-ref'd when a body consumer attaches, so an unread Response does not keep the event loop alive while paused.handle_response_body{,_chunked}_from_multiple_packetsnow flush to the progress callback on every read instead of waiting foris_done || ResponseBodyStreaming. The libdeflate one-shot path was only ever taken by_from_single_packet(whole body arrived with the headers), which is unchanged.Per transport
maybe_pause_receiveaftercallback.run()callssocket.pause_stream()and clears the idle timeout; the keep-alive release path resumes before pooling so a paused socket is never handed back. Upgraded (101) connections and proxy tunnels are excluded.replenish_windowskips the per-streamWINDOW_UPDATEwhileis_receive_paused(); the connection-level window stays receipt-based so siblings aren't starved.resume_receive_by_http_idre-runsreplenish_window.on_stream_datacallswant_read(false)afterdeliver()when paused so lsquic withholdsMAX_STREAM_DATA; resume re-enables it.The
schedule_response_body_drainqueue is folded into the newschedule_receive_resumequeue (the resume handler doesresume_receive()thendrain_response_body()).Signals::is_empty()had no callers and is removed.Not affected
bun installand S3 wireSignalswithoutbody_receive_modeand use their own callbacks, so the pause is never set for them._from_multiple_packetsflushing per-read is what they already got viaresponse_body_streaming = true.Tests
test/js/web/fetch/fetch-backpressure.test.ts— 40 tests:getReader()/pipeTo()/for await/ unread Response — for each ofh1,h1-chunked,h1-gzip,h1-tls,h2,h3: a subprocess reads the first chunk, sleeps until RSS settles, then drains to the full body. RSS is reported but not bounded (JIT warmup + mimalloc chunks + TLS dylib faulting exceed the 16 MiB body on several CI hosts); these prove the resume path does not deadlock.h1/h1-chunked/h1-tls) — a 1 GiB in-process server'swrite()count plateaus while the reader is stalled, then reaches the full body once it drains. This is the platform-independent proof of the h1 pause.res.{arrayBuffer,bytes,text,blob}(),res.body.{bytes,text,blob,json}(), andBun.readableStreamTo{ArrayBuffer,Bytes,Text,Blob,Array}(res.body)each receive the full body without intervention.reader.cancel()then the abandoned body drains and a second keep-alive fetch completes;res.body.tee()both branches drain; two sequential keep-alive responses each drain fully.Also fixes an unrelated
is_final_chunk = truein the_from_single_packetchunked needs-more-data arm (it was finalising the decompressor mid-stream).