Skip to content

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

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

robobun wants to merge 3 commits 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() with a Bun.file() body of 32 KiB or more follows a 307/308 with Content-Length: 0 and no body, then resolves 200.
  • HTTPClient::do_redirect (src/http/lib.rs) replays only HTTPRequestBody::Bytes. A file this size is Sendfile.
  • With http_proxy=https://... the same upload aborts: panic: sendfile is only supported without SSL. This code should never have been reached!

Fix

  • InternalState (the state of one attempt) gets a sendfile send cursor. original_request_body stays unchanged. A redirect gives it to start() again.
  • On a TLS socket or a CONNECT tunnel, start_() reads the file into buffered_sendfile_body and sends Bytes.
  • Correct: WHATWG HTTP-redirect fetch re-sends a body that has a source on 307/308.
  • Verified: test/js/web/fetch/fetch-redirect.test.ts (9 of 13 new cases fail on main), test/js/bun/http/proxy.test.ts. More in Notes.

Background

  • Sendfile is a request body that sendfile(2) copies from an fd to a plaintext socket. It cannot go through TLS.
  • Alternatives: fail the TLS hop (breaks http to https redirects of large uploads). Or read the file in chunks in the write path (larger, not in this PR).

Downsides

  • A TLS hop reads the whole file on the HTTP thread. Other fetches stall about 0.55 ms per MiB (563 ms for 1 GiB, warm cache).
  • If the file shrinks between hops, a plain http hop stays pending until timeout or abort. Main resolved 200 with an empty body.
  • The keep-alive retry in on_close still skips Sendfile bodies.
Notes

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

Tests

  • New describe block in fetch-redirect.test.ts: 307/308/301/302 with POST, PUT, PATCH, DELETE. The 303 and 301/302 POST change to GET (same as before). A Bun.file().slice() window across a chain of 3 hops. A chain http, https, https. A server that redirects while a 4 MiB upload is in flight.
  • New describe block in proxy.test.ts: two subprocess tests with a proxy from the environment. On main the TLS proxy case aborts with the panic above. The case that redirects into a CONNECT tunnel uploads 0 bytes.
  • Also run on the debug build: fetch-file-upload, fetch-compress, fetch-keepalive, fetch-url-after-redirect, fetch-http2-client, fetch-http3-client.

Cost of the TLS fallback read

  • Method: a C probe that does the same steps as SendFile::read_to_vec (allocate, zero-fill, pread until full). Warm page cache, 5 runs for each size, Linux x64. It is not a run of a release build of this PR.
  • 64 MiB: 36 ms median (31 to 46). 256 MiB: 149 ms median (143 to 162). 1 GiB: 563 ms median (519 to 596).
  • The file stays in memory until the request ends. A cold cache was not measured.
  • For comparison, a request that starts on https:// reads the whole file on the JS thread before it sends (fetch.rs).
  • An automated pre-merge check reported these numbers for head 7e81605. I did not run them again. 1 GiB redirected to https: slowest concurrent GET 0.1 to 1 s, peak memory 1024 MB. The same file sent to https from the start: 1 s stall and 2048 MB on main, 1.1 s and 2048 MB on this PR.

Cost for a request with no file body

  • Struct sizes from debug builds, read with gdb. Main bf42a52: InternalState 400, HTTPClient 1680, AsyncHTTP 2208 bytes. This head: 440, 1744, 2272.
  • So each request carries 64 more bytes: 40 for the sendfile cursor and 24 for the empty buffered_sendfile_body. From the diff, that path has no new allocation and no new syscall. I did not measure those two.

File that shrinks

  • TLS hop: the fallback read returns the bytes that exist. The hop sends them with a matching Content-Length.
  • Plain http hop: SendFile::write reports Done when sendfile(2) returns 0 with bytes still owed. The server then waits for the rest of the body.
  • I ran this on main (1.4.3, 367d939) with no redirect. A 4 MiB file cut to 64 KiB during the upload: the server received 2691072 of 4194304 bytes, and the fetch was still pending after 8 s.
  • With the file cut between hops, main resolves 200 and hop 2 has Content-Length: 0.
  • On a debug build of 7e81605, with the file cut between hops: hop 2 sends Content-Length: 4194304 and then 65536 bytes. The fetch is still pending after 8 s (3 of 3 runs). A pre-merge check saw the same.
  • fetch: reject when a Bun.file() request body is truncated mid-upload #36212 had a change for this case. It was closed without a merge on 2026-09-13 in a cleanup of stale pull requests.

