Skip to content

webcore: fix Content-Type lost when Request/Response body is read before headers - #32913

Closed
robobun wants to merge 1 commit into
mainfrom
farm/f358b3d7/request-formdata-content-type
Closed

robobun wants to merge 1 commit into
mainfrom
farm/f358b3d7/request-formdata-content-type

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The Content-Type header that Bun derives from a FormData, URLSearchParams, or typed Blob body is only copied into the Request/Response headers lazily, on the first access of .headers. Consuming the body first replaces the internal Blob with Value::Used, after which the lazy path finds no Blob and synthesizes empty headers.

For a multipart body the boundary lives only in that header, so it became unrecoverable:

const fd = new FormData();
fd.append("n", "V");
const req = new Request("http://x/", { method: "POST", body: fd });
const body = await req.arrayBuffer(); // read the body first, as a proxy/middleware does

req.headers.get("content-type");
// bun:  null  (the boundary is now unknowable)
// node: "multipart/form-data; boundary=----formdata-undici-0416..."
// ...while `body` already starts with "------WebKitFormBoundaryc00e33..."

Reading .headers before the body returned the correct value, so the result depended on access order. This breaks the standard proxy/signing/middleware shape (consume or hash the body, then forward the headers plus bytes): the forwarded multipart body has no boundary the upstream can parse. Per Fetch, the header list is fixed at construction, so it must not depend on which of the two is read first.

URLSearchParams and Blob bodies with a type lost their Content-Type the same way, Response had the identical bug, and Response.body lost it too. fetch(request) was also affected: it extracts the body Blob directly, so afterwards the user-held Request's headers.get("content-type") was null even though the correct boundary went out on the wire.

Cause

Request::construct_into only copied the Blob's content type into the headers when a headers init was supplied; otherwise it deferred to ensure_fetch_headers, which reads the content type off BodyValue::Blob(blob). Once the body is consumed that match arm no longer hits and nothing is written. Response::constructor / get_or_create_headers had the same guard.

Fix

The two classes need different treatment.

Request: write the Content-Type into the headers at construction (construct_into). When the extracted body is a Blob with a non-empty content type, the headers object is created (if it does not already exist) and the Content-Type written, unless the headers already have one. Nothing depends on a user-constructed Request's headers being unmaterialized, and fixing it at construction closes every path that takes the body Blob afterwards at once: the BodyMixin consumers, fetch(request) (fetch.rs:1249), Bun.write, the shell, new Blob([req]), and anything added later. fetch() already skips the body's content type when the headers contain one (from_fetch_headers), so nothing is sent twice.

Response: preserve the Content-Type at consumption time. Construction-time materialization regressed 25 Content-Range tests in serve.test.ts plus 1 in bun-serve-file.test.ts in an earlier revision of this PR, because Bun.serve read Response.init.headers being unset as "the user passed no headers init" when deciding whether a sliced Bun.file() response gets an automatic 206 (RequestContext::render_metadata). The last commit replaces that overloaded signal (see the second fix below), but preservation at consumption time is still the right shape for Response: it is the only approach that also covers the Responses fetch() builds internally for data:, file:, and blob: URLs (which never pass through the JS constructor), and it keeps new Response(body) on the Bun.serve path from allocating a FetchHeaders nothing reads. So BodyMixin gains preserve_body_content_type, a gated default that materializes the lazy headers only when they do not exist yet and the body is a Value::Blob with a non-empty content type; every body-consuming method (get_text, get_json, get_array_buffer, get_bytes, get_form_data, get_blob_with_this_value, and get_text_stream, which main added while this PR was open) and the .body getter call it right before the Blob is taken. Request and Response each supply a one-line materialize_headers that routes to their existing lazy materializer. For a Request this hook is now a no-op (the headers are already materialized), which its doc comment states.

Response external consumers that bypass BodyMixin (Bun.write(dest, response), HTMLRewriter.transform, the shell, WebAssembly.compileStreaming, new Blob([response]), and the Bun.serve send path) are intentionally not hooked: they consume the Response as an input rather than as the headers-then-body proxy shape this fixes. The internal jsFunctionGetCompleteRequestOrResponseBodyValueAsArrayBuffer is also not hooked; it has no JS call site.

