Skip to content

fetch: stream FormData bodies that contain Bun.file() parts - #35792

Closed
robobun wants to merge 13 commits into
mainfrom
claude/farm-b82a7c1b/formdata-file-stream
Closed

robobun wants to merge 13 commits into
mainfrom
claude/farm-b82a7c1b/formdata-file-stream

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

fetch(url, { body: formData }) where the FormData holds a Bun.file(path) entry read the entire file into memory while building the multipart body. Blob::from_dom_form_data did a synchronous read_file + push_cloned per file-backed part, then flattened the joiner into one contiguous buffer. Peak RSS was roughly 2x the combined file size, so a 500 MB upload peaked at 570-1040 MB, while the same file as body: Bun.file(path) streams flat via sendfile.

Repro

const fd = new FormData();
fd.append("f", Bun.file("/tmp/500mb.bin"));
await fetch(url, { method: "POST", body: fd });
// peak RSS ~570-1040 MB on stock 1.4.0-canary

Fix

Adds a streaming path used by fetch's body extraction:

  • MultipartSegments::from_dom_form_data walks the FormData and emits the same boundary/header bytes as the buffered serializer (both now call the shared write_multipart_entry<S: MultipartSink> / for_each_form_data_entry / make_multipart_boundary helpers), but records file-backed parts as Segment::File { store, offset, size } instead of reading them. Total body size is computed from stat so the request is sent with an explicit Content-Length.
  • MultipartFormLoader is a new ReadableStream source that iterates the segments, serving Bytes segments from memory and reading File segments with pread in 256 KB chunks.
  • HTTPRequestBody gains a MultipartFormStream variant carrying the segment template, the stream, the content-type and the precomputed length. fetch injects Content-Length for it so the HTTP client avoids chunked transfer-encoding.

Gating

needs_streaming_multipart takes the new path only when the FormData contains at least one file-backed entry and no S3 entry. HTTPRequestBody::from_js additionally keeps the buffered path when prefer_buffered is set, which fetch does for S3 destinations and an explicit compress option. Missing files, pipes/FIFOs, and plain in-memory FormData still flow through the buffered serializer, so new Response(formData), new Request({body: formData}), the S3 PUT path, request-body compression, and the ENOENT error behaviour are unchanged.

Redirect replay

A FormData body has a non-null Fetch "source", so 307/308 redirects must re-send it rather than failing with RequestBodyNotReusable. The HTTP client now supports replaying a restartable streaming body:

  • Flags.streaming_body_can_restart lets handle_response_metadata follow non-303 redirects for this body and makes request_stream_detach keep the buffer ref so the redirect path still has it.
  • do_redirect / do_redirect_multiplexed call prepare_stream_body_for_redirect, which for 307/308 extracts the Stream, fires the buffer's restart_callback, bumps its atomic generation, resets it, and passes the stream to start() for the follow-up request. For 301/302/303 it just drops the streaming flag as before.
  • WriteMessage is stamped with the generation it was scheduled for and drain_queued_writes discards stale ones, so an in-flight End from the previous sink cannot mark the replayed request done.
  • FetchTasklet registers on_request_stream_restart as the restart callback; it sets request_stream_restart_pending (which makes stale write_request_data/write_end_request/resume_request_data_stream calls no-op) and enqueues a JS-side detach_stale_request_sink. When the redirected request reaches its body stage, HTTPClientResult.restart_request_stream drives on_progress_update to rebuild a fresh loader/stream from the saved segment template and start a new sink writing at the new generation.

Verification

48 MB file, client-side peak RSS delta (ASAN quarantine disabled for the fixture process):

body before after
Bun.file() (sendfile) 2 MB 2 MB
Bun.file().stream() 20 MB 20 MB
FormData{Bun.file()} 106 MB 18 MB

The new tests rebuild the expected multipart bytes independently and compare a rolling hash of the received body, and exercise 301/302/303/307/308 redirects with a FormData+Bun.file() body. Existing FormData & Bun.file (roundtrip), FormData-multipart-serialization, FormData-file-error-leak, and fetch-redirect tests pass unchanged.


[review] gate passed · iteration 2 · 18 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/html/FormData-file-stream.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/39] gen cpp.rs (cppbind)
[2/39] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (31 fields)
Found 8 classes from /workspace/bun/src/runtime/api/h
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (95c909bbb)