Other

  • The head is 1057464, a rebase of 7e81605 onto main bc7a813 (one import conflict in InternalState.rs). The numbers in these notes were taken on 7e81605.

  • The fd behind a Sendfile body stays valid across hops. The FetchTasklet owns it and closes it after the final result.

  • The running HTTPClient is a bitwise copy of the JS-thread original. It frees buffered_sendfile_body in the two teardown sites (AsyncHTTP.rs, HTTPThread.rs), after state, which borrows it.

  • A plain http:// proxy from the environment keeps sendfile(2).

  • fetch: stream FormData bodies that contain Bun.file() parts #35792 adds redirect replay for streaming FormData bodies and touches the same do_redirect lines.

  • Branch farm/89990154/fetch-sendfile-env-proxy has a variant that selects sendfile only after the environment proxy is known. It cannot help a redirect to https, so it is not in this PR.


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

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9e31c142-0810-4d81-984b-f44ec054a5a2

📥 Commits

Reviewing files that changed from the base of the PR and between bf42a52 and 7e81605.

📒 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

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Sendfile request bodies can be replayed across redirects. TLS requests and HTTPS proxy tunnels buffer sendfile data in memory. Request state tracks the send cursor, and cleanup drops the buffer after state.

Changes

Sendfile state and file reads

Layer / File(s) Summary
Sendfile state and file reads
src/http/InternalState.rs, src/http/SendFile.rs
InternalState tracks an optional sendfile cursor while retaining the original request body. SendFile.read_to_vec reads the body from its offset and returns the bytes read or an error.

TLS buffering and redirect replay

Layer / File(s) Summary
TLS buffering and redirect replay
src/http/lib.rs, src/http/AsyncHTTP.rs, src/http/HTTPThread.rs, test/js/bun/http/proxy.test.ts, test/js/web/fetch/fetch-redirect.test.ts
HTTP/1 and multiplexed redirects use a shared helper to select the next request body. TLS paths buffer sendfile bodies; request cleanup drops the buffer after state. Tests cover body replay, redirect method changes, TLS and proxy paths, and interrupted uploads.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7e816

The change preserves file bodies across redirects and buffers them for encrypted transports. No actionable merge-blocking issue was established; merge readiness remains subject to normal build and test checks.

🚥 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 and concisely describes the primary change: resending a Bun.file() request body when following redirects.
Description check ✅ Passed The description clearly explains the problem, implementation, verification, performance costs, limitations, and test results. It does not use the exact template headings, but it provides the required …
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

The head is 1057464, a rebase onto main bc7a813 (one import conflict, see the comment below). CI build 122871 on that head is complete: 180 of 181 jobs passed, macOS included. The one red test is test/js/bun/spawn/spawn.test.ts on debian 13 x64-asan. It also fails on main, this diff does not touch it, and it is reported for triage. The other failures in that build passed on a retry. I did not retrigger: a new run fails the same way until main is fixed. Two review threads are open on purpose: they are declined for this PR with the reasons in the threads.

@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 7:41 AM PT - Oct 2nd, 2026

❌ @robobun, your commit 1057464 has 1 failures in Build #122871 (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.

@robobun
robobun force-pushed the farm/9fdeb2b5/fetch-replay-sendfile-body-on-redirect branch from 65c23b0 to 4c68c31 Compare August 26, 2026 02:30
Comment thread src/http/InternalState.rs Outdated
Comment thread src/http/SendFile.rs Outdated
Comment thread src/http/SendFile.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/InternalState.rs Outdated
Comment thread src/http/SendFile.rs Outdated
Comment thread src/http/SendFile.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (4c68c31). The only conflict was in do_redirect and do_redirect_multiplexed: main changed unix_socket_path from a ZigStringSlice to a plain slice, so the ManuallyDrop reset there became self.unix_socket_path = b"", and my body-replay change sits next to it. No other changes to the fix.

The doc comments the comment check flagged are now one line each (7cddd52, 12f28f7). The new tests and the related fetch, proxy, h2, and h3 suites pass on the rebased debug build; the new cases still fail on the unfixed binary. The coderabbit notices on this PR are rate-limit messages only.

