Skip to content

fetch: re-send a Bun.file() body when following a redirect - #38406

Open
robobun wants to merge 1 commit into
mainfrom
farm/9fdeb2b5/fetch-replay-sendfile-body-on-redirect
Open

fetch: re-send a Bun.file() body when following a redirect#38406
robobun wants to merge 1 commit into
mainfrom
farm/9fdeb2b5/fetch-replay-sendfile-body-on-redirect

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • fetch(url, { method: "POST", body: Bun.file(path) }) through a 307/308 (or a 301/302 with a non-POST method) uploads the file to the first hop, then sends the redirected request with Content-Length: 0 and no body, and resolves with the second hop's 200 as if the upload had succeeded. Only files of 32 KiB or more are affected; smaller ones are replayed correctly.
  • fetch.rs sends a file of st_size >= 32 KiB to a plain http:// URL as HTTPRequestBody::Sendfile. HTTPClient::do_redirect (and do_redirect_multiplexed) replayed only HTTPRequestBody::Bytes and substituted b"" for everything else (src/http/lib.rs, next to the old // TODO: what we do with stream body?). handle_response_metadata rejects only Stream bodies with RequestBodyNotReusable, so a Sendfile body was neither replayed nor rejected.
  • Same check, second symptom: with http_proxy=https://... in the environment, the same request aborts the process on the first hop with panic: sendfile is only supported without SSL. This code should never have been reached!. fetch.rs decides on sendfile by looking at the explicit proxy option only; the environment proxy is resolved afterwards in FetchTasklet, so the sendfile body reached a TLS connection.

Fix

  • InternalState gets a sendfile cursor next to the existing request_body cursor for Bytes; SendFile::write now advances that copy, so state.original_request_body still describes the whole file range after a hop has (partially) uploaded it.
  • Both do_redirect variants go through request_body_for_redirect(), which hands a Bytes or Sendfile body to start() again unchanged (a Stream body only reaches this point on a 303, which has already become a bodiless GET). This is what WHATWG HTTP-redirect fetch asks for: a body with a source is re-sent on 307/308, and the existing 303 / 301+302-POST downgrade to GET is unchanged.
  • start_() calls buffer_sendfile_body_for_tls(): if the body is a Sendfile but this hop is a TLS socket (IS_SSL) or will CONNECT-tunnel through a proxy (the same http_proxy.is_some() && url.is_https() predicate the pool uses), the file range is pread into a new client-owned buffered_sendfile_body and the hop is started with a Bytes body. A request built for an https:// URL in the first place is already uploaded this way, so a redirect onto https behaves like fetching the final URL directly. The buffer is filled at most once per request (the body is Bytes from then on) and is freed where compressed_request_body is freed, after state, which borrows it. This same path replaces the environment-proxy panic above; a plain http:// environment proxy keeps using sendfile as before.
  • The fd behind the Sendfile stays valid across hops: it is owned by the FetchTasklet, which closes it only after the final result is delivered.
  • The keep-alive retry in on_close is left as is (in-memory bodies only); only its comment was updated.
  • Verified:
    • test/js/web/fetch/fetch-redirect.test.ts: new describe block. 9 of the 13 new cases fail on the unfixed build (hop 2 receives Content-Length: 0), all pass with the fix. Covers 307/308/301/302 with POST/PUT/PATCH/DELETE, the 303 and 301/302-POST downgrade (unchanged behavior), a Bun.file().slice() window across a 3-hop chain, an http -> https -> https chain, and a server that redirects while the 4 MiB upload is still in flight (a probe confirmed hop 1 had accepted ~2.6 MiB at that point and hop 2 received all 4 MiB byte-exact).
    • test/js/bun/http/proxy.test.ts: new describe block, two subprocess tests with proxies from the environment. Before: the TLS-proxy case aborts with the panic above and the redirect-into-CONNECT-tunnel case uploads 0 bytes; both pass with the fix. The full file (69 tests) passes.
    • fetch-file-upload, fetch-compress, fetch-keepalive, fetch-url-after-redirect, fetch-http2-client, fetch-http3-client pass on the debug build. cargo clippy -p bun_http and cargo fmt --check are clean.

Background

  • HTTPRequestBody (src/http/HTTPRequestBody.rs) is the HTTP thread's view of a request body: Bytes (a borrowed in-memory slice), Sendfile (an fd plus offset/length, written with sendfile(2) straight from the file to the socket fd, so it only works on a plaintext socket), or Stream (chunks pushed from a JS ReadableStream, which cannot be replayed).
  • InternalState is the per-attempt state of an HTTPClient; do_redirect resets it and calls start() with the body for the next hop, so anything that must survive a hop has to be taken out of it first or live on the HTTPClient itself (which is why the new buffer is a client field).
  • The HTTPClient that runs a request is a bitwise copy of the JS-thread original made when the request is picked up by the HTTP thread, so buffers it allocates on its own (redirect, compressed_request_body, now buffered_sendfile_body) are freed explicitly in the two teardown sites in AsyncHTTP.rs and HTTPThread.rs rather than by dropping the struct.
