Skip to content

fetch: read a Bun.file() body with st_size 0 to EOF, up to the JS size limit - #43964

Open
robobun wants to merge 1 commit into
mainfrom
robobun/5049e0bb/read-file-st-size-zero
Open

robobun wants to merge 1 commit into
mainfrom
robobun/5049e0bb/read-file-st-size-zero

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fetch(url, { body: Bun.file("/proc/kallsyms") }) uploads exactly 262160 bytes of a 12 MB file, with no error. A procfs file is a regular file that reports st_size 0. .slice(1) and .slice(0, 1 << 20) are cut at the same place.
  • Cause: the upload passes max_size = Some(blob.size) to NodeFS::read_file (fetch.rs:1655). Any Some sets has_max_size (node_fs.rs:7211), which stops the read at the 256 KiB scratch buffer.

Fix

  • For a blob caller, read_file reads a regular file with st_size 0 to EOF. The caller's window still clamps a slice.
  • That read has the 4 GiB size limit that fs.readFile has. /proc/self/pagemap has no practical end and fails with ENOMEM.
  • A file with st_size above 0, a device and a FIFO are read as before.
  • Verified: test/js/bun/http/fetch-file-upload.test.ts. Two of three new tests fail on bun 1.4.3.

Background

  • read_file fills a 256 KiB scratch buffer, calls fstat, then reads st_size bytes. Without a caller bound it continues to EOF (readFileSync not reading values in /proc. #1220).
  • Considered "no bound when the bound is the MAX_SIZE sentinel". It left slices cut and unbounded every regular file. Node's readFile uses the st_size == 0 rule.

Downsides

  • An upload of /proc/self/pagemap reads 4 GiB on the JS thread, then fails (7.4 s, 4.7 GB RSS, debug build). Before, it sent 262160 bytes at once. fs.readFileSync does the same on bun 1.4.3.
  • None for other files. Syscalls per upload, main and this PR: 20000 B 5 and 5, empty file 4 and 4.
  • Still wrong: a FormData part over 256 KiB also needs FormData: read a Bun.file() part whose stat size is 0 #41590.
Notes

Where this comes from. #41590 first held three sites. A review of it found that "read a regular file to EOF" has no bound for /proc/self/pagemap, that slices were still cut, and asked for one site per PR. This PR is the read_file site. #41590 keeps the FormData serializer and #43965 has the module loader.

Upload sizes (bytes the server receives. bun 1.4.3 canary 367d939, and a debug build of this branch):

body bun 1.4.3 this PR
/proc/kallsyms (12340250 B) 262160 12340250
/proc/kallsyms .slice(1) 262160 12340249
/proc/kallsyms .slice(0, 1 << 20) 262160 1048576
/proc/version (219 B) 219 219
/dev/zero 262160 262160
/proc/self/pagemap 262160, at once ENOMEM after 4 GiB

The bound. A blob caller that reaches the new path gets limit_size_for_javascript, the check that fs.readFile already uses: ENOMEM when the bytes read pass the size limit of a JS buffer (u32::MAX), or on Linux the size of the memory. The test lowers the limit to 1 MiB with setSyntheticAllocationLimitForTesting. A smaller fixed bound for this path is a policy choice and is one line to change.

Sibling readers and /proc/self/pagemap on bun 1.4.3: fs.readFileSync throws ENOMEM after 6.8 s at 4.0 GB RSS. await Bun.file(p).bytes() has no bound (killed at 11.7 GB RSS after 16 s).

No cancel. The two blob callers read on the JS thread with no AbortSignal and no VM stop check. #36259 and #40207 add those to fs.readFile. They do not reach the blob callers.

FormData. The serializer calls resolve_size() first, so on this branch alone a procfs part is still empty. With #41590 and this PR together, a part over 256 KiB has its full content, and a part for /proc/self/pagemap throws ENOMEM from the serialize. A test for that pair goes into the PR that lands second.

#41488 changes the same line to max_size != BLOB_SIZE_MAX and streams a device or FIFO body before it reaches read_file. It is in conflict with main. The /dev/zero test here pins the bound that a device has when it does reach read_file.

Syscall counts. Counted with a ptrace counter on debug builds, because strace is not installed in the container. An upload of an ordinary 20000 B file: openat, fstat, read x2, close on both builds. An upload of an empty file: openat, fstat, read, close on both builds.

Suites run on the debug build. test/js/bun/http/fetch-file-upload.test.ts (14 pass), test/js/node/fs/fs.test.ts, test/js/web/fetch/blob.test.ts, test/js/bun/util/bun-file-read.test.ts, test/js/web/html/FormData.test.ts. cargo check -p bun_runtime passes for linux, aarch64-apple-darwin and x86_64-pc-windows-msvc.

@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.3 (canary 367d939). fetch(url, { method: "POST", body: Bun.file("/proc/kallsyms") }) sends 262160 of 12340250 bytes, and .slice(1) and .slice(0, 1 << 20) send 262160 bytes too. With this branch the server receives 12340250, 12340249 and 1048576 bytes. /dev/zero stays at 262160 bytes. /proc/self/pagemap fails with ENOMEM at the size limit. Two of the three new tests in test/js/bun/http/fetch-file-upload.test.ts fail on 1.4.3 and all pass with this branch.

@coderabbitai

coderabbitai Bot commented Sep 25, 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: 910d026c-c377-4a79-888c-e32a7421501d

📥 Commits

Reviewing files that changed from the base of the PR and between f8ea6fc and 67904c0.

📒 Files selected for processing (1)
  • test/js/bun/http/fetch-file-upload.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

Regular files that report a size of zero now use adjusted size limits and JavaScript-size checks during reads. Upload tests cover procfs files, /dev/zero, and an ENOMEM failure under a lowered allocation limit.

Changes

Zero-Size File Uploads

Layer / File(s) Summary
Zero-size reads and upload validation
src/runtime/node/node_fs.rs, test/js/bun/http/fetch-file-upload.test.ts
The read loop applies JavaScript-size checks to zero-size regular files when max_size is supplied, without treating that value as the usual file-size cap. Tests cover whole-file and sliced procfs uploads, bounded /dev/zero uploads, and an ENOMEM read failure.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 67904

The zero-size file upload change is ready to merge after normal checks; no actionable issue remains from this review.

🚥 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 main change: reading Bun.file() bodies for regular files with st_size 0 through EOF, subject to the JavaScript size limit.
Description check ✅ Passed The description explains the problem, root cause, fix, behavioral boundaries, risks, test results, and verification commands. It does not use the exact template headings, but it provides the required …

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

A procfs file is a regular file that reports st_size 0 and has content.
fetch() passes the size of the blob as max_size, and any max_size
stopped the read at the 256 KiB scratch buffer plus 16 bytes. An upload
of a 12 MB procfs file sent 262160 bytes with a matching
Content-Length. Slices were cut at the same place.

For a caller with max_size, a regular file with st_size 0 now has an
unknown size: the read continues until EOF, clamped by the caller's
window. That read has the size limit of a JS buffer, which fs.readFile
already has, so a file with no practical end (/proc/self/pagemap) fails
with ENOMEM.

A file with st_size above 0, a device and a FIFO take the same path as
before.

@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 that the new size_unknown gate at node_fs.rs:7212 is keyed on S_ISREG, so device and FIFO bodies (/dev/zero, pipes) and regular files with a non-zero st_size keep the exact pre-PR has_max_size behavior, and that fs.readFile/readFileSync (no max_size) are unaffected since both new conditions require args.max_size.is_some().

Extended reasoning...

The change is confined to the post-fstat sizing logic of NodeFS::read_file in src/runtime/node/node_fs.rs plus three subprocess tests in test/js/bun/http/fetch-file-upload.test.ts; it touches no auth, crypto, or injection surface. Inline findings were posted on the slice/growth-arm interaction and on serial test execution, and one further verified finding was withheld, so approval is not appropriate; this note only records the sibling paths that were checked and found unchanged.

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

  • 🟡 src/runtime/node/node_fs.rs — Uploading a slice of a procfs file can now fail with ENOMEM after every byte of the window was already read, and always pays one useless buffer doubling. At node_fs.rs:7286 the growth arm runs when total == max_size because has_max_size is false for a size-0 regular file, so try_reserve(8192) at :7293 doubles a full buffer (e.g. 512 MiB to 1 GiB) although upper at :7271 can never exceed max_size. If that allocation fails the read returns ENOMEM/read instead of the complete window. Fix: skip the growth arm (and break) once total >= max_size, i.e. only grow when the window still has room, so a slice window on a size-0 file never allocates beyond max_size.

    Why this was flagged

    Trigger: fetch(url, { body: Bun.file(procfsPath).slice(0, N) }) (fetch.rs:1655 passes max_size = Some(N)) or a FormData part with a window (Blob.rs:3689) on a regular file whose fstat reports st_size == 0. size_unknown at node_fs.rs:7212 makes has_max_size false at :7213. The read that fills the buffer to exactly max_size returns amt != 0 with total > size (size is the pre-stat total, :7217), so the arm at :7286 runs: truncate then try_reserve(8192) at :7293 with len == capacity triggers Vec amortized growth to 2x capacity. upper at :7271 is capped at max_size, so the new capacity is never used; the next read is empty and breaks at :7304. On the base branch has_max_size was true for every blob caller, so this arm never ran and no allocation beyond max_size + 16 happened. Consequence: transient 2x peak memory for the window, a shrinking realloc in into_boxed_slice at :7341, and, when the doubled allocation fails, a spurious ENOMEM for an upload whose bytes were all already read. The per-read check at :7275 does not guard this because it measures total, not…

    Verification: nit. Trigger: a blob caller (fetch.rs:1655 rf_args.max_size = Some(blob_size), or Blob.rs:3689) reading a regular file whose fstat reports st_size 0 (procfs) with a slice window W larger than the 256 KiB pre-stat scratch and file content >= W, when W leaves less than 8192 bytes of spare capacity in the doubling sequence (all power-of-two windows from 1 MiB to 64 MiB qualify). Mechanism…

// it on the JS thread. The read must go to EOF, or to the end of a slice, and
// not stop at the 256 KiB scratch buffer. The child reads its own
// /proc/self/environ, which three 100 KB variables make larger than that.
test.skipIf(!isLinux)("uploads a procfs file and its slices, not the stat size", async () => {

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.

🟡 nit (optional): the three new subprocess tests run serially, so the file gets slower on the debug/ASAN lane than it needs to be. Each spawns its own child and Bun.serve({port: 0}) and shares no state, and the sibling slice tests in this file at test/js/bun/http/fetch-file-upload.test.ts:168 already use test.concurrent. Fix: declare the three new cases with test.concurrent (and test.concurrent.skipIf(...)), covering the tests at lines 257, 307 and 325.

Why this was flagged

The new tests at test/js/bun/http/fetch-file-upload.test.ts:257, :307 and :325 each spawn a child through runChild and wait for it to finish before the next starts. The procfs child reads and uploads a >270 KB body four times and the pagemap child reads 1 MiB from /proc/self/pagemap before failing; on a debug+ASAN build these serial waits add up. test/CLAUDE.md asks that independent subprocess tests in one file be test.concurrent, and the existing slice tests in this file at :168 and :201 follow that. Nothing is functionally wrong; the base file simply has fewer tests.

Verification: nit. Triggering condition: any run of this file (notably the debug/ASAN lane), where the three new subprocess tests execute one after another. Verified in the diff of /home/claude/bun/test/js/bun/http/fetch-file-upload.test.ts: the new cases are declared test.skipIf(!isLinux)("uploads a procfs file and its slices, ...") (L257), test.skipIf(isWindows)("upload of a device with no EOF ...")…

Comment on lines +7212 to +7213
let size_unknown = sys::S::ISREG(stat_.st_mode as u32) && stat_.st_size == 0;
let has_max_size = args.max_size.is_some() && !size_unknown;

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: a user who touches file.size (or .exists()) before uploading a procfs file still sends an empty body, exactly as on the base branch. The blob getter calls resolve_size, which sets size to the stat's 0 for a regular file, so fetch.rs:1655 passes max_size = Some(0). At node_fs.rs:7213 size_unknown is then true but max_size 0 still caps upper at 7271, so zero bytes are read. Fix: for a size_unknown regular file treat a caller bound that came from st_size 0 (max_size == Some(0), or resolve to MAX_SIZE in Blob.rs:5805 for procfs) as unbounded, so both the resolved and unresolved blob reach EOF, while keeping a genuine slice(0,0) empty.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A script does const f = Bun.file("/proc/kallsyms"); f.size; await fetch(url, { method: "POST", body: f }). Blob.rs:2081 get_size calls resolve_size; Blob.rs:5805 sets file.max_size = st_size = 0 because the file is S_ISREG, and Blob.rs:2133 sets blob.size to 0. fetch.rs:1655 passes rf_args.max_size = Some(0). In read_file, node_fs.rs:7117 makes pre_stat_len 0 so nothing is read before fstat; node_fs.rs:7212-7213 compute size_unknown = true and has_max_size = false, but max_size stays 0 so node_fs.rs:7271 gives upper = 0 and the read returns 0 bytes, ending the loop with total 0. The server receives content-length 0. The base branch does the same, and the PR's own description only claims the unresolved case; the new size_unknown logic is the natural place where this sibling input is decided, and it still yields an empty upload.

Verification: pre-existing (base fails identically by the same route; the PR's fix does not reach this path). Trigger: any read of file.size (or file.exists(), which calls the same resolve_size) on a Bun.file() over a procfs-style regular file before it is used as a fetch body. Mechanism verified: - A fresh Bun.file() blob starts with size = MAX_SIZE… | pre-existing. Trigger: the script observes…

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