The decision-side variant on farm/89990154/fetch-sendfile-env-proxy (pick sendfile only after the environment proxy is resolved) is left out of this PR on purpose: the client-side fallback here already covers that hop, and it also covers redirects, which a JS-thread decision cannot.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main bf42a52 (was 12f28f7, now 7e81605). The conflicts and their resolutions:

  • src/http/lib.rs, do_redirect. Main removed the two statements above the request-body block (keep_alive_possible and the early reset of unix_socket_path). The resolution follows main and keeps this pull request's let request_body = self.request_body_for_redirect();.
  • src/http/lib.rs and src/http/AsyncHTTP.rs. Main added the pool field at the end of HTTPClient, where this pull request adds buffered_sendfile_body. Both fields are kept.
  • test/js/bun/http/proxy.test.ts. The harness import line is the union of both sides.

The added and removed lines in src/ are the same as before the rebase. Only their context moved.

Checked on a debug build of the rebased branch:

  • The 2 new proxy.test.ts tests pass. Both fail on a debug build of main bf42a52.
  • fetch-redirect.test.ts: 43 pass. On main, 9 of the 13 "re-sends" tests fail.
  • fetch-file-upload.test.ts (11) and fetch-compress.test.ts (32) pass.
  • All of proxy.test.ts: 98 pass, 1 timeout. The timeout is a bad record delivered with a 101 through a proxy tunnel does not free the client mid-dispatch. Its fixture prints the expected lines and exits 0, but it needs 4.5 to 10 s on my debug build and the test allows 5 s. It times out in 2 of 3 runs on a debug build that does not contain this change.

@robobun
robobun force-pushed the farm/9fdeb2b5/fetch-replay-sendfile-body-on-redirect branch from 12f28f7 to 7e81605 Compare September 30, 2026 21:32

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the unsafe detach_lifetime borrow in buffer_sendfile_body_for_tls: start() builds self.state fresh immediately before start_(), so re-seating it there loses no per-hop state, the body is Bytes for every later hop so the Vec is never reassigned while borrowed, and both teardown sites drop buffered_sendfile_body after state.

Extended reasoning...

The change makes the HTTP client replay a Sendfile body across redirects and buffer it into memory when a hop lands on TLS or a CONNECT tunnel, touching InternalState, SendFile, the redirect paths and two teardown sites; it touches no auth or crypto surface. Two findings are being posted inline (synchronous file read on the shared HTTP thread, and the keep-alive retry predicate still excluding Sendfile), so approval is not on the table; this note only records the lifetime and drop-order checks that were examined and ruled out.

Comment thread src/http/lib.rs
Comment on lines +2946 to +2947
debug_assert!(self.buffered_sendfile_body.is_empty());
self.buffered_sendfile_body = sendfile.read_to_vec()?;

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.

🟡 (optional) Every other in-flight fetch in the process stalls while a large Bun.file() body is read into memory on the HTTP thread when a redirect (or env https proxy) moves it onto TLS. buffer_sendfile_body_for_tls at src/http/lib.rs:2947 calls read_to_vec synchronously on the single shared HTTP thread, allocating and pread-ing the whole content_size (the full file, possibly GBs) before the hop can start. Fix: keep the HTTP thread non-blocking for this fallback, e.g. fail the hop with RequestBodyNotReusable (the spec-conformant option the PR itself names) or bounce the read to the JS/IO side, while still buffering small files so the http->https redirect case keeps working.

Why this was flagged

