Skip to content

Bun.file: do not cache the st_size of a pipe or a socket as the size of the file - #45718

Open
robobun wants to merge 7 commits into
mainfrom
robobun/d9a9b404/pipe-size-is-not-a-length
Open

robobun wants to merge 7 commits into
mainfrom
robobun/d9a9b404/pipe-size-is-not-a-length

Conversation

@robobun

@robobun robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On macOS, a read of piped stdin stops early after .size, exists() or a Response body stats Bun.stdin. No error.
  • Repro: head -c 1000000 /dev/zero | bun -e 'await Bun.stdin.exists(); console.log((await Bun.stdin.bytes()).length)' prints 65536 (macOS st_size emulated on Linux).
  • resolve_file_stat (src/runtime/webcore/Blob.rs) cached any positive st_size as the file size. macOS and the BSDs report the bytes buffered in a pipe or a socket there.

Fix

  • resolve_file_stat caches st_size only for a regular file, and for a directory as before. Other types have .size Infinity.
  • Correct: it is the only writer of the cached size.
  • Verified: test/js/bun/util/bun-file.test.ts. Both macOS lanes of build 127436 fail 8 cases on main. With the fix, both pass (build 128969). On Linux, the symlink case needs the fix.
  • Self-reviewed: 15 concerns raised, 14 addressed. Not done: an issue for exists() on a socket.

Background

  • A Blob is a window on a store. Bun.stdin uses a file store that caches one stat. An unknown size means "read to the end".
  • Considered a check in resolve_size: the wrong count stays in the store. A regular-file-only rule changes Bun.file(dir).size.