test/js/web/html/FormData-file-stream.test.ts:
FormData Bun.file() upload: file=48 MB, stream peak=20 MB, form peak=8 MB
(pass) fetch streams FormData with a Bun.file() part instead of buffering it [458.76ms]
(pass) fetch replays a streaming FormData+Bun.file() body across a redirect [16.53ms]

 2 pass
 0 fail
 18 expect() calls
Ran 2 tests across 1 file. [662.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/html/FormData-file-stream.test.ts
bun test v1.4.0 (6fe893da7)

test/js/web/html/FormData-file-stream.test.ts:
FormData Bun.file() upload: file=48 MB, stream peak=38 MB, form peak=19 MB
(pass) fetch streams FormData with a Bun.file() part instead of buffering it [6062.12ms]
(pass) fetch replays a streaming FormData+Bun.file() body across a redirect [484.96ms]

 2 pass
 0 fail
 18 expect() calls
Ran 2 tests across 1 file. [8.54s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 776ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/32] gen cpp.rs (cppbind)
[2/32] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/32] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameP
... (truncated)
diff hotspot
src/http/HTTPRequestBody.rs                      |  10 +
 src/http/HTTPThread.rs                           |  36 +-
 src/http/ThreadSafeStreamBuffer.rs               |  24 ++
 src/http/h2_client/ClientSession.rs              |   5 +-
 src/http/h2_client/encode.rs                     |   2 +-
 src/http/h3_client/ClientContext.rs              |   4 +-
 src/http/h3_client/ClientSession.rs              |  10 +-
 src/http/h3_client/encode.rs                     |   2 +-
 src/http/lib.rs                                  | 121 +++++-
 src/runtime/api/streams.classes.ts               |   8 +-
 src/runtime/webcore.rs                           |   2 +
 src/runtime/webcore/Blob.rs                      | 507 +++++++++++++++--------
 src/runtime/webcore/MultipartFormLoader.rs       | 198 +++++++++
 src/runtime/webcore/fetch.rs                     |  37 +-
 src/runtime/webcore/fetch/FetchTasklet.rs        | 289 +++++++++++--
 src/runtime/webcore/headers_ref.rs               |   6 -
 test/js/web/html/FormData-file-stream-fixture.ts |  97 +++++
 test/js/web/html/FormData-file-stream.test.ts    | 150 +++++++
 18 files changed, 1249 insertions(+), 259 deletions(-)

gate history · 7 passed · 2 rejected · iteration 2

evidence per changed file
file                                        reads  edits  tests
src/http/HTTPRequestBody.rs                     1      1      0
src/http/HTTPThread.rs                          3      5      0
src/http/ThreadSafeStreamBuffer.rs              2      8      0
src/http/h2_client/ClientSession.rs             1      2      0
src/http/h2_client/encode.rs                    1      1      0
src/http/h3_client/ClientContext.rs             1      1      0
src/http/h3_client/ClientSession.rs             1      1      0
src/http/h3_client/encode.rs                    1      1      0
src/http/lib.rs                                 3     20      0
src/runtime/api/streams.classes.ts              2      4      0
src/runtime/webcore.rs                          1      1      0
src/runtime/webcore/Blob.rs                    12     21      0
src/runtime/webcore/MultipartFormLoader.rs      2      9      0
src/runtime/webcore/fetch.rs                    6      8      0
src/runtime/webcore/fetch/FetchTasklet.rs      22     46      0
src/runtime/webcore/headers_ref.rs              3      3      0
(+ 2 more files)

fetch(url, { body: formData }) where the FormData holds a Bun.file(path)
entry read the entire file into memory while building the multipart body
(Blob::from_dom_form_data does a synchronous read_file + push_cloned per
file-backed part, then flattens the joiner into one contiguous buffer).
Peak RSS was roughly 2x the combined file size, so a 500 MB upload
peaked at 570-1040 MB, while the same file as body: Bun.file(path)
streams flat via sendfile.

This adds a streaming path used only by fetch's body extraction:

- MultipartSegments::from_dom_form_data walks the FormData and emits the
  same boundary/header bytes as the buffered serializer, but records
  file-backed parts as Segment::File { store, offset, size } instead of
  reading them. Total body size is computed from stat so we can still
  send an explicit Content-Length.
- MultipartFormLoader is a new ReadableStream source that iterates the
  segments, serving Bytes segments from memory and reading File segments
  with pread in 256 KB chunks.