A user calls fetch("http://...", { method: "PUT", body: Bun.file(largePath) }) where the file is >= 32 KiB, so src/runtime/webcore/fetch.rs:1599-1629 turns the body into HTTPRequestBody::Sendfile. The first hop answers 307 with an https Location (or http_proxy=https://... is set in the environment). do_redirect calls start() (src/http/lib.rs:2703) and start_:: runs buffer_sendfile_body_for_tls (src/http/lib.rs:2793). That function calls SendFile::read_to_vec (src/http/SendFile.rs:27-38), which try_reserve_exact + resize the full content_size and pread_all the whole range synchronously on the HTTP thread. For a multi-hundred-MiB or GiB file on a cold disk this blocks the one HTTP thread for seconds and holds the whole file in memory, so every other fetch() in the process (unrelated requests, timeouts, keep-alive handling) stops progressing until the read finishes. On the base branch this hop sent Content-Length: 0 (or panicked for the env-proxy case), so the stall is new.

Verification: buffer_sendfile_body_for_tls (src/http/lib.rs:2937-2954) does self.buffered_sendfile_body = sendfile.read_to_vec()?; when IS_SSL or http_proxy.is_some() && url.is_https(). SendFile::read_to_vec (src/http/SendFile.rs:27-38) does try_reserve_exact(self.content_size), then pread_all, which loops pread until the whole buffer is filled. Nothing yields to the uws loop, so every other fetch on the process's HTTP thread makes no progress.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cost is real. The read is synchronous on the HTTP thread and holds content_size bytes, and http to https is a common redirect. I am evaluating a version that keeps the body as Sendfile on a TLS hop and reads the file in bounded chunks in the Body and ProxyBody write arms. That removes this function and the owned buffer. The current head stays unchanged until that is decided. I will post the result here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: the head stays at 7e81605. A merge does not wait on me.

The cost of this read is about 0.55 ms per MiB with a warm cache (563 ms for 1 GiB), and the file stays in memory until the request ends. A pre-merge check on this head reported 0.1 to 1 s and 1024 MB for 1 GiB. The same file sent to https from the start costs 1 s and 2048 MB on main, because that path reads the whole file on the JS thread. So this fallback is not worse than the https upload path that exists today.

A read in bounded chunks in the write path is still the better end state. It applies to uploads that start on https too, so it is a separate change and not part of this PR. The PR body now lists this cost under Downsides.

Comment thread src/http/lib.rs
Comment on lines +2186 to 2187
// Only an in-memory body is retried; a Stream body is consumed as it is written.
&& matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))

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.

🟣 pre-existing, not blocking: pre-existing: an idempotent upload of a Bun.file() of 32 KiB or more on a stale keep-alive socket still fails with ConnectionClosed, while a smaller file is retried transparently. The retry predicate at src/http/lib.rs:2187 still admits only HTTPRequestBody::Bytes even though this PR makes a Sendfile body replayable (the write cursor now lives in state.sendfile, so original_request_body stays pristine). Fix: let the keep-alive retry restart Sendfile bodies too, i.e. match Bytes() | Sendfile() there, so the same sibling site as do_redirect uses the pristine body; the PR says this site was left as is on purpose, but the reason given (the body is consumed as it is written) no longer holds after this change.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls fetch(url, { method: "PUT", body: Bun.file(path) }) with a file of st_size >= 32 KiB to a plain http:// origin, so src/runtime/webcore/fetch.rs:1629 hands the HTTP thread an HTTPRequestBody::Sendfile. The client picks a pooled keep-alive socket that the server has already closed; on_close runs with in_progress true and response_stage before Body. At src/http/lib.rs:2187 the retry requires matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)), so the Sendfile request falls through to self.fail(crate::Error::ConnectionClosed) at src/http/lib.rs:2202 and the fetch rejects. A file below 32 KiB is a Bytes body and is silently retried by self.start(body) at src/http/lib.rs:2198. The base branch fails the same way, but on the base the exclusion was justified because SendFile::write mutated original_request_body; after this PR InternalState::init seats a separate cursor in state.sendfile (src/http/InternalState.rs:159-161) and do_redirect already replays the pristine Sendfile through request_body_for_redirect (src/http/lib.rs:2713).

Verification: An idempotent fetch with a Bun.file() body of st_size >= 32 KiB to a plain http:// origin is assigned a pooled keep-alive socket that the server has already closed. on_close only retries if matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) (src/http/lib.rs:2187); a Sendfile body falls through to self.fail(crate::Error::ConnectionClosed) at line 2203.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. The old reason does not hold for Sendfile now that the send cursor lives in state.sendfile. The behavior at this site is the same as on main. I am deciding this together with the thread on buffer_sendfile_body_for_tls, and I will reply here with the outcome.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: not in this PR. The head stays at 7e81605, so a merge does not wait on this. A retry for Sendfile bodies at this site is a follow-up. The PR body lists it under Downsides.

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.
@robobun
robobun force-pushed the farm/9fdeb2b5/fetch-replay-sendfile-body-on-redirect branch from 7e81605 to 1057464 Compare October 2, 2026 13:16
@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

