Skip to content

fetch: send the slice's Content-Length when uploading Bun.file().slice() via sendfile - #36862

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/e6d069df/fetch-sendfile-slice-content-length
Aug 4, 2026
Merged

fetch: send the slice's Content-Length when uploading Bun.file().slice() via sendfile#36862
Jarred-Sumner merged 1 commit into
mainfrom
farm/e6d069df/fetch-sendfile-slice-content-length

Conversation

@robobun

@robobun robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Found by the outbound-request-body fuzzer (ledger #11440).

Repro

// file is >= 32 KiB so the sendfile fast path is taken
require("fs").writeFileSync("/tmp/f.bin", Buffer.alloc(65536));
using server = Bun.serve({ port: 0, async fetch(req) {
  console.log("CL", req.headers.get("content-length"));
  await req.arrayBuffer();
  return new Response("ok");
} });
await fetch(server.url, { method: "POST", body: Bun.file("/tmp/f.bin").slice(10, 110) });
CL 65536            <- should be 100
(hangs: fetch() never settles)

The 100 slice bytes arrive correctly (right offset, right count); only the Content-Length header is wrong, so the origin waits for 65436 more bytes that never come. Cliff is exactly at a 32 KiB backing file; slice size is irrelevant.

Cause

src/runtime/webcore/fetch.rs's sendfile setup computed content_size as the whole file's stat.st_size for regular files, discarding the slice's own size:

let blob_size = if bun_sys::S::ISREG(stat.st_mode as u32) {
    stat_size                         // <- ignores the slice window
} else {
    original_size.min(stat_size)
};

remain was then separately clamped to the slice window, so sendfile(2) wrote the right bytes while HTTPRequestBody::Sendfile(sf).len() (= sf.content_size) produced the wrong Content-Length.

Fix

After the existing remain clamp for regular files, set content_size = remain; that is exactly the byte count sendfile will write. The now-redundant blob_size branch is dropped.

Verification

test/js/bun/http/fetch-file-upload.test.ts gains a describe covering slice uploads across the 32 KiB boundary (32767 / 32768 / 64 KiB / 1 MiB files) plus an open-ended slice(10). All four boundary cases and the open-ended slice time out on main and pass with this change. The existing whole-file sendfile roundtrip test in the same file continues to pass.

Related: #32794 fixes the same bug class on the Bun.serve response side; this is the fetch() client upload side.


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/bun/http/fetch-file-upload.test.ts

The sendfile fast path (plain http, no proxy/compress, file >= 32 KiB)
computed content_size as the whole file's stat size, ignoring the slice
window. remain was then clamped to the slice length, so the right bytes
went on the wire but Content-Length advertised the full file, and the
origin waited for bytes that never came.

Set content_size to the clamped remain for regular files; that is exactly
what sendfile will write.
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix verified locally; waiting on CI.

Reproduced with:

USE_SYSTEM_BUN=1 bun test test/js/bun/http/fetch-file-upload.test.ts -t slice   # 4 timeouts
bun bd test test/js/bun/http/fetch-file-upload.test.ts                           # 11 pass

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b5cc6dab-6dce-4c52-8483-e13e7009d8de

📥 Commits

Reviewing files that changed from the base of the PR and between 5544d69 and 6a4d932.

📒 Files selected for processing (2)
  • src/runtime/webcore/fetch.rs
  • test/js/bun/http/fetch-file-upload.test.ts

Walkthrough

Changes

Sendfile slice uploads

Layer / File(s) Summary
Align sendfile content size with the transmitted slice
src/runtime/webcore/fetch.rs
Sendfile requests derive content_size from blob and filesystem sizes. Regular files use the clamped remaining byte count.
Validate bounded and open-ended slice uploads
test/js/bun/http/fetch-file-upload.test.ts
Concurrent tests verify Content-Length and transmitted bytes for bounded and open-ended Bun.file().slice() bodies across sendfile threshold sizes.

Possibly related PRs

  • oven-sh/bun#33659: Introduces explicit slice-size semantics used by sendfile content-length handling.
  • oven-sh/bun#36212: Covers sendfile-based Bun.file() uploads and mid-upload truncation behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the fetch sendfile fix for Bun.file().slice() Content-Length handling.
Description check ✅ Passed The description explains the bug, cause, fix, reproduction, and verification, although it does not use the template headings.
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.

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

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

LGTM — the sendfile content_size now derives from the same clamped remain that governs how many bytes are actually written, so Content-Length matches the slice window.

What was reviewed

  • Traced whole-file (offset=0, size=MAX_SIZE=2^52-1): after the ISREG clamp remain=stat_size, so content_size=stat_size — unchanged from before.
  • Non-regular-file branch (Linux only; macOS bails earlier): initial content_size = original_size.min(stat_size) is byte-identical to the removed else arm.
  • Tests bracket the 32 KiB threshold (32767 exercises the read-file fallback as a control), assert exact Content-Length + received byte count + first/last byte, and cover the open-ended slice(10) case.
Extended reasoning...

Overview

Two-file change: src/runtime/webcore/fetch.rs (net −2 lines in the sendfile setup block) and new tests in test/js/bun/http/fetch-file-upload.test.ts. The bug was that for regular files ≥32 KiB, content_size was set to stat.st_size (whole file) while remain was independently clamped to the slice window — so sendfile(2) sent the right bytes but the advertised Content-Length was the whole file, hanging the origin.

The fix drops the redundant blob_size computation and, inside the existing ISREG clamp block, sets sf.content_size = sf.remain after remain has been clamped to [offset, min(offset+size, stat_size)) - offset. That is by construction the exact byte count sendfile will write.

Behavior preservation

I traced three cases against the old code:

  • Whole file (offset=0, size=MAX_SIZE where MAX_SIZE=(1<<52)-1): new remain clamps to stat_size, so content_size=stat_size — identical to the old ISREG → stat_size branch. The existing whole-file sendfile roundtrip test in the same file covers this.
  • Non-regular file (Linux only — macOS breaks out of 'use_sendfile for non-regular, Windows never enters via is_eligible): the ISREG block is skipped, so content_size keeps its initial value original_size.min(stat_size), which is exactly the old else arm. No change.
  • Sliced regular file: content_size now equals the clamped slice length instead of stat_size. This is the fix.

No overflow concern from (blob_offset + original_size): MAX_SIZE is 2^52-1, so adding a u64 offset stays well within u64/usize on 64-bit — and that line is unchanged by this PR anyway.

Security risks

None. This is a header-value correctness fix in the outbound HTTP client; no untrusted-input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. The production change is a 3-line rearrangement inside a single well-understood block, with no new control flow or types. The sendfile path is already gated by is_eligible (POSIX + plain http + no proxy + no compress) and the ≥32 KiB check.

Other factors

Tests follow harness conventions (tempDir, port: 0, await using, test.concurrent, exact-value assertions on a combined object). The 32767-byte case is a useful control that confirms the read-file fallback path was already correct. PR description confirms USE_SYSTEM_BUN=1 reproduces 4 timeouts and the debug build passes all 11. No prior reviews or outstanding comments.

@Jarred-Sumner
Jarred-Sumner merged commit d6302e9 into main Aug 4, 2026
43 of 48 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/e6d069df/fetch-sendfile-slice-content-length branch August 4, 2026 02:36
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…e() via sendfile (oven-sh#36862)

Found by the outbound-request-body fuzzer (ledger oven-sh#11440).

### Repro

```js
// file is >= 32 KiB so the sendfile fast path is taken
require("fs").writeFileSync("/tmp/f.bin", Buffer.alloc(65536));
using server = Bun.serve({ port: 0, async fetch(req) {
  console.log("CL", req.headers.get("content-length"));
  await req.arrayBuffer();
  return new Response("ok");
} });
await fetch(server.url, { method: "POST", body: Bun.file("/tmp/f.bin").slice(10, 110) });
```

```
CL 65536            <- should be 100
(hangs: fetch() never settles)
```

The 100 slice bytes arrive correctly (right offset, right count); only
the `Content-Length` header is wrong, so the origin waits for 65436 more
bytes that never come. Cliff is exactly at a 32 KiB backing file; slice
size is irrelevant.

### Cause

`src/runtime/webcore/fetch.rs`'s sendfile setup computed `content_size`
as the whole file's `stat.st_size` for regular files, discarding the
slice's own size:

```rust
let blob_size = if bun_sys::S::ISREG(stat.st_mode as u32) {
    stat_size                         // <- ignores the slice window
} else {
    original_size.min(stat_size)
};
```

`remain` was then separately clamped to the slice window, so
`sendfile(2)` wrote the right bytes while
`HTTPRequestBody::Sendfile(sf).len()` (= `sf.content_size`) produced the
wrong `Content-Length`.

### Fix

After the existing `remain` clamp for regular files, set `content_size =
remain`; that is exactly the byte count `sendfile` will write. The
now-redundant `blob_size` branch is dropped.

### Verification

`test/js/bun/http/fetch-file-upload.test.ts` gains a `describe` covering
slice uploads across the 32 KiB boundary (32767 / 32768 / 64 KiB / 1 MiB
files) plus an open-ended `slice(10)`. All four boundary cases and the
open-ended slice time out on `main` and pass with this change. The
existing whole-file sendfile roundtrip test in the same file continues
to pass.

Related: oven-sh#32794 fixes the same bug class on the `Bun.serve` response
side; this is the `fetch()` client upload side.

<!-- 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/bun/http/fetch-file-upload.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.

2 participants