Repository navigation
Conversation
…raining the body When a fetch() Response is dropped without reading or cancelling its body, the Response's Weak finalizer (on_response_finalize) called ignore_remaining_response_body, which flips the receive mode to Ignore and resumes the paused transport so the HTTP thread reads-and-discards the rest of the body and returns the socket to the keep-alive pool. For the common "just read the headers" pattern this downloads 100% of every body at line rate (verified up to 1 GB) before pooling the socket. undici destroys the socket on GC instead, so the server stops at the kernel send window and a follow-up request opens a fresh connection. The finalizer now calls abort_task() before ignore_remaining_response_body, exactly as on_stream_cancelled_callback already does for reader.cancel()/ body.cancel() (#33231). abort_task() only touches atomics and the HTTP-thread shutdown queue, so it is safe from a JSC Weak finalizer during sweep; the tracker.did_cancel() call is a no-op without a debugger attached and reads only C++ inspector state with one.
|
Warning Review limit reached
Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 11:10 PM PT - Jul 25th, 2026
❌ @robobun, your commit d610112 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 35809That installs a local version of the PR into your bun-35809 --bun |
…loopback kernel buffers
Addresses review: some CI hosts have tcp_rmem[2]+tcp_wmem[2] approaching
256 MiB, so a 64 MiB body can be fully written into kernel buffers before
socket.write() returns false and the server races to settle('drained')
before the client's close arrives. 1 GiB matches the neighboring test in
this file. Bytes are now counted per-connection and sampled before the
second fetch so the assertion measures only the abandoned connection.
There was a problem hiding this comment.
Both earlier rounds of feedback are addressed and I don't see anything further, but this is a user-visible change to fetch keep-alive semantics (GC'd Response now closes the socket instead of draining-and-pooling it) inside a JSC Weak finalizer, so a maintainer should sign off.
What was reviewed:
has_live_promiserefactor preserves the exact old branch conditions (None / Some(empty) → abort+ignore, Some(live) → keep loading); only the addedabort_task()is new.abort_task()from the finalizer: atomic swap +tracker.did_cancel(early-returns onid == 0) +schedule_shutdown; matches the shape already used byon_stream_cancelled_callback, andignore_remaining_response_bodyalready skips resume/streaming whenabortedis set.- Test: 1 GiB body and per-connection
writtennow match the neighboring backpressure test's kernel-buffer reasoning;settledis first-resolve-wins so a post-close drain on conn 1 can't flip the outcome.
Extended reasoning...
Overview
Two files: src/runtime/webcore/fetch/FetchTasklet.rs changes on_response_finalize (the JSC Weak finalizer on the fetch Response wrapper) so that when the Response is collected with no consumer attached, it calls abort_task() before ignore_remaining_response_body(true) — closing the socket instead of resuming the paused transport and draining the entire body to return it to the keep-alive pool. The branch structure is also refactored from nested if let Some(promise) into a single has_live_promise boolean, which I verified is behavior-equivalent to the old conditions apart from the new abort_task() call. test/js/web/fetch/fetch-backpressure.test.ts adds a subprocess test (content-length + chunked variants) with a raw net origin that asserts the server observes a close and a follow-up fetch opens a fresh connection.
Security risks
None identified. This narrows behavior toward closing connections earlier rather than reading more from the network. No untrusted-input parsing, auth, or crypto is touched.
Level of scrutiny
High. This runs inside a JSC Weak finalizer during WeakBlock::sweep, where touching JSCells is forbidden — the surrounding code already documents this constraint and the PR is careful to stay within it (abort_task() only does an atomic swap, an AsyncTaskTracker::did_cancel that early-returns on id == 0, and a cross-thread shutdown enqueue). The change also mirrors the existing on_stream_cancelled_callback (abort_task() + ignore_remaining_response_body), and ignore_remaining_response_body already gates schedule_receive_resume() / enable_response_body_streaming() on !aborted, so ordering the abort first correctly suppresses the drain. That said, finalizer-path changes in fetch are exactly the kind of thing a maintainer should look at.
Other factors
- This is a deliberate behavior change: previously an abandoned Response drained and the socket was pooled; now the socket is closed and the next request reconnects. The PR argues (with a repro) that this matches Node/undici and avoids pulling potentially-large bodies for header-only patterns. That trade-off (bandwidth vs. connection reuse) is a product decision worth a human ack.
- My two prior inline comments were both addressed: the test now uses a 1 GiB body with per-connection byte tracking sampled before the second fetch (fc5eaf7), and the numbered-scenario comment framing was replaced with per-branch one-liners (17a0ef0, further condensed in 492adeb/2ef3d9d6). The comment-cop bot's remaining pings are on lines that are now one-liners.
- CI build #81603 was still in progress at the time of the last timeline update; the PR description notes the new tests are platform-sensitive and defers to CI for coverage.
There was a problem hiding this comment.
No bugs found this pass; my earlier comments (64 MiB vs kernel-buffer flake, shared written counter, stale scenario-numbered comments) are addressed. Deferring to a human because this is a user-visible behavioral change to fetch() — GC'd Responses now close the socket instead of drain-and-pool — and abort_task() is newly reachable from inside a JSC Weak finalizer.
Checked: abort_task() from the finalizer touches only the aborted atomic, AsyncTaskTracker::did_cancel (early-returns when id == 0), and the HTTP-thread shutdown queue — no JSCell access. ignore_remaining_response_body already gates schedule_receive_resume/enable_response_body_streaming on !aborted, so the paused transport is not resumed. The has_live_promise refactor is behavior-equivalent to the old two-arm shape for which cases fall through to ignore. Test now uses 1 GiB + per-connection counters and awaits an observable close/drain rather than a byte threshold.
Extended reasoning...
Overview
Two files: src/runtime/webcore/fetch/FetchTasklet.rs changes on_response_finalize (the JSC Weak finalizer on the Response wrapper) to call abort_task() before ignore_remaining_response_body(true) when the body has no live consumer, mirroring on_stream_cancelled_callback. Net effect: dropping a Response without reading or cancelling its body closes the socket instead of resuming the paused transport and draining the full body so the socket can be pooled. test/js/web/fetch/fetch-backpressure.test.ts adds a subprocess test for both content-length and chunked encodings that asserts the server observes a close (not a full drain) and that a follow-up fetch opens a fresh connection.
Security risks
None identified. No new input parsing, no auth/TLS/crypto changes. The change narrows what happens on GC (close socket vs. keep reading), which if anything reduces bandwidth exposure.
Level of scrutiny
Medium-high. The Rust diff is small (~20 lines net) and reuses the exact abort-then-ignore shape already shipping for reader.cancel() since #33231. But two aspects warrant a maintainer look:
- Design trade-off: this swaps keep-alive connection reuse for not draining unbounded bodies when the user drops a Response. The PR argues (with numbers) that this matches Node/undici and is the right call for the header-only-check pattern, but it's a user-visible fetch semantics change.
- Weak-finalizer safety:
abort_task()is now reachable fromWeakBlock::sweep. I traced it —signal_store.aborted.swapis atomic,tracker.did_cancelearly-returns when no debugger is attached (id == 0) and otherwise callsDebugger__didCancelAsyncCall(C++ inspector state, no JSCell access per the PR), andschedule_shutdownis a cross-thread enqueue. The existingfrom_finalizerguards inignore_remaining_response_bodystill apply.fetch-response-finalizer-sweep.test.tsundercollectContinuouslyreportedly passes. This looks correct, but new-code-in-a-GC-finalizer is exactly the class REVIEW.md flags for extra care.
Other factors
- My two earlier inline comments (64 MiB body vs. documented ~256 MiB kernel buffers → false
drained; sharedwrittencounter across connections; contradictory scenario-numbered comments) were addressed in fc5eaf7 / 17a0ef0 / 492adeb / 2ef3d9d. The test now uses 1 GiB, per-connectionwritten, samples before the second fetch, and asserts{outcome: 'closed', drained: false, conns: 2, second: 'ok'}— the assertion can fail (pre-fix produces{outcome: 'drained', conns: 1}). - The
has_live_promiseinversion is equivalent to the oldSome(empty) → ignore/None → ignore/Some(live) → keep loadingstructure; only the addedabort_task()changes behavior. - The comment-cop bot fired repeatedly on paragraph-length comments; the author condensed them across the last three commits and the current comments are one-liners.
- CI for the latest commit is still building per robobun's status comment, so there's no green build to point at yet.
On alpine 3.23 x64 the Response was not collected within 5 Bun.gc(true) cycles so await settled hung and the test timed out at 30s. Hold a WeakRef, drop the local explicitly, and loop GC+poll up to 200 iterations until the server observes a close or a full drain. If neither happens, report whether the Response was collected so the failure is diagnostic.
There was a problem hiding this comment.
No new issues found — my earlier feedback on the test's kernel-buffer sizing and the scenario-numbered comments has been addressed. Deferring to a human because this changes user-visible connection-pooling semantics from inside a JSC Weak finalizer, which is worth a maintainer's eyes.
Checked that the has_live_promise refactor is behavior-equivalent to the old two-arm match for the keep-loading case, and that ignore_remaining_response_body already gates schedule_receive_resume/enable_response_body_streaming on aborted so the abort-then-ignore ordering does what the description says. Traced abort_task() for finalizer safety: tracker.did_cancel early-returns on id == 0 and Debugger__didCancelAsyncCall early-returns without an agent, so the no-debugger path touches only atomics and the HTTP-thread shutdown queue. The test now uses the 1 GiB body and per-connection byte counters, and d610112 replaced the fixed GC loop with a poll on settled.
Extended reasoning...
Overview
The PR changes FetchTasklet::on_response_finalize — the JSC Weak finalizer that fires when a Response wrapper is collected — so that a Response dropped without its body being read or cancelled now calls abort_task() before ignore_remaining_response_body(true), closing the socket instead of resuming the paused transport and draining the entire body to return it to the keep-alive pool. The logic was also refactored from a nested if let Some(promise) into a single has_live_promise early-return; I verified the three cases (Some(live) → keep loading, Some(empty) → abort+ignore, None → abort+ignore) map exactly onto the old arms with the abort added. A new pair of subprocess tests in fetch-backpressure.test.ts covers content-length and chunked encodings.
Security risks
None identified. This is an outbound-client behavior change (close vs. drain on GC) with no untrusted-input parsing. The finalizer-safety question is a memory-safety concern rather than a security one.
Level of scrutiny
High. The change runs during WeakBlock::sweep, where touching JSCells is forbidden — the file already carries a from_finalizer flag on ignore_remaining_response_body for exactly that reason, and abort_task() is now newly reachable from that context. I traced it: the atomic aborted swap and http_thread().schedule_shutdown are heap/queue only; tracker.did_cancel short-circuits on id == 0 (Debugger.rs:716) and Debugger__didCancelAsyncCall short-circuits without an agent (BunDebugger.cpp:1029), so the no-debugger path is JSCell-free. The debugger-attached path goes into InspectorDebuggerAgent::didCancelAsyncCall, which the PR description asserts is inspector-state-only; I didn't independently verify that WebKit path, and a maintainer familiar with the inspector should confirm. The change also alters observable semantics — sockets are no longer pooled after a header-only fetch — which is intentional (matching undici) but is a product decision.
Other factors
Both of my earlier inline comments were addressed: the test now uses a 1 GiB body and per-connection written counters (fc5eaf7), and the numbered-scenario comment framing was replaced with per-branch descriptions (17a0ef0 and follow-ups). The comment-cop bot fired repeatedly and the comments were trimmed in response. The latest commit (d610112) replaced the fixed 5-GC loop with a 200-iteration poll on settled plus a WeakRef fallback that distinguishes not-collected from settle-timeout, which is a reasonable de-flake. ignore_remaining_response_body reads aborted before checking set_receive_mode_terminal and gates both the resume and enable_response_body_streaming on it (FetchTasklet.rs:1808-1819), so the abort-then-ignore ordering in both this finalizer and on_stream_cancelled_callback produces the intended no-resume behavior. Given the finalizer context, the cross-thread shutdown, and the connection-pool semantics change, this warrants a human sign-off even though I found nothing wrong.
|
CI status on build 81836: the new
The alpine-x64 timeout seen in build 81734 was addressed in d610112 (GC-poll loop with WeakRef probe instead of a fixed 5 cycles); that lane was blocked by the build timeout in 81836 so did not re-run. The diff is ready for review. |
|
Closing in favour of #39690. Both this PR and #35819 fix the same unbounded drain in the same arm of |
Problem
fetch()→ read status/headers → drop theResponsewithout reading or cancelling its body: the Response's Weak finalizer resumes the paused transport and discards the entire remaining body at line rate so the socket can be returned to the keep-alive pool. Verified with 64 KB, 1 MB, 32 MB, 64 MB Content-Length bodies and a 1 GB chunked stream: 100% of every body is pulled after GC, RSS stays flat (the bytes are discarded, not buffered), and the nextfetch()to the same origin reuses the connection.redirect: 'manual'→ drop pulls the redirect response's body identically.Operator cost for a health-checker that reads headers only: 20 × 32 MB endpoints costs 640 MB of download over 12 pooled connections in Bun vs ~85 MB (20 × kernel window) over 33 short connections in Node.
Node/undici destroys the socket on GC instead: the transfer stalls at the kernel send window (~2-4 MB), the socket is closed, and the next request opens a fresh connection.
Before:
origin pushed 64.0 MB of 64 ; conns opened 1Node and after:
origin pushed 2.6 MB of 64 ; conns opened 2Cause
FetchTasklet::on_response_finalize(the JSC Weak finalizer on the Response wrapper) handles the "body never touched" case by callingignore_remaining_response_body, which setsBodyReceiveMode::Ignoreand callsschedule_receive_resume()+enable_response_body_streaming(). The HTTP thread un-pauses the socket, and with the receive mode set toIgnoreFetchTasklet::callbackdrops every chunk on arrival and never re-pauses, so the whole body streams through and the socket is then pooled.reader.cancel()/res.body.cancel()already close the connection viaabort_task()since #33231; the GC/drop path still drained.Fix
on_response_finalizenow callsabort_task()beforeignore_remaining_response_body(true), the same abort-then-ignore shape ason_stream_cancelled_callback.abort_task()setsaborted = trueand enqueues a shutdown on the HTTP thread;ignore_remaining_response_bodyalready skipsschedule_receive_resume()/enable_response_body_streaming()whenabortedis set, so the paused transport is never resumed and the socket is closed instead of drained.abort_task()only touches atomics, theAsyncTaskTracker(no-op without a debugger, and only C++ inspector state with one), and the cross-thread shutdown queue; it does not touch JSCells, so it is safe from a Weak finalizer duringWeakBlock::sweep.fetch-response-finalizer-sweep.test.ts(runs undercollectContinuously) still passes.Verification
New tests in
test/js/web/fetch/fetch-backpressure.test.ts(fetch() abandoned Response body (content-length|chunked)): a rawnetorigin that counts bytes written serves a 64 MB body; the client reads headers, drops the Response, forces GC, and waits for either the server to observe a close or to finish writing the whole body. Asserts{outcome: 'closed', conns: 2}and that the server wrote well under half of 64 MB; a follow-upfetch()to the same origin opens a fresh connection and completes.Without the
src/change:{outcome: 'drained', conns: 1}and the server writes the full 64 MB.Existing
fetch-response-finalizer-sweep,fetch-stream-cancel-leak,33227,fetch-keepalive,fetch-abort-*suites pass unchanged.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/fetch-backpressure.test.ts