Verification

test/js/web/fetch/body.test.ts, under "content-type survives reading the body before the headers", covers Request and Response x {FormData, URLSearchParams, typed Blob} x {arrayBuffer, bytes, text, blob, json, formData, draining .body, draining .textStream()}, asserts the header matches the headers-first order, asserts the multipart boundary in the header is the one actually used in the body bytes, and asserts an explicit content-type from the init is not overridden. A separate test, "Request content-type survives fetch(request) and matches the wire", fetches the Request to a local Bun.serve and asserts the user-held Request's boundary is non-null and identical to the Content-Type the server received and to the boundary in the body bytes.

A third block, "fetch() non-remote response content-type survives body consumption", covers the three fetch() URL schemes that build a Response whose Content-Type lives only on the body Blob and so had the identical bug: data:, file:, and blob: URLs each had headers.get("content-type") return null if the body was read first. The BodyMixin hook fixes them; these tests pin that down.

Against unmodified main plus only the tests:

  • bun bd test test/js/web/fetch/body.test.ts -t "content-type survives": 52 fail, 8 pass (the 8 are the explicit-header cases and Request's .body path, which already worked)
  • bun bd test test/js/web/fetch/body.test.ts -t "via draining .textStream": 6 fail, 0 pass
  • bun bd test test/js/web/fetch/body.test.ts -t "survives fetch": 1 fail

With the fix (numbers as of the rebase onto current main):

  • bun bd test test/js/web/fetch/body.test.ts: 512 pass, 0 fail
  • bun bd test test/js/bun/http/serve.test.ts -t "Content-Range": 42 pass, 0 fail
  • bun bd test test/js/bun/http/bun-serve-file.test.ts: 105 pass, 0 fail
  • bun bd test test/js/bun/http/fetch-file-upload.test.ts: 11 pass, 0 fail

Rebase notes

Rebased onto current main as a single commit. Three source conflicts, all resolved in favor of keeping both sides:

  • RequestContext.rs: main moved sendfile into a Cell<SendfileContext> read once into a local, so the 206 condition now uses that local instead of self.sendfile.total.
  • Body.rs: main added stream-ownership bookkeeping (check_body_stream_ref) to the .body getter; the preserve_body_content_type call sits before it. Main also added a new consumer, get_text_stream (.textStream()), which takes the Blob the same way, so it gets the same call and a row in the test matrix (fails 6/6 without the fix).
  • Response.rs: main narrowed the Init fields to pub(crate); the new headers_from_init field follows suit.

Also verified that fetch() with a FormData body (both the options-object and the fetch(new Request(...)) shapes) still sends a single multipart/form-data; boundary=... header whose boundary matches the body bytes, that request.clone() and the original keep the same boundary, and that typeless bodies (plain string, Blob without a type) still correctly get no Content-Type.

Second fix: Bun.serve auto-206 for a sliced blob keyed on the wrong signal

Found while landing the above. Bun.serve decides whether a .slice()-driven Bun.file() response should auto-promote to 206 with a Content-Range by checking whether Response.init.headers exists. That signal is overloaded: init.headers is also created by the lazy headers getter, and (with this PR) by the Content-Type preservation above, neither of which means the caller supplied a headers init.

The lazy-getter case is a pre-existing bug, reproducible on unmodified bun:

Bun.serve({
  fetch() {
    const response = new Response(Bun.file(path).slice(0, 10));
    response.headers.get("x-not-set"); // any read of .headers
    return response;                   // 200, should be 206
  },
});

Response::Init gains a headers_from_init flag, set inside Init::init (the one place a user ResponseInit is parsed, so new Response(body, init), Response.json(data, init), and Response.redirect(url, init) are all covered, not just the constructor). Bun.serve keys the auto-206 decision on it instead of on init.headers being present.

Init::init has three paths (a Request as the init, a Response as the init, and a plain object). Each computes headers_from_init as result.headers.is_some() for itself. The Response-as-init path clones the donor's Init for the other fields but must not inherit the donor's headers_from_init: the donor may have materialized its own headers lazily, but new Response(slice, donor) is still a headers-carrying init from this caller's point of view. An earlier revision did inherit it via Init::clone, which would have regressed that shape from 200 to 206; Init::clone is otherwise unchanged since its other caller is response.clone(), where the verbatim copy is correct.

This is also what makes it safe for anything other than a user init to create init.headers at all, and it removes the one consumer that depended on a Response's headers staying unmaterialized.

New tests in test/js/bun/http/serve.test.ts under "should support Content-Range with Bun.file()":

  • "206 for a slice is not defeated by reading response.headers": fails on unmodified bun (200), passes with the fix.
  • "slice with a headers init and no Content-Range stays 200": guards the preserved contract, that a caller who supplies a headers init without a Content-Range is managing the response themselves. Passes before and after.
  • "slice with a Response as the headers-carrying init stays 200": the Response-as-init shape above. Passes on unmodified bun and with the fix, and would have failed on the revision that inherited headers_from_init from the donor.

With the fix, bun bd test test/js/bun/http/serve.test.ts -t "Content-Range": 42 pass, 0 fail.


no test proof · iteration 10 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts test/js/web/fetch/body.test.ts

@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a headers_from_init flag to ResponseInit/Response, a materialize_headers hook and preserve_body_content_type helper to BodyMixin, fixes blob Content-Type derivation in Request construction, and corrects 206/Content-Range promotion logic for sliced file responses.

Changes

Content-Type preservation and 206 slice status fixes

Layer / File(s) Summary
ResponseInit headers_from_init flag and materialize_headers
src/runtime/webcore/Response.rs
Adds headers_from_init: bool to Init with default/clone/init assignments; sets the flag from parsed header presence on fast-path and general-path branches; exposes Response::headers_from_init() and Response::materialize_headers().
BodyMixin materialize_headers hook and preserve_body_content_type
src/runtime/webcore/Body.rs
Adds materialize_headers as a required trait method and preserve_body_content_type as a default helper; calls the helper before body consumption in get_text, get_body, get_json, get_array_buffer, get_bytes, get_form_data, and get_blob_with_this_value.
Request materialize_headers and blob Content-Type construction
src/runtime/webcore/Request.rs
Implements materialize_headers via ensure_fetch_headers; reworks blob-derived Content-Type materialization in construct_into to create empty headers when req.headers is None before setting Content-Type.
206/Content-Range promotion fix in render_metadata
src/runtime/server/RequestContext.rs
Captures whether init headers exist before swap_init_headers() consumes init.headers, and updates needs_content_range so sliced responses can still promote to 206 when init headers are absent or already contain Content-Range.
Serve regression tests
test/js/bun/http/serve.test.ts
Adds regression tests for slice responses under early response.headers access and under custom init header scenarios.
Fetch body content-type tests
test/js/web/fetch/body.test.ts
Adds body tests for Content-Type stability across consumption methods, multipart boundary preservation, request forwarding, and data:/blob:/file: URL fetch content-type correctness.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the primary Content-Type bug fixed when Request or Response bodies are read before headers.
Description check ✅ Passed The description explains the problem, cause, implementation, secondary Content-Range fix, tests, and verification results in detail.

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

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:44 AM PT - Aug 13th, 2026

❌ @robobun, your commit cf1d285 has 2 failures in Build #94033 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32913

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

bun-32913 --bun

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

I didn't find any issues — the fix looks correct and well-tested — but since this changes the eager allocation behavior in the core Request/Response constructors (hot path, borrowck-sensitive Rust), it's worth a human look.

Extended reasoning...

Overview

This PR fixes an order-dependence bug: the body-derived Content-Type (from FormData, URLSearchParams, or a typed Blob) was only copied into headers lazily on first .headers access. If the body was consumed first (replacing the internal Blob with Value::Used), the lazy path found no blob and the header — including the multipart boundary — was lost. The fix materializes the headers eagerly at construction time in Request::construct_into and Response::constructor when the extracted body has a non-empty content type, while preserving the fast_has guard so an explicit Content-Type from the init still wins. ~20 lines of logic change across two Rust files plus ~60 lines of new tests.

Security risks

None identified. This is header-population timing; no new untrusted input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Moderate-to-high. The diff is small and the logic is straightforward, but it sits in the Request/Response constructors — a hot, critical path that runs for every typed-body construction in the runtime. The Request-side change reshuffles a borrowck workaround (ct_ptr: *const [u8]) and adds an eager HeadersRef::create_empty() allocation where there previously was none when no headers init was supplied. I traced the error paths: in Request, the bail! → cleanup → finalize_without_deinit path drops the newly-created HeadersRef; in Response, init's field drop glue releases it on ?. Both look correct.

Other factors

  • Test coverage is thorough: Request × Response × {FormData, URLSearchParams, typed Blob} × {arrayBuffer, bytes, text, blob}, plus order-independence and explicit-override assertions, with the FormData case verifying the boundary in the header matches the body bytes.
  • No CODEOWNERS for these paths.
  • The behavioral change (eager FetchHeaders allocation for every typed-body Request/Response, even if .headers is never read) is a deliberate trade-off for correctness; a human familiar with the allocation/perf characteristics here should confirm that's acceptable.
  • The bug-hunting system found nothing.

Given this touches core constructor logic with unsafe pointer reshaping rather than a mechanical/config change, deferring to a human reviewer.

@robobun
robobun force-pushed the farm/f358b3d7/request-formdata-content-type branch from 7039f32 to 510113b Compare June 28, 2026 00:32
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.rs
Comment thread src/runtime/webcore/Request.rs
Comment thread src/runtime/webcore/Response.rs

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Response.rs`:
- Around line 1385-1399: The `headers_from_init` flag in `Response` construction
is being derived from `result.headers.is_some()`, which loses the distinction
between an explicit empty `headers` init and no init at all. Update the
`Response` init handling paths (including the direct `Response` clone path and
the general init path) to capture whether the caller supplied `headers` before
normalization/cloning, and assign that presence boolean to `headers_from_init`
instead of recomputing it from `result.headers`. This should preserve explicit
empty headers for downstream logic in `RequestContext` and avoid incorrect slice
auto-206 promotion.

In `@test/js/bun/http/serve.test.ts`:
- Around line 1350-1363: The slice-response tests in the fetch handlers should
also assert the `Content-Range` behavior, not just the status and body. Update
the relevant `runTest` cases around the `Response(Bun.file(fixture).slice(...))`
paths to verify that `Content-Range` is present when the response is promoted to
`206`, and absent when it should not be emitted. Use the existing `response`
assertions in these slice cases and add checks that strongly validate the
`206`/`Content-Range` split in `serve.test.ts`.

In `@test/js/web/fetch/body.test.ts`:
- Around line 746-768: The current fetch(Request) regression test in Request
content-type survives fetch(request) and matches the wire only covers the
FormData path, so broaden it to cover the other body variants mentioned in this
PR. Extend the same test or add adjacent cases for URLSearchParams and typed
Blob bodies, verifying that fetch(req) preserves the request content-type and
that the server sees the same header/body pairing. Use the existing Request,
fetch, and Bun.serve setup as the reference point so the regression is checked
across the full body matrix.
- Around line 704-711: The shared loop in the body header test is swallowing all
`formData()` errors, which hides regressions in the success path for multipart
and urlencoded bodies. Split the `json()` and `formData()` cases in the test
around the `obj[consume]()` call so `json()` can still tolerate rejection, but
`formData()` must be awaited without a catch and assert the strongest invariant
using the existing `makeBody`, `obj`, and `consume` test setup.
🪄 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: 34ef8634-3884-4176-b597-91a1e2acc32a

📥 Commits

Reviewing files that changed from the base of the PR and between 0f9331d and 813b6cc.

📒 Files selected for processing (6)
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/Response.rs
  • test/js/bun/http/serve.test.ts
  • test/js/web/fetch/body.test.ts

Comment thread src/runtime/webcore/Response.rs
Comment thread test/js/bun/http/serve.test.ts
Comment thread test/js/web/fetch/body.test.ts Outdated
Comment thread test/js/web/fetch/body.test.ts Outdated
@robobun

robobun commented Jun 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Current state of this PR (head cf1d28509f, a single commit rebased onto bdb738222e):

Reproduction. On an unmodified build, new Request(url, { method: "POST", body: formData }) followed by await req.arrayBuffer() leaves req.headers.get("content-type") as null, so the multipart boundary is unrecoverable; same for Response, URLSearchParams, and typed Blob bodies. Against main's src/ plus this PR's tests: body.test.ts -t "content-type survives" 52 fail, -t "via draining .textStream" 6 fail, and serve.test.ts -t "Content-Range" fails exactly the new 206 for a slice is not defeated by reading response.headers case.

With the fix. body.test.ts 512 pass / 0 fail, serve.test.ts -t "Content-Range" 42 / 0, bun-serve-file.test.ts 105 / 0, fetch-file-upload.test.ts 11 / 0. No unresolved review threads.

CI on build #94033. 175 jobs passed; the two darwin-14 jobs are still queued. The only hard failures are two tests on the debian 13 x64-asan lane, and both are pre-existing intermittents that do not involve Request, Response, headers, or bodies:

  • test/cli/run/require-cache.test.ts (via import() timed out at 30s). Also appears in main builds #93726, #92941, and #92885.
  • test/js/bun/util/inspect-error-leak.test.js (timed out at 10s). Also appears in main build #93967, which is the exact commit this PR is rebased onto, and it flaked-then-passed on windows 11 aarch64 within this same build.

Everything else in the build's failure list is tagged flaky by the CI tooling (passed on retry or when run alone) and is unrelated to this change. A retrigger was already used on this PR earlier, so I am not pushing another; the diff itself is ready for review.

Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Response.rs Outdated
Comment thread src/runtime/webcore/Response.rs Outdated
Comment thread src/runtime/webcore/Response.rs Outdated
…headers

A Request or Response built from a FormData, URLSearchParams, or typed
Blob body carries its Content-Type (for FormData, the multipart boundary)
only on the body Blob. The headers object was created lazily, and the
Blob was gone by then if the body had been consumed first, so
headers.get("content-type") returned null and the boundary was
unrecoverable. Node/undici fix the header list at construction, as the
Fetch spec requires.

Request: materialize the headers at construction, copying the body's
Content-Type in, so it survives whatever consumes the Blob later.

Response: materializing at construction would make Bun.serve treat every
typed-Blob response as if the caller had supplied headers, which it uses
to decide whether a sliced Bun.file() auto-promotes to 206. Instead,
every body-consuming BodyMixin method (text/json/arrayBuffer/bytes/blob/
formData, .body, .textStream) calls preserve_body_content_type first,
which copies the Content-Type over while the Blob is still there.

Bun.serve: the auto-206 decision now keys on a new Init.headers_from_init
flag rather than on init.headers being present, so headers materialized
by the above (or by the lazy .headers getter) no longer flip a sliced
response from 206 to 200.
@robobun
robobun force-pushed the farm/f358b3d7/request-formdata-content-type branch from 4490d18 to cf1d285 Compare August 13, 2026 03:31
Comment thread src/runtime/server/RequestContext.rs
Comment thread src/runtime/webcore/Body.rs
Comment thread src/runtime/webcore/Request.rs
Comment thread src/runtime/webcore/Response.rs
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #42015. It fixes the same access-order bug by capturing the body's Content-Type into Init when the Response is constructed, instead of materializing the header list in each body consumer. The header list of new Response(body) stays unallocated, so the sliced-Bun.file() auto-206 rule in render_metadata needs no change, and the HTMLRewriter, Response-as-init and Bun.serve stream-fallback paths are covered by the same field. The Request half (append the header at construction) is the same as here. The FormData/URLSearchParams/data: URL cases from this PR's tests are ported there.

Closing this one so there is a single PR to review. Reopen if the consumption-time shape is preferred.

@robobun robobun closed this Sep 8, 2026
Jarred-Sumner pushed a commit that referenced this pull request Sep 8, 2026
…nstead of teeing it (#42053)

### Problem
- `new Response(Bun.file("a.html").stream())` answers `blob().type ===
"text/html;charset=utf-8"` and Bun.serve sends that Content-Type. After
`.clone()` both bodies answer `""` and Bun.serve sends none. A
`Bun.file()` or typed `Blob` body loses it too once `.body` was observed
first.
- Cause: `Value::clone_with_readable_stream`
(`src/runtime/webcore/Body.rs`) tees every stream-backed body through
JS, so the store behind an unread native stream (MIME type, sendfile
path) is gone on both sides. The Blob arm has the mirror bug: `new
Response(Bun.stdin).clone()` dupes a Blob over fd 0 and the clone reads
`""`.

### Fix
- `clone()` decides per store. A store that reads the same twice
(memory, S3, a regular file by path) is duped. An unread native stream
first moves back into its Blob through `ReadableStream::to_any_blob`, as
the readers and Bun.serve already do. A store that yields its bytes once
(any fd, a FIFO by path) is read as one stream and teed. JS streams and
partly read bodies tee as before.
- The pre-clone stream is detached, so a held reference reads as locked,
as after a tee. The cached `.body` is cleared and rebuilt from the Blob
(the #33779 guarantees hold).
- Correct because both bodies now resolve to what the un-cloned body
resolves to, so every consumer answers the clone as it answers the
original.
- Verified: `test/js/web/fetch/body-clone.test.ts` (13 new tests, 6 fail
on stock bun) and the neighbouring body, response, request, FormData,
blob, serve and fetch suites. Self-reviewed: 2 concerns raised, both
addressed.

### Background
- A body `Value` is a `Blob` (memory, `Bun.file()` store, S3), a string
or byte buffer, or `Locked` (a `ReadableStream`). `new Response(stream)`
is `Locked`, as is any body once `.body` ran.
- A native stream over a Blob or an unopened file holds a ref to its
`Store`. `to_any_blob` turns it back into a Blob without reading it.
- The JS wrapper caches `.body` and its stream. A clone that changes
what the body holds must resync both (#33779).

<details><summary>Notes</summary>

- Ledger context: content-type propagation matrix, member "`new
Response(Bun.file(p).stream())`: type recovered for the direct return,
dropped by `.clone()`; Bun.serve `/direct text/html`, `/clone null`".
Expected: clone == original.
- `store_reads_repeatably()` (`Blob.rs`, next to `resolve_file_stat`):
Bytes and S3 are repeatable; a `File` over an fd never is (reads share
the fd offset, and a pipe's bytes are gone once read: `new
Response(Bun.file(fd)).clone()` read `""` on the second body even for a
regular file); a `File` over a path is stat'd once if `seekable` is
unknown and is repeatable unless stat says it is not a regular file. A
missing path stays repeatable (both bodies fail the same way).
- Probes: with an fd-only guard, `new
Response(Bun.file(fifoPath).stream()).clone()` hung on the second open
while stock's tee delivered the bytes to both; `new
Response(Bun.stdin).clone()` gives `["hello world", ""]` on stock. Both
are tests now (posix for the FIFO).
- Not changed here, left for a maintainer call: `new Response(new
Blob([..], { type }).stream())` puts the type into the header list at
construction (undici: `null`) because `Value::from_js` moves a
memory-blob stream into its Blob eagerly, while a file stream stays
`Locked` until first use, so its headers stay `null`. Making file
streams eager too would route `fetch(url, { body: Bun.file(p).stream()
})` through the file-blob upload path (a synchronous whole-file read
over https, `fetch.rs` "TODO: make this async + lazy") instead of a
streamed upload, so it is not done here.
- Side effect worth knowing: `Response` derives a missing `Content-Type`
header lazily from a Blob body on first `.headers` access. For `new
Response(file.stream())`, calling `.clone()` before the first `.headers`
access therefore makes `content-type: text/html` appear on both. Header
derivation is already order-dependent today (`.body` or `.text()` before
`.headers` drops it, see #32913); this adds no new mechanism.
- Suites run locally on the debug+ASAN build: body-clone (76/76), body
(476), response, request, body-stream (9086), FormData (149), blob
(110), bun-serve-static (46), regression 18547/02368/2993/25648,
serve.test.ts -t "clone|type|Content|file" (84), fetch.test.ts -t clone.
`request-clone-leak.test.ts` and `request-method-getter.test.ts` time
out at 5 s per test on this build for the constructor-only cases too;
unrelated.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/web/fetch/body-clone.test.ts

<!-- robobun:evidence:end -->
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.

1 participant