- HTTPRequestBody gains a MultipartFormStream variant carrying the
  stream plus its content-type and precomputed length. fetch injects the
  Content-Length header for it so the HTTP client uses Content-Length
  rather than chunked transfer-encoding.

needs_streaming_multipart gates the new path: it is taken only when the
FormData contains at least one file-backed entry and no S3 or unsized
(pipe/FIFO/stat-failed) entry. Missing files, S3 parts, and plain
in-memory FormData still flow through the existing buffered serializer,
so new Response(formData), new Request({body: formData}), and the
ENOENT error behaviour are unchanged.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9a631769-8d83-4d42-b5d2-b2a77f596950

📥 Commits

Reviewing files that changed from the base of the PR and between 04bb5c4 and 6fe893d.

📒 Files selected for processing (18)
  • src/http/HTTPRequestBody.rs
  • src/http/HTTPThread.rs
  • src/http/ThreadSafeStreamBuffer.rs
  • src/http/h2_client/ClientSession.rs
  • src/http/h2_client/encode.rs
  • src/http/h3_client/ClientContext.rs
  • src/http/h3_client/ClientSession.rs
  • src/http/h3_client/encode.rs
  • src/http/lib.rs
  • src/runtime/api/streams.classes.ts
  • src/runtime/webcore.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/MultipartFormLoader.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/headers_ref.rs
  • test/js/web/html/FormData-file-stream-fixture.ts
  • test/js/web/html/FormData-file-stream.test.ts

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

Comment thread src/runtime/api/streams.classes.ts Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/MultipartFormLoader.rs Outdated
Comment thread src/runtime/webcore/MultipartFormLoader.rs Outdated
Comment thread src/runtime/webcore/MultipartFormLoader.rs Outdated
Comment thread src/runtime/webcore/MultipartFormLoader.rs Outdated
Comment thread src/runtime/webcore/MultipartFormLoader.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs
@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:23 PM PT - Jul 25th, 2026

❌ @robobun, your commit 6fe893d has some failures in Build #81982 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 35792

That installs a local version of the PR into your bun-35792 executable, so you can run:

bun-35792 --bun

Comment thread src/runtime/webcore/MultipartFormLoader.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/MultipartFormLoader.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/MultipartFormLoader.rs
…uncated file

- Extract write_multipart_entry<S: MultipartSink> so FormDataContext and
  MultipartSegmentBuilder drive the same per-entry wire layout. StringJoiner
  and the segment Vec<u8> accumulator each implement MultipartSink; only the
  blob body dispatch remains serializer-specific.
- Drop MultipartFormLoader.total_size (never read).
- on_pull now errors the stream when pread returns 0 with bytes remaining
  (file shrank after Content-Length was sent) instead of finishing short.
Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/http/HTTPThread.rs
Comment thread src/http/lib.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
…restart rebuild failure

drain_queued_writes guarded only the two h1 client arms; h2 and h3
reached st.ended = ended with no check. Now
stream_body_by_http_id (h2 ClientSession, h3 ClientSession/ClientContext)
takes the write generation and drops the message when the buffer's
current generation is newer, mirroring the h1 arms.

The redirect-restart handler now matches on multipart_form_stream's
result and aborts the task on Err/None instead of leaving the redirected
request at its body stage with no sink and a pending JS exception.
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
…eplay

- take_and_detach_sink: release the ResumableSink ref (init_exact_refs
  starts at 2; detach_js alone never decrements) and, when the sink had
  not reached write_end_request yet, also release the tasklet ref that
  start_request_stream took for it so a mid-pump detach does not pin
  the tasklet.
- detach_stale_request_sink: guard on request_stream_restart_pending so
  a callback-coalesced onProgressUpdate that already installed the
  fresh sink is not detached afterwards.
