fetch: re-send a Bun.file() body when following a redirect - #38406
fetch: re-send a Bun.file() body when following a redirect#38406robobun wants to merge 1 commit into
Conversation
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.
|
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 (7)
Comment |
|
Status: reproduced on bun 1.4.0 and on current main with the script in the PR description (hop 2 receives 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 |
There was a problem hiding this comment.
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_redirectand the newstate.sendfilecursor:original_request_bodyis never advanced, so replaying it on the next hop restarts from offset 0; the 303 / 301+302-POST GET-downgrade path still clearsresend_request_body_on_redirectand reaches theBytes(b"")arm.buffered_sendfile_bodylifecycle: initialized toVec::new()inmake_client(so the bitwise-shared JS-thread original has nothing to free), filled at most once on the clone, andmem::take'd afterstateat both teardown sites, mirroringcompressed_request_body.SendFile::read_to_vec: usestry_reserve_exact(OOM-safe) +bun_sys::File::pread_allfrom the stored offset, truncating on short read;content_sizeis the same valuefetch()already stat'd, so the allocation is bounded.- The
on_writableSendfilearm now mutatesstate.sendfileinstead oforiginal_request_body, and theIS_SSLpanic is now unreachable becausebuffer_sendfile_body_for_tlsruns first instart_.
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.
|
Updated 6:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 65c23b0 has some failures in 🧪 To try this PR locally: bunx bun-pr 38406That installs a local version of the PR into your bun-38406 --bun |
|
Cross-reference: the second symptom in this PR's description ( For reference, the decision-side version of that part of the fix is on branch |
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 withContent-Length: 0and 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.rssends a file ofst_size >= 32 KiBto a plainhttp://URL asHTTPRequestBody::Sendfile.HTTPClient::do_redirect(anddo_redirect_multiplexed) replayed onlyHTTPRequestBody::Bytesand substitutedb""for everything else (src/http/lib.rs, next to the old// TODO: what we do with stream body?).handle_response_metadatarejects onlyStreambodies withRequestBodyNotReusable, so aSendfilebody was neither replayed nor rejected.http_proxy=https://...in the environment, the same request aborts the process on the first hop withpanic: sendfile is only supported without SSL. This code should never have been reached!.fetch.rsdecides on sendfile by looking at the explicitproxyoption only; the environment proxy is resolved afterwards inFetchTasklet, so the sendfile body reached a TLS connection.Fix
InternalStategets asendfilecursor next to the existingrequest_bodycursor forBytes;SendFile::writenow advances that copy, sostate.original_request_bodystill describes the whole file range after a hop has (partially) uploaded it.do_redirectvariants go throughrequest_body_for_redirect(), which hands aBytesorSendfilebody tostart()again unchanged (aStreambody 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_()callsbuffer_sendfile_body_for_tls(): if the body is aSendfilebut this hop is a TLS socket (IS_SSL) or will CONNECT-tunnel through a proxy (the samehttp_proxy.is_some() && url.is_https()predicate the pool uses), the file range ispreadinto a new client-ownedbuffered_sendfile_bodyand the hop is started with aBytesbody. A request built for anhttps://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 isBytesfrom then on) and is freed wherecompressed_request_bodyis freed, afterstate, which borrows it. This same path replaces the environment-proxy panic above; a plainhttp://environment proxy keeps using sendfile as before.Sendfilestays valid across hops: it is owned by theFetchTasklet, which closes it only after the final result is delivered.on_closeis left as is (in-memory bodies only); only its comment was updated.test/js/web/fetch/fetch-redirect.test.ts: new describe block. 9 of the 13 new cases fail on the unfixed build (hop 2 receivesContent-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), aBun.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-clientpass on the debug build.cargo clippy -p bun_httpandcargo fmt --checkare 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 withsendfile(2)straight from the file to the socket fd, so it only works on a plaintext socket), orStream(chunks pushed from a JSReadableStream, which cannot be replayed).InternalStateis the per-attempt state of anHTTPClient;do_redirectresets it and callsstart()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 theHTTPClientitself (which is why the new buffer is a client field).HTTPClientthat 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, nowbuffered_sendfile_body) are freed explicitly in the two teardown sites inAsyncHTTP.rsandHTTPThread.rsrather than by dropping the struct.Repro (before: hop 2 gets 0 bytes; after: 32768)
Before (bun 1.4.0 and current main):
After:
Notes on the buffered fallback
RequestBodyNotReusableinstead 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.Content-Length, which is also what a freshfetch()of the sameBun.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.do_redirectlines; whichever lands second needs a trivial rebase.