Repro (before: hop 2 gets 0 bytes; after: 32768)
import { createServer } from "node:http";
import { writeFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os"; import { join } from "node:path";
const p = join(mkdtempSync(join(tmpdir(), "frd-")), "body.bin");
const N = Number(process.argv[2] ?? 32768);
writeFileSync(p, Buffer.alloc(N, 0x61));
const srv = createServer((req, res) => {
  let n = 0; req.on("data", c => n += c.length).on("end", () => {
    console.log("server saw", req.method, req.url, "content-length:", req.headers["content-length"], "body bytes:", n);
    if (req.url === "/upload") { res.writeHead(307, { Location: "/upload2" }); res.end(); }
    else { res.writeHead(200); res.end(String(n)); }
  });
}).listen(0, "127.0.0.1", async () => {
  const r = await fetch(`http://127.0.0.1:${srv.address().port}/upload`, { method: "POST", body: Bun.file(p) });
  console.log("client:", r.status, "hop-2 received", await r.text(), "of", N);
  srv.close();
});

Before (bun 1.4.0 and current main):

server saw POST /upload content-length: 32768 body bytes: 32768
server saw POST /upload2 content-length: 0 body bytes: 0
client: 200 hop-2 received 0 of 32768

After:

server saw POST /upload content-length: 32768 body bytes: 32768
server saw POST /upload2 content-length: 32768 body bytes: 32768
client: 200 hop-2 received 32768 of 32768
Notes on the buffered fallback
  • The fallback read runs synchronously on the HTTP thread, once, for the rare hop that moves a sendfile upload onto TLS. Failing such a redirect with RequestBodyNotReusable instead would also be spec-conformant; buffering was chosen because a request that starts out on https already buffers the same file on the JS thread, so this keeps the redirected and the direct request equivalent. Easy to switch if buffering on the HTTP thread is not wanted.
  • If the file shrank between the first hop and the fallback read, the hop sends the bytes that are there with a matching Content-Length, which is also what a fresh fetch() of the same Bun.file() would send at that point. fetch: reject when a Bun.file() request body is truncated mid-upload #36212 deals separately with files truncated while sendfile is running.
  • Related but distinct: fetch: stream FormData bodies that contain Bun.file() parts #35792 adds redirect replay for streaming FormData bodies and touches the same do_redirect lines; whichever lands second needs a trivial rebase.

A Bun.file() body of 32 KiB or more is sent with sendfile(2). do_redirect
only replayed HTTPRequestBody::Bytes, so the redirected request went out
with Content-Length: 0 and an empty body, and resolved with the next
hop's status as if the upload had succeeded.

Keep state.original_request_body untouched by the send path (the sendfile
cursor now lives in InternalState, next to the Bytes cursor) so a redirect
can hand a Sendfile body to start() again. A hop that lands on a TLS
socket or a CONNECT tunnel cannot sendfile; start_ reads the file range
into a client-owned buffer and sends it as Bytes, which is also what a
request built for an https:// URL does up front. That same check replaces
the panic hit when an https:// proxy from the environment was applied to
a request that had already picked sendfile.
@coderabbitai

coderabbitai Bot commented Aug 14, 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: 4 minutes

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: 1f0ad159-e291-4789-9e32-09c457a972c0

📥 Commits

Reviewing files that changed from the base of the PR and between 01aa7cd and 65c23b0.

📒 Files selected for processing (7)
  • src/http/AsyncHTTP.rs
  • src/http/HTTPThread.rs
  • src/http/InternalState.rs
  • src/http/SendFile.rs
  • src/http/lib.rs
  • test/js/bun/http/proxy.test.ts
  • test/js/web/fetch/fetch-redirect.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 and on current main with the script in the PR description (hop 2 receives Content-Length: 0 for a 32768-byte Bun.file() body, 32767 bytes is replayed). The environment-proxy panic was reproduced the same way with http_proxy=https://.... Fix and tests are in this PR; the new cases in fetch-redirect.test.ts and proxy.test.ts fail on the unfixed build and pass with it.

CI (build 95636): every lane that ran is green (no test failures; a few unrelated tests passed on retry). The build is marked failed only because the two macOS arm64 test jobs never got an agent: that lane had a queue of a few hundred jobs all day and the jobs expired on each of their four scheduling rounds, so the change has not been exercised on macOS in CI yet. Nothing in the diff is macOS-specific (the sendfile write path is unchanged; the new read goes through File::pread_all). Retrying those two jobs once the lane drains is enough; I am not pushing a retrigger, since a full rebuild would just re-enter the same queue.

@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 reviewed this PR and didn't find any bugs. Because it touches the HTTP client's redirect state machine, adds a new self-borrowing owned buffer with an unsafe lifetime detachment, and makes a design choice the description explicitly flags as reviewable (synchronous pread on the HTTP thread when a sendfile hop lands on TLS vs. failing with RequestBodyNotReusable), a human look would still be worthwhile.

What was reviewed:

  • request_body_for_redirect and the new state.sendfile cursor: original_request_body is never advanced, so replaying it on the next hop restarts from offset 0; the 303 / 301+302-POST GET-downgrade path still clears resend_request_body_on_redirect and reaches the Bytes(b"") arm.
  • buffered_sendfile_body lifecycle: initialized to Vec::new() in make_client (so the bitwise-shared JS-thread original has nothing to free), filled at most once on the clone, and mem::take'd after state at both teardown sites, mirroring compressed_request_body.
  • SendFile::read_to_vec: uses try_reserve_exact (OOM-safe) + bun_sys::File::pread_all from the stored offset, truncating on short read; content_size is the same value fetch() already stat'd, so the allocation is bounded.
  • The on_writable Sendfile arm now mutates state.sendfile instead of original_request_body, and the IS_SSL panic is now unreachable because buffer_sendfile_body_for_tls runs first in start_.
Extended reasoning...

Overview

This PR fixes two related bugs around fetch(url, { body: Bun.file(...) }) when the file is large enough (≥ 32 KiB) to be sent via sendfile(2): (1) following a 307/308 (or a non-POST 301/302) sent Content-Length: 0 on the redirected hop instead of replaying the file, and (2) an https:// proxy taken from the environment reached the sendfile is only supported without SSL panic. It touches five Rust files in src/http/ (the HTTP client state machine, per-attempt state, the sendfile shim, and the two clone-teardown sites) plus adds ~250 lines of tests across two files.

The fix has three pieces: a per-attempt sendfile cursor on InternalState so original_request_body stays intact for replay; a shared request_body_for_redirect() helper used by both do_redirect and do_redirect_multiplexed that hands Bytes/Sendfile bodies to start() unchanged; and a buffer_sendfile_body_for_tls fallback in start_() that preads the file range into a new client-owned buffered_sendfile_body: Vec<u8> and swaps the body to Bytes when the hop is TLS or a CONNECT tunnel.

Security risks

None identified. The file read uses the same fd and content_size the JS side already opened and stat'd for the request; try_reserve_exact guards the allocation and pread_all bounds the read to the pre-sized buffer. No new user-controlled input reaches a parser or a path resolver. The redirect body-replay semantics match WHATWG HTTP-redirect fetch (307/308 keep the body; 303 and 301/302-POST become bodiless GET), which the tests cover explicitly.

Level of scrutiny

This is a correctness fix in a critical, memory-safety-sensitive path. src/http/lib.rs is the HTTP client's core state machine; the change adds an owned Vec on HTTPClient that state.original_request_body borrows via unsafe { bun_ptr::detach_lifetime(...) }, with correctness depending on (a) the buffer being filled at most once (guarded by the body becoming Bytes afterward and a debug assert) and (b) both explicit teardown sites dropping state before the buffer. The reasoning in the SAFETY comment holds and the pattern mirrors compressed_request_body, but it is exactly the kind of self-borrow lifetime invariant the repo's review guidance calls out as most-blocked and worth a maintainer's eyes.

Other factors

The PR description explicitly offers an alternative design — failing the TLS hop with RequestBodyNotReusable instead of buffering on the HTTP thread — and says it is "easy to switch if buffering on the HTTP thread is not wanted." That is a design decision a maintainer should sign off on: the synchronous pread runs on the single HTTP thread and, while rare (only when a sendfile upload is redirected onto TLS or routed through an https env-proxy), can block every other in-flight request for the duration of the read. The author also notes an interaction with #35792 that will need a rebase. Test coverage is thorough (redirect status × method matrix, sliced files, http→https→https chain, mid-upload interruption, env-proxy subprocess tests) and the description reports the relevant existing suites pass; no bugs were found by the automated bug hunt.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 AM PT - Aug 14th, 2026

@robobun, your commit 65c23b0 has some failures in Build #95636 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38406

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

bun-38406 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: the second symptom in this PR's description (http_proxy=https://... plus a Bun.file() body of 32 KiB or more aborting with panic: sendfile is only supported without SSL) was reported separately. Confirmed on current main: a 64 KiB Bun.file() POST to an http:// origin through a TLS absolute-form proxy taken from http_proxy or HTTP_PROXY exits 134 with that panic; the same upload works with an http:// proxy or with a file under 32 KiB. The "is uploaded through a TLS proxy" test added here covers that case, so no separate PR is being opened for it.

For reference, the decision-side version of that part of the fix is on branch farm/89990154/fetch-sendfile-env-proxy (commit 819b00d): fetch() builds the ProxySettings before preparing the body and only picks sendfile when they resolve to a direct connection for the URL, and FetchTasklet::get resolves hop 0 from those same settings instead of building its own. With that, hop 0 takes the existing buffered upload path on the JS thread and buffer_sendfile_body_for_tls is only reached by redirects. It is verified with the debug build (tests in test/js/bun/http/fetch-file-upload.test.ts, two of which fail on main with the panic above) and is independent of the HTTP client changes here, in case it is useful to fold in.

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