- restart block: reset the stream buffer again on the JS thread before
  clearing request_stream_restart_pending, so a write_request_data that
  passed its atomic check before the HTTP-thread reset cannot leave a
  stale chunk for the new sink to append to.
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/webcore/fetch/FetchTasklet.rs:933-936 — The self.ref_() taken in start_request_stream() (line 693, "lets only unref when sink is done") is only released via on_end → write_end_request → FetchTasklet::deref, but detach_js() never invokes on_end — so both new detach sites (here at ~933 and detach_stale_request_sink at ~2273) orphan that ref for the previous generation, then start_request_stream() runs again for the replay and takes another. Net: one FetchTasklet ref (response buffer, headers, url_proxy_buffer, Strong handles) leaked per 307/308 hop. This is distinct from the already-flagged missing ResumableFetchSink::deref_(sink) — that leaks the sink allocation; this leaks the tasklet itself, and fixing that alone does not release this ref. Add a matching FetchTasklet::deref when sink.take() returns Some at both sites (or use sink.cancel(UNDEFINED) like the is_done cleanup at line 917 does, whose comment says exactly "write_end_request drops that ref").

    Extended reasoning...

    What the bug is

    start_request_stream() at FetchTasklet.rs:693 takes a +1 on the FetchTasklet:

    self.ref_(); // lets only unref when sink is done
    let sink = ResumableSink::init_exact_refs(&global_this, stream, std::ptr::from_mut(self), 2);
    self.sink = Some(sink);

    That +1 has exactly one release path: the sink's pump reaches js_end → on_end → write_end_request, and every path through write_end_request ends with FetchTasklet::deref(this_ptr) (including the new request_stream_restart_pending early-return this PR added).

    Why the new detach sites orphan it

    detach_js() (ResumableSink.rs:439) downgrades js_this to weak and clears the cached slots. Its own doc comment: "Unlike Self::cancel this does NOT run any JS callbacks or invoke on_end." After it runs, is_detached() (line 427: !self.js_this.is_strong() || status == Done) is true, so any later js_write / js_end from the pump early-returns at line 303/345 without calling on_end. write_end_request is therefore never reached for that sink, and the +1 from line 693 is orphaned.

    Both new sites do exactly sink.take() + detach_js() and nothing else for the tasklet ref:

    • on_progress_update restart handler, ~933-936:
      if let Some(sink) = self.sink.take() {
          unsafe { (*sink).detach_js() };
      }
    • detach_stale_request_sink, ~2273-2276:
      if let Some(sink) = this_ref.sink.take() {
          unsafe { (*sink).detach_js() };
      }
      FetchTasklet::deref(this);   // ← this balances on_request_stream_restart's ref_(), NOT start_request_stream's
      The trailing deref here pairs with the this_ref.ref_() taken in on_request_stream_restart for the enqueued concurrent task (its SAFETY comment: "we hold the ref taken above"), so it does not help.

    Step-by-step proof

    const fd = new FormData();
    fd.append("f", Bun.file("/tmp/big.bin"));
    await fetch(url307, { method: "POST", body: fd });   // /a → 307 → /b
    1. Initial request reaches the body stage → start_request_stream(): self.ref_() (tasklet refcount +1, call it R₀), init_exact_refs(.., 2) allocates sink₀, self.sink = Some(sink₀). Pump starts.
    2. Server sends 307 before the body finishes (common — servers often redirect without reading the body). HTTP thread: prepare_stream_body_for_redirect → buf.report_restart() → on_request_stream_restart: sets request_stream_restart_pending = true, does self.ref_() (call it R_task), enqueues detach_stale_request_sink.
    3. JS thread runs detach_stale_request_sink: self.sink.take() → Some(sink₀); (*sink₀).detach_js() — sink₀'s js_this is now weak, is_detached() == true. Then FetchTasklet::deref(this) releases R_task. R₀ is still held.
    4. The pump's pending reader.read() for sink₀ resolves; sink₀.write(...) → js_write → is_detached() → early return. Eventually sink₀.end() → js_end → line 345 if this.is_detached() { return; } → early return. on_end never fires; write_end_request never runs for gen 0; R₀ is orphaned.
    5. Redirected request reaches its body stage; on_progress_update enters the restart block at 927 (or if it ran before step 3, it does the sink.take()/detach_js() itself with the same result). start_request_stream() runs again → self.ref_() (R₁).
    6. The final generation's R₁ is eventually released — either via the pump reaching write_end_request, or via the is_done cleanup at line 917-919 (sink.cancel(UNDEFINED) → on_end → write_end_request → deref). clear_sink() in clear_data releases the final sink's allocation ref but does not (and need not) touch the tasklet refcount. R₀ is never released.

    Net: one FetchTasklet ref leaked per 307/308 hop. The tasklet never reaches ref_count == 0, so its response_buffer, headers, url_proxy_buffer: Box<[u8]>, and the Strong handles it holds (promise, abort reason, check_server_identity) all leak.

    Why the PR's redirect test doesn't catch it

    The redirect test's server does for await (const _ of req.body ?? []) {} — it drains the full 1 KB body before sending 307, so the client's pump reaches js_end before the restart signal, on_end fires, write_end_request hits the new request_stream_restart_pending guard and derefs R₀. The leak requires the 307 to arrive before the client's pump has called js_end, which is the common case for an early redirect on a non-trivial upload.

    Distinct from the already-flagged sink-allocation leak

    The existing 🔴 comment on line 936 ("omit ResumableFetchSink::deref_(sink)") is about the sink's own intrusive refcount (from init_exact_refs(.., 2)) — without deref_(sink), the ResumableSink heap allocation stays at refcount ≥ 1 after GC finalizes the wrapper. Fixing that (adding ResumableFetchSink::deref_(sink)) drops the sink; it does not touch the FetchTasklet's ref_count, so R₀ is still orphaned. Both fixes are needed at both sites.

    Fix

    At each if let Some(sink) = ….sink.take() in the two new sites, release the start_request_stream ref alongside the sink cleanup — either:

    • Add an explicit FetchTasklet::deref(this_ptr) inside the Some(sink) arm (paired with the existing sink deref_ fix), or
    • Replace detach_js() with (*sink).cancel(JSValue::UNDEFINED), matching the pre-existing is_done cleanup at line 917-919 whose comment already documents the intent: "Cancel the sink so the JS side releases the reader and write_end_request drops that ref". cancel() fires on_end → write_end_request, which now hits the request_stream_restart_pending guard, does FetchTasklet::deref(this_ptr), and returns without touching the new request's state.

    Per REVIEW.md → Native code: memory safety → "Reference counts provably balanced on every terminal path — success, error, cancellation, finalize."

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/http/lib.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/http/ThreadSafeStreamBuffer.rs
…am; clear restart_callback

