Skip to content

Bun.file: stop a file's stat size from capping reads of the whole file - #33360

Open
robobun wants to merge 1 commit into
mainfrom
farm/c897cd79/bun-file-stat-size-read-limit
Open

Bun.file: stop a file's stat size from capping reads of the whole file#33360
robobun wants to merge 1 commit into
mainfrom
farm/c897cd79/bun-file-stat-size-read-limit

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Bun.file(p) reads nothing once anything has resolved the file's size, for any file whose stat size is a lie. procfs, sysfs and cgroupfs regular files all report st_size == 0 and still read hundreds of bytes, and exists()-then-read is the canonical usage sequence, so this reads as "procfs is flaky in Bun".

Repro

const p = "/proc/version"; // any procfs / sysfs / cgroup file

await Bun.file(p).text(); // "Linux version ..." (212 bytes) — a fresh blob is fine

const f = Bun.file(p);
await f.exists(); // true, but this caches the stat size (0) onto the blob
await f.text(); // "" — and so does every later read of f

Reading f.size does the same. .text(), .bytes(), .arrayBuffer(), .json() and .stream() are all affected, and three paths are broken on a fresh blob too, because they resolve the size themselves:

// all of these resolve the size before reading
await drain(new Response(Bun.file(p)).body); // 0 bytes, expected 212
await fetch(url, { method: "POST", body: Bun.file(p) }); // sends 16 bytes, expected 212

const form = new FormData();
form.append("f", Bun.file(p)); // the part body is empty

The same conflation widens a slice. Body::to_readable_stream calls resolve_size() unconditionally, and the File arm overwrites a slice's concrete length with max_size - offset:

await Bun.write("/tmp/hello.txt", "hello world");
const f = Bun.file("/tmp/hello.txt");
await drain(new Response(f.slice(0, 5)).body); // "hello world", expected "hello"

Cause

Blob.size means two different things at once: the number of bytes the view spans, and the upper bound on a read of it. For a file-backed blob the first is only ever a stat hint, but resolve_size() caches that hint into size, and every file read path then uses size as its max_length:

  • Blob::do_read_file / do_read_file_internal pass it to ReadFile
  • ReadableStream::from_blob_copy_ref passes it to FileReader::max_size
  • FormDataContext and fetch_impl pass it to node_fs::read_file's max_size

ReadFile compounds it with a "skip reading an empty regular file" fast path that consults the store's cached mode rather than the mode it just fstat'd, so it only fires once something has already statted the file. That is precisely why a fresh Bun.file(p).text() works and a post-exists() one does not.

node_fs's read_file already gets this right, and has since #1220:

// For certain files, the size might be 0 but the file might still have contents.
// https://github.com/oven-sh/bun/issues/1220

Passing max_size: Some(..) there sets has_max_size and disables that tail read, which is how the FormData and fetch upload sites lost the content.

Fix

Separate the two meanings. Blob::size_is_explicit records that size is a bound the caller asked for with slice(); Blob::read_limit() returns MAX_SIZE ("read until EOF") otherwise, and every file read path uses it. resolve_size() / resolved_size() no longer overwrite a caller-supplied bound for file stores, and ReadFile's unsound fast path is gone — an empty regular file now costs one read() returning 0, which is what the fresh-blob path already did.

ReadFileUV (Windows) keeps its equivalent fast path: it tests the mode from the fstat it just performed, and NTFS regular files do not under-report their size.

.size is unchanged and still matches fs.statSync(p).size, including 0 for procfs and for genuinely empty files.

Relationship to #32794

#32794 fixes the slice widening above from the other side, by clamping a concrete size against the store's size in both arms of resolve_size(). For file stores that clamp is against max_size, which is the value that lies — Bun.file("/proc/version").slice(0, 10) would clamp to min(10, 0) == 0. Leaving a caller-supplied bound alone, as this PR does, is the behaviour both want. The two PRs overlap only on the File arm of resolve_size() / resolved_size(); whichever lands second should take that arm from here. Everything else is disjoint.

Verification

test/js/bun/util/bun-file-read.test.ts. The procfs cases are skipIf(!isLinux); the slice, empty-file and regular-file cases run everywhere.

before (released 1.4.0) / after
$ USE_SYSTEM_BUN=1 bun test test/js/bun/util/bun-file-read.test.ts
(fail) reading a procfs file is not capped by its stat size
(fail) streaming a procfs file is not capped by its stat size
(fail) a procfs file appended to FormData carries its contents
(fail) a procfs file uploaded as a fetch body is sent whole
(fail) a sliced Bun.file() keeps its bounds when read as a Response body
(pass) a sliced Bun.file() keeps its bounds when uploaded as a fetch body
(pass) an empty file still reads empty after exists()
(pass) a regular file still reads after exists()
 4 pass, 5 fail

$ bun bd test test/js/bun/util/bun-file-read.test.ts
 9 pass, 0 fail

Also green: blob.test.ts, bun-file.test.ts, bun-file-fd-read.test.ts, empty-file.test.ts, structured-clone-blob-file.test.ts, blob-cow.test.ts, blob-write.test.ts, bun-serve-file.test.ts, fetch-file-upload.test.ts, FormData*.test.ts, body-stream.test.ts, streams.test.js. bun run rust:check-all passes on all 10 targets.