Downsides

  • On macOS, a finite Bun.stdin.size no longer means "stdin is piped". fs.openAsBlob() of a pipe changes too.
  • The first .size of a file runs 5 more instructions (264 to 269, x64). .text shrinks by 256 bytes.
  • Not fixed: a regular file keeps its first stat size (Calling BunFile.exists makes Bun.write write nothing #4930). See Notes.
Notes

How the bug shows

Nobody reported this. It came from a read of the code, and then from the macOS lanes below. #27849 is the same pattern on Linux, and #27852 repaired it.

  • More input than the pipe holds is cut on every run.
  • A small input is cut only if the writer has not finished at the first stat.
  • A first stat of an empty pipe gives Infinity and no cut. So echo hi | bun ... on a Mac usually shows the whole input.
  • A read with no stat before it is whole. Bun.write(Bun.stdout, Bun.stdin) is whole (bun-write.test.js:546 holds that on the macOS lanes). The reader exits with code 0. The writer can get SIGPIPE.

What puts the count on the handle: .size, exists(), structuredClone(), Bun.inspect() or console.log of a Response over it, and .body of that Response. lastModified puts the count in the store only: .size then reports it, but a text() right after lastModified is whole. Each of these has a row in the test.

What is then cut: text(), bytes(), stream(), slice(), a Response body, and process.stdin when the stat comes before its first use (ProcessObjectInternals.ts:119 builds it from Bun.stdin.stream()).

The clause || stat.st_size > 0 is from d5c81b7 (2022). That commit made an empty FIFO report Infinity. It kept st_size for every other case, and no test named a file type for that.

Evidence

Where Source of main This PR
darwin aarch64 and darwin x64, build 127436, commit 28694be (main plus the first 8 cases) 8 of 8 fail: 6 and first, where Infinity and first,second are expected
darwin aarch64 and darwin x64, build 128969, commit 58adc97 25 pass, 2 skip, 0 fail on each: all 18 pipe and socket rows pass. 181 of 181 jobs pass
Windows x64, build 128026, commit 1196561 7 pass, 20 skip, 0 fail
Linux x64, debug builds, st_size of each pipe and socket rewritten to its FIONREAD count by a ptrace tool 24 of 24 pipe and socket rows fail 24 of 24 pass
Linux x64, no tool the symlink row fails (Bun.file(fd).size is the length of the target), also on 1.4.2 38 of 38 pass

The rows changed twice after build 127436, and they keep the same questions and the same readers.

  • For builds 128026 and 128969, the first chunk is in a socketpair or a pipe before the reader starts. There is no FIFO and no shell. Each row also checks what fstat reported: 6 on macOS, 0 on Linux. So a row that passes on macOS also shows that the kernel reported the waiting bytes, which is the input that main takes for a size.
  • The last commit (d8c45be) runs 18 of the rows on a Bun.file(fd) in the test process. 6 rows stay in a child, because Bun.stdin, process.stdin, Bun.file(0) and Bun.file("/dev/stdin") need the pipe as stdin. The two Linux lines of the table are for this last shape. On macOS, two of its 18 rows ran in build 128969 in the same shape.

Build 128969 also confirms two expectations that came from source:

  • stat("/dev/stdin") of a pipe reports the buffered bytes on macOS (XNU fdesc_attr calls pipe_stat).
  • A directory has st_size 0 on Windows (fstat_handle in src/sys/lib.rs), so Bun.file(dir).size is Infinity there.

Measurements

x64 Linux, release builds of main (017b243) and of this PR.

What main PR
resolve_file_stat (nm -S) 579 bytes 509 bytes
.text (size) 106,738,870 106,738,614
first .size of a regular file by path, getter entry to return, user space (gdb single-step) 264 instructions, 30 conditional branches 269, 32
first .size of a directory 266 271
cached .size 38 instructions, 3 conditional branches 38, 3
size; exists(); lastModified; text() on a 64 KiB file, per iteration (ptrace counter, 300 and 600 iterations) 1 newfstatat, 1 fstat, 1 read the same
.size then text() of a pipe, st_size emulated: fstat on fd 0 3 3
the same: reads on fd 0 read(0, buf, 6) = 6, then stop 6, 6, 0
1000 .size, 5 exists(), 1 lastModified on Bun.stdin: fstat on fd 0 2 2
Bun.file(dir).size, ext4, by path and by fd 4096 4096
Bun.file("/proc").size Infinity Infinity
HEAD Content-Length of new Response(Bun.file(dir)) 4096 4096
writers of File.max_size 2 1

What else changes

  • A script that tests Bun.stdin.size !== Infinity to see if stdin is piped: with printf 'hello\n' piped in and the macOS st_size emulated, main prints read 6 and this PR prints skip. Linux prints skip today. !process.stdin.isTTY or fs.fstatSync(0).isFIFO() answer that question.
  • fs.openAsBlob(path) is Bun.file(path), so its size for a pipe changes with it. fs.openAsBlob(dir).size stays st_size.
  • On macOS, HEAD of a Response over a pipe sends content-length: 0 where it sent the buffered count (RequestContext.rs:2739). Linux sends 0 today. Not run on macOS.
  • On Linux, the fd of a symlink opened with O_PATH | O_NOFOLLOW has .size Infinity where it had the length of the target. No read of that fd works before or after. On macOS, an object from shm_open has no file type in st_mode, so it moves the same way (from source, not run).
  • Windows: no change. A pipe and a directory have st_size 0 there.
  • FreeBSD: the same line applies. It has no test lane. cargo check --workspace passes for x86_64-unknown-freebsd, aarch64-apple-darwin and x86_64-pc-windows-msvc.

Not fixed here

These are the same on main and on this PR, except where a line says so.

Three other places read st_size of a source that can be a pipe. They are different code with the same fact. This PR leaves them out on purpose: no CI lane can run a change to them, and none of them cuts a read on macOS or Linux. No issue tracks them. The table is from a read of the code, not from a run.

Site What it does Where it can matter
fetch.rs:1604 An upload of a file that is not regular, with st_size of 32 KiB or more, goes through sendfile with min(size, st_size) as its length FreeBSD pipes. macOS has an ISREG guard at :1595. On Linux a pipe reports 0.
copy_file.rs:878 (macOS) and :920 (FreeBSD) Bun.write compares the st_size of the source with the destination window before ftruncate A pipe source with a destination window. On macOS fcopyfile must first accept the pipe.
RequestContext.rs:1957 Bun.serve stores min(size, st_size) on the blob of a file body that is not regular The body length comes from sendfile.remain, which does not use that value.

Open PRs #41257 and #43896 rewrite the same block and keep the old clause. This PR copies the stat_file shape of #41257 so that the later merge is small. The PR that lands second must keep the new predicate. If the old clause comes back, the symlink row fails on Linux and the pipe rows fail on macOS.

Suites run on the debug build of this PR

bun-file.test.ts, regression/issue/27849.test.ts, bun-stdin-slice.test.ts, bun-file-exists.test.js, bun-file-fd-read.test.ts, bun-file-read.test.ts, filesink.test.ts, body-clone.test.ts (100 pass), blob.test.ts (147 pass), bun-serve-file.test.ts (172 pass), fetch-file-upload.test.ts, and fetch.test.ts -t "size is Infinity on a fifo". bun-write.test.js, body.test.ts and serve-file-slice-read-error.test.ts each have one or two tests that pass the 5 second default timeout on a debug build. The same tests do that on a debug build of main.


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/util/bun-file.test.ts

…ait in it

A pipe and a socket have no length. macOS and the BSDs report the bytes
that wait in one as st_size. The test asks Bun.stdin for its size (or
calls exists()) while the first chunk waits in it, then sends a second
chunk and expects the read to return both.

This commit has the test alone, so that the macOS lanes run it against
main's source.
@robobun

robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 AM PT - Oct 21st, 2026

❌ @robobun, your commit d8c45be has some failures in Build #129910 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 45718

That installs a local version of the PR into your bun-45718 executable, so you can run:

bun-45718 --bun

@robobun

robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

PR: #45718. The fix is green on both macOS lanes. The branch now has main merged in (a conflict in the test file), and CI runs on that merge.

How I reproduced it:

  • macOS, without the fix: both macOS test lanes of build 127436 ran the first 8 cases on the source of main (28694be, test only): darwin aarch64 and darwin x64. All 8 fail on each. For a socket and for a kernel pipe, the child prints 6 and first, where Infinity and first,second are expected.
  • macOS, with the fix: in build 128969 (58adc97), darwin aarch64 and darwin x64 each run test/js/bun/util/bun-file.test.ts with 25 pass, 2 skip, 0 fail. All 18 pipe and socket rows pass, and each row also checks that fstat reported the 6 waiting bytes. 181 of 181 jobs pass.
  • Linux with the macOS answer: under a ptrace tool that rewrites the st_size of each pipe and socket to its FIONREAD count, a debug build of main fails 24 of 24 pipe and socket rows. The debug build of this PR passes 24 of 24.
  • Linux with no tool: the symlink row fails on main and on 1.4.2 (Bun.file(fd).size is the length of the link target). It passes on this PR.
  • One line to try on a Mac: head -c 1000000 /dev/zero | bun -e 'await Bun.stdin.exists(); console.log((await Bun.stdin.bytes()).length)'. With the macOS st_size emulated on Linux, main prints 65536 and this PR prints 1000000.

autofix-ci Bot and others added 3 commits October 17, 2026 09:07
…of the file

macOS and the BSDs report the bytes buffered in a pipe or a socket as
its st_size. resolve_file_stat cached that count as the size of the
file store. .size reported it, and a later read through that handle
stopped there.

resolve_file_stat now runs one stat and one assignment. It caches
st_size for a regular file, and for a directory as before. A pipe, a
socket and every other file type get no length.

The tests put the first chunk in a socketpair or a pipe before the
reader starts, so they need no FIFO and no shell. Each row checks what
fstat reports, asks, and then reads to the end. They ask and read in
more ways than before. On Linux, the fd of a symlink shows the same
rule. Two more tests hold the size of a directory.
@robobun robobun changed the title Bun.file: do not take the bytes that wait in a pipe as its size Bun.file: do not cache the st_size of a pipe or a socket as the size of the file Oct 18, 2026
@robobun
robobun marked this pull request as ready for review October 19, 2026 17:15

@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 diffed the old and new max_size rule in resolve_file_stat: regular files and directories (including a directory with st_size 0, which was already Infinity) resolve exactly as before, and the only other consumer of file.max_size (the !seekable && max_size == MAX_SIZE branch in get_size, Blob.rs:2078) still yields Infinity for a pipe or socket. I also checked the O_PATH literal in the Linux-only symlink test: 0o10000000 is the value on both x86_64 and aarch64 Linux.

Extended reasoning...

The change narrows the st_size caching in Blob.rs's resolve_file_stat to regular files and non-empty directories, collapsing two duplicated Path/Fd arms into a stat_file helper, and adds pipe/socket/symlink/directory tests; it touches no auth, crypto or injection surface. The refactor preserves the regular-file and directory branches, and the sole remaining writer of max_size is this function, which is what this note records as ruled out.

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

  • 🟣 src/runtime/webcore/Blob.rs — On macOS a FormData part built from a pipe or socket is still truncated after this change, now at the node_fs initial buffer size instead of the buffered byte count, so the PR's "reads to the end" claim does not hold for this reader. Blob.rs:3686 passes blob.size (now MAX_SIZE) as rf_args.max_size, which sets has_max_size at node_fs.rs:7208 and disables buffer growth at node_fs.rs:7279. The pipe is read only up to the initial capacity and the rest is dropped silently. Fix: pass max_size as None when blob.size is MAX_SIZE so read_file grows to EOF for every unsized store, or resolve the pipe through the streaming reader.

    Why this was flagged

    Trigger: new FormData().append(name, Bun.stdin) or a fetch body over a pipe on macOS, then serialisation through Blob.rs:3661-3687. On the base branch macOS gave max_size = the buffered count N and read exactly N bytes. After the change blob.size is MAX_SIZE, Blob.rs:3686 still wraps it in Some, node_fs.rs:7208 sets has_max_size = true, node_fs.rs:7264 caps every read at buf.capacity() and node_fs.rs:7279 refuses to grow because has_max_size is set, so the part stops at the initial capacity (about 262,160 bytes per the PR's own Linux measurement). The dismissal called this pre-existing because Linux already passed MAX_SIZE, but the changed line is what now routes macOS into this truncating branch, and the PR text names no test for it. Population: macOS users uploading piped stdin larger than the initial buffer; silent data loss on every such upload. Remedy: send None instead of Some(MAX_SIZE) at Blob.rs:3686 so read_file reads to EOF.

    Verification: pre-existing. acknowledged in diff: the PR description's "Not fixed here" list states "A FormData part from a pipe stops at 262,160 bytes (seen on Linux with a 1,000,000 byte pipe, node_fs.rs:7210). A fetch body takes the same path." — and that bound is accurate per the code below; the author's claim that it is "the same on main and on this PR" is accurate on Linux and slightly off on macOS (base cut at the buffered count N, PR cuts at 262,160), so the PR makes this reader less wrong, not worse.

Comment thread src/runtime/webcore/Blob.rs
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: baa47f49-8346-45b2-b4ff-d26ce4f66079
📥 Commits

Reviewing files that changed from the base of the PR and between 71bc818 and d8c45be.

📒 Files selected for processing: 2
  • src/runtime/webcore/Blob.rs
  • test/js/bun/util/bun-file.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

…-size-is-not-a-length

# Conflicts:
#	test/js/bun/util/bun-file.test.ts
Each way to ask and to read now runs on a Bun.file() of a socketpair
and of a pipe in the test process. A child process runs only the cases
that need the pipe as its stdin: Bun.stdin, process.stdin, Bun.file(0)
and Bun.file("/dev/stdin"). The file spawns 6 children where it
spawned 16.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes from the review, and what this PR does with them.

A FormData part or a fetch body from a pipe. The note is right about macOS. On main, macOS stops this reader at the buffered count. With this PR it stops at 262,160 bytes, which is what Linux does today. The PR body said "the same on main and on this PR" for that line. That was true for Linux only, and the body now says so. This PR does not change read_file in node_fs.rs. Open PRs #41590 (a FormData part) and #41488 (a fetch body) change those readers.

await Bun.stdin.exists() on a socket. This PR does not change get_exists_sync. The rows call exists() only because it stats the handle. They do not assert its answer: false is the wrong answer for a readable socket, and a test must not hold a wrong answer. The case is in the PR body under "Not fixed here".

The last push merges main (one conflict in bun-file.test.ts). It also runs 18 of the rows on a Bun.file(fd) in the test process, so the file spawns 6 children where it spawned 16.

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

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.

1 participant