- h2/h3 drain_send_body still called body.detach() directly on
  local_closed/request_body_done, bypassing the restart guard. A 307/308
  that arrives after END_STREAM over h2/h3 then finds stream.buffer=None
  and drain_send_body early-returns without ever emitting DATA. Now
  gated on !client.flags.streaming_body_can_restart to mirror
  request_stream_detach.
- The restart block cancels the previous ReadableStream before deinit so
  MultipartFormLoader::on_cancel closes its fd immediately instead of
  waiting for GC.
- clear_drain_callback now also clears restart_callback so clear_sink
  leaves no dangling context pointer on the buffer.
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread test/js/web/html/FormData-file-stream.test.ts Outdated
The /b handler now hashes the received body and returns the request
Content-Type; the client rebuilds the expected multipart bytes from the
boundary and compares hashes, so a stale-chunk or duplicated-segment bug
in the replay path would be caught instead of passing on byte count
alone.

@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 prior findings are addressed (95c909b adds the if is_done { deref } on the restart-rebuild abort arm; 6fe893d has the redirect test verify replayed body bytes via rolling hash), and this pass found nothing new. Deferring to a human reviewer given the scope — ~1200 lines across the h1/h2/h3 client, a new cross-thread restart/generation protocol between the HTTP thread and FetchTasklet, and new refcount balancing in take_and_detach_sink/on_progress_update.

What was reviewed: the prepare_stream_body_for_redirect / generation / request_stream_restart_pending machinery for stale-write races and refcount balance on every on_progress_update exit; the h2/h3 body.detach() gating vs. redirect replay; MultipartFormLoader fd lifecycle on cancel/restart; and that the buffered serializer's wire layout is byte-identical to the new streaming one via the shared write_multipart_entry.

Extended reasoning...

Overview