A BunFile's `size` is both "how many bytes this view spans" and "how many
bytes a read of it may return". `resolve_size()` caches the stat size onto
the blob, and procfs/sysfs/cgroupfs regular files report `st_size == 0`
while reading more, so `await f.exists()` or reading `f.size` turned every
later read of that BunFile into an empty one.

Track whether `size` is a bound the caller asked for with `Blob.slice()`,
and read to EOF when it isn't. `read_file`'s "skip reading an empty regular
file" fast path went with it: it consulted the store's cached mode, so it
only fired once something had already statted, and it is unsound for a file
whose stat size lies. `node_fs`'s `read_file` has handled this since #1220.
@coderabbitai

coderabbitai Bot commented Jul 5, 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: 3 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: 9f85a83a-25e3-4e5e-a19a-4ce0b528abc9

📥 Commits

Reviewing files that changed from the base of the PR and between fb50cce and 938ebc6.

📒 Files selected for processing (6)
  • src/jsc/webcore_types.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/ReadableStream.rs
  • src/runtime/webcore/blob/read_file.rs
  • src/runtime/webcore/fetch.rs
  • test/js/bun/util/bun-file-read.test.ts

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

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. --env-file and Bun.file().text() silently drop data on FIFOs; breaks 1Password's local-env-file integration #30520 - FIFOs report st_size == 0, causing Bun.file().text() to return empty; this PR fixes stat-size-0 capping reads
  2. Calling BunFile.exists makes Bun.write write nothing #4930 - Calling .exists() caches size as 0, making subsequent Bun.write write nothing; this PR fixes resolve_size() from permanently capping reads
  3. BunFile .text() does not return correct content after BunFile.write() #23902 - .text() returns stale content after .write() because cached stat size caps reads to the old length; fixed by this PR's read_limit() changes
  4. await BunFile.exists() does not change #22484 - .exists() caches its result and never re-checks; this PR's changes to resolve_size() fix the caching behavior

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #30520
Fixes #4930
Fixes #23902
Fixes #22484

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Take the native blob path for Response-wrapped Bun.file() streams #31674 - Both independently fix Blob::resolve_size/resolved_size overwriting a concrete slice size with store_size - offset, widening the view to the end of the file

🤖 Generated with Claude Code

@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 didn't find any bugs, but this threads a new semantic (size_is_explicit / read_limit()) through the core Blob struct and five separate file-read paths, removes a fast path in ReadFile, and explicitly overlaps with #32794 on resolve_size() — worth a human look, especially for merge-order coordination.

Extended reasoning...

Overview

This PR fixes Bun.file() reads being capped (often to zero bytes) by a cached stat size for files whose st_size lies — procfs/sysfs/cgroupfs regular files. It also fixes the inverse: sliced file blobs having their explicit bounds widened by resolve_size(). The fix adds a size_is_explicit: Cell<bool> field to the #[repr(C)] Blob struct in src/jsc/webcore_types.rs and a read_limit() accessor that returns MAX_SIZE unless the caller set an explicit bound via slice(). Every file-read path — do_read_file/do_read_file_internal in Blob.rs, FileReader::max_size in ReadableStream.rs, node_fs::read_file's max_size in both FormDataContext and fetch_impl — is switched from size.get() to read_limit(). resolve_size()/resolved_size() gain an early-return guard for explicit bounds on file stores. The POSIX ReadFile::run_async_with_fd fast path that skipped reading when size == 0 && is_regular_file(store.mode) is removed (it consulted the store's cached mode, not the fresh fstat, which is why the bug only fired after exists()). Nine new tests cover procfs reads, streaming, FormData, fetch upload, and slice bounds.

Security risks

None identified. The change loosens a read cap from a stat-derived hint to EOF for whole-file reads, and preserves caller-supplied slice bounds. There's no new user-controlled input parsing, no path handling changes, and no auth/crypto surface. The removed fast path traded correctness for one read() syscall on genuinely-empty files, which is not security-relevant.

Level of scrutiny

High. This is a semantic redesign of what Blob.size means for file-backed blobs, threaded through a #[repr(C)] struct (the JS wrapper's m_ctx payload — Rust-owned so field additions should be layout-safe on the C++ side, but every Rust-side struct-literal construction site must carry the new field, and dupe_with_content_type/structured-clone paths must propagate it correctly). It touches five runtime files across Blob, streams, fetch, and FormData. It removes an optimization. And the description explicitly calls out an overlap with open PR #32794 on the File arm of resolve_size()/resolved_size(), with a note that whichever lands second should adopt this PR's version of that arm — that's a merge-coordination decision a human should make.

Other factors

The PR description is exceptionally thorough (repro, root cause, per-path fix rationale, before/after test output, and a list of 12 adjacent test files verified green plus rust:check-all on all targets). The test coverage is good: procfs cases are skipIf(!isLinux) and the slice/empty/regular-file cases run everywhere. The Windows ReadFileUV fast path is deliberately kept (it uses the fresh fstat's mode and NTFS doesn't under-report), which is a reasonable asymmetry but worth a reviewer confirming. No prior human or bot review comments exist beyond build-status noise.

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.

1 participant