The head moved from 7e81605 to 1057464. Main changed src/http/InternalState.rs and the PR had a merge conflict. I rebased onto main bc7a813. The one conflict was an import line: main added use crate::decompressor::has_zlib_header beside the import that this PR changes. The added and removed lines of the PR are the same as before.

Checks on the rebased debug build: fetch-redirect.test.ts passes (43 of 43). In proxy.test.ts the two tests of this PR pass. Two or three other subprocess tests in that file hit the 5 s test timeout on my machine, a different set in each run, while the host load average was about 1000. Each of them passes when it runs alone. A check that was pinned to 7e81605 must run again for the new head.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/http/SendFile.rs — pre-existing: a fetch whose Bun.file() body was truncated on disk between redirect hops now hangs for the 300 s idle timeout instead of resolving. SendFile::write treats a zero-byte sendfile(2) with bytes still owed as Status::Done at src/http/SendFile.rs:73-75, so the replayed plain-http hop declares the original Content-Length and then stops short. Fix: when sendfile returns 0 (or success with sbytes == 0) while remain > 0, return Status::Err(crate::Error::RequestBodyLengthMismatch) so the hop fails fast like the stream path at src/http/lib.rs:3165, on the Linux, FreeBSD and macOS arms alike (SendFile.rs:73, :103, :134). The TLS hop already handles this by truncating in read_to_vec; only the plain-socket replay is left inconsistent.

    Why this was flagged

    A user calls fetch("http://...", { method: "POST", body: Bun.file(path) }) with a file of 32 KiB or more, the first hop answers 307, and the file is truncated before hop 2 starts. src/http/lib.rs:2713 replays the pristine Sendfile cursor and InternalState::init seats it at src/http/InternalState.rs:160-167, so hop 2 sends Content-Length: content_size from the original size. In the Body arm at src/http/lib.rs:3492 SendFile::write runs sendfile(2); once the shortened file hits EOF the syscall returns 0 with self.remain > 0, and src/http/SendFile.rs:73-75 returns Status::Done, setting request_stage = Done at src/http/lib.rs:3495. The server keeps waiting for the owed bytes, so the request sits until IDLE_TIMEOUT_SECONDS (300, src/http/lib.rs:313) fires and the fetch rejects with Timeout. On the base branch hop 2 went out with Content-Length: 0 and resolved 200 immediately, so the hang on this path is new, while the same Done-on-short-read exists on the base for a truncation during the first hop (pre-existing).

    Verification: src/http/SendFile.rs:73-75 reports a zero-byte sendfile(2) with remain > 0 as Done; the FreeBSD/other-unix arms at :103-106 and :134-137 do the same. src/http/lib.rs:2713 forwards the pristine Sendfile body, and the Body arm at lib.rs:3492-3497 maps Status::Done to request_stage = Done. The base branch already hangs by this route on a single-hop truncation, so this is pre-existing.

@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

On the review finding about a file that is truncated between hops: that case is real and it is listed under Downsides in the description. I reproduced it on a debug build of this PR (hop 2 sends Content-Length: 4194304 and then 65536 bytes, and the fetch is still pending after 8 s, 3 of 3 runs). Main has the same hang with no redirect when a file is cut during the upload.

The fix belongs in SendFile::write, for all three sendfile(2) arms, and it is a separate change that revives #36212. That work is in progress in another pull request. The suggestion to report it with the existing RequestBodyLengthMismatch error, like the stream path, is a good one, and I passed it on.

@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Status: this fix is included in #46528, as SendFile::rewind() in request_body_for_redirect plus SendFile::write_copy() for TLS and tunnel hops.

I ran the repro of this PR and a grid of 40 cells (5 sizes, http or https target, 307 or 308, Bun.file() or Buffer) against a debug build of #46528 at a919d6b. Every cell arrives whole. Bun 1.4.3 loses 12 of them.

The two limits in this PR's Downsides (the keep-alive retry of a file body, and a file that shrinks between hops) are the same on #46528. They are listed with tests in #46528 (comment).

This branch has not been deployed

No deployments
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