This PR makes fetch(url, { body: formData }) stream file-backed Bun.file() FormData parts from disk in 256 KB chunks instead of buffering the entire file into memory. It touches 18 files (~1224 insertions / 259 deletions):

  • src/runtime/webcore/Blob.rs (+507/-180): refactors the multipart serializer into a shared write_multipart_entry<S: MultipartSink> used by both the buffered (StringJoiner) and new streaming (MultipartSegmentBuilder) sinks; adds MultipartSegments::from_dom_form_data and needs_streaming_multipart.
  • src/runtime/webcore/MultipartFormLoader.rs (new, 198 lines): a new ReadableStream source that iterates Segment::{Bytes,File} and preads file segments.
  • src/runtime/webcore/fetch/FetchTasklet.rs (+289/-30): new HTTPRequestBody::MultipartFormStream variant; take_and_detach_sink helper; a restart block in on_progress_update that rebuilds the loader/sink on 307/308; on_request_stream_restart/detach_stale_request_sink callbacks; request_stream_restart_pending atomic and request_stream_generation stamping on schedule_request_write.
  • src/http/{lib,HTTPThread,ThreadSafeStreamBuffer,HTTPRequestBody}.rs and h2/h3 client: new Flags::{streaming_body_can_restart,pending_request_stream_restart}, HTTPClientResult::restart_request_stream, prepare_stream_body_for_redirect, WriteMessage::generation + is_stale_generation filtering, restart_callback on the stream buffer, and gating of body.detach() at h1/h2/h3 body-completion sites.
  • Two new tests (RSS-bounded streaming upload with wire-byte hash verification; 301/302/303/307/308 redirect replay with hash verification).

Security risks

None identified. The new file-open path uses bun_sys (O_RDONLY|O_NOCTTY, dup for fd-backed stores) mirroring the existing needs_to_read_file path in fetch.rs. No new user-controlled path parsing; the FormData entries are already trusted at this layer. The generation counter is a defensive fence against stale cross-thread writes, not a security boundary.

Level of scrutiny

High. This is production-critical HTTP client code with new cross-thread state (an atomic restart_pending flag written from the HTTP thread and read on the JS thread; a generation counter used to discard stale WriteMessages; a restart_callback fired from do_redirect). The on_progress_update restart block adds a new early-return path that must maintain the same mutex-unlock / if is_done { deref } invariants as its siblings — and the review history shows exactly those invariants were missed and patched in three separate follow-up commits (79323e1 mutex unlock, 95c909b deref-on-is_done, plus the take_and_detach_sink refcount balancing in 64654c8). The h2/h3 body.detach() gating was also missed initially and patched in 7c9a1d7. A change that accumulates this many concurrency/refcount fixes during review warrants a maintainer's eyes on the final shape.

Other factors

  • All eight rounds of prior findings (from both the bug-hunting system and my inline comments) are now addressed and marked resolved; the current pass found nothing new.
  • The tests are solid: both now verify wire bytes via a rolling hash against an independently rebuilt multipart body, the RSS test disables ASAN quarantine for the fixture process and asserts peakDeltaMB < fileSizeMB, and the redirect test covers the full 301/302/303/307/308 matrix.
  • The buffered serializer refactor (FormDataContext::on_entry → write_multipart_entry) is behaviour-preserving by construction (both sinks call the same framing helper), and existing FormData-multipart-serialization / FormData & Bun.file (roundtrip) tests are stated to pass unchanged.
  • The gating (needs_streaming_multipart + prefer_buffered for S3/compress) keeps the buffered path for every case that previously depended on it.

Given the size, the number of subsystems touched, and the concurrency subtlety demonstrated by the review iteration, this should be signed off by a maintainer familiar with the HTTP client / FetchTasklet lifecycle rather than auto-approved.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green locally on linux-x64 (debug ASAN + release) and rust:check-all passes for all 10 targets. Build #81982 failed because seven build-bun jobs timed out across darwin/linux/windows simultaneously (fleet-wide build infra, not this diff), which cascaded into 108 waiting_failed test jobs. The only actual test result is test/regression/issue/1632.test.ts (process.stdout EPIPE, marked flaky, passed on retry, unrelated to fetch/FormData).

The mechgate also bounces on a PCH-staleness build failure when toggling between with-fix and without-fix: this PR adds a new codegen'd class (MultipartFormInternalReadableStreamSource via streams.classes.ts), so stashing src/ regenerates ZigGeneratedClasses+lazyStructureHeader.h without the new class and clang rejects the stale PCH. That is a build-system dependency issue independent of the diff.

Fail-before / pass-after is reproducible with the stock installed bun:

$ /usr/local/bin/bun test test/js/web/html/FormData-file-stream.test.ts
(fail) fetch streams FormData with a Bun.file() part instead of buffering it   # form peak=107 MB > 48 MB
$ bun bd test test/js/web/html/FormData-file-stream.test.ts
(pass) fetch streams FormData with a Bun.file() part instead of buffering it   # form peak=17 MB
(pass) fetch replays a streaming FormData+Bun.file() body across a redirect

Ready for a maintainer to review/merge.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

2 participants