Bun.write: don't over-copy or ftruncate a caller-supplied destination fd - #36758
Conversation
WalkthroughChangesCopy fallbacks distinguish path destinations from existing descriptors. Descriptor writes use bounded loops, while path destinations are truncated. Size limits apply only to regular files. POSIX tests cover sliced writes, redirected stdout, and pipe copying. ChangesCopy fallback behavior
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
No related open issues found. |
There was a problem hiding this comment.
No bugs found, but this changes Bun.write I/O semantics for fd-backed destinations on macOS/FreeBSD and the author flags it needs coordination with #32859 and #33715 which touch the same block — worth a human look before landing.
What was reviewed:
do_read_write_loop_capped: bounds arithmetic (SizeType = u64,wantcapped at 64 KiB,remaining -= amtcan't underflow), short-write handling, error paths setsystem_error+read_len.- Path-vs-fd gating in the macOS/FreeBSD blocks:
do_close()is still reached on every branch; the macOSftruncatecall now uses the safebun_sys::ftruncate(Fd, i64)wrapper (matches the FreeBSD path). - The 64 KiB stack buffer matches existing usage in
read_file.rs/node_fs.rs;bun_sys::read/writeon macOS use$NOCANCEL(same as the existing fallback loop). - Tests: fd is closed in
finally, subprocess pipes drained concurrently, exit code asserted last; the Linux/Windows skip rationale checks out (copy_file_rangerejectsO_APPENDbefore writing).
Extended reasoning...
Overview
This PR fixes data loss in Bun.write(Bun.file(fd), Bun.file(path)) on macOS and FreeBSD. Previously, an fd-backed destination went through the same fcopyfile(COPYFILE_DATA) (macOS) or unbounded copy_file_using_read_write_loop + ftruncate (FreeBSD) path as a path destination. Both operations are safe when Bun opened the destination itself with O_CREAT|O_TRUNC, but for a caller-supplied fd (e.g. Bun.stdout under >> redirection, or Bun.file(fs.openSync(..., 'r+'))) they discard whatever the file already contained. The fix adds a bounded read()/write() loop (do_read_write_loop_capped) that transfers exactly max_length bytes and never touches the destination's length, and gates the destructive path on PathOrFileDescriptor::Path(_).
Two files touched: src/runtime/webcore/blob/copy_file.rs (+~90/-30 across cfg-gated macOS/FreeBSD blocks) and test/js/bun/io/bun-write.test.js (+50, two new tests skipped on Linux/Windows).
Security risks
None identified. The change narrows what Bun.write does to a caller-supplied fd (write only the requested bytes at the fd's current position, honoring O_APPEND) rather than expanding it. No new user-controlled data reaches size arithmetic — cap is derived from min(stat.st_size, max_length) - offset as before, and the loop's per-iteration want is clamped to 64 KiB.
Level of scrutiny
Medium-high. This is a semantic change to a core user-facing I/O API on two platforms, one of which (FreeBSD) has no CI coverage and the other (macOS) is a smaller CI lane than Linux. The new tests are describe.skipIf(isWindows || isLinux), so they won't run on the majority of CI. The author reports cargo check for all three targets passes, but runtime verification is macOS-only. The change also swaps the macOS raw bun_sys::darwin::ftruncate for the safe bun_sys::ftruncate wrapper — a small drive-by that matches the FreeBSD block.
Other factors
- The PR description explicitly names two in-flight PRs (#32859, #33715) that rework the same
run_asyncblock and will need rebasing against whichever lands first. That merge-order decision is a maintainer call. - I traced the new loop against
SizeType = u64/MAX_SIZE = (1<<52)-1fromsrc/jsc/webcore_types.rs: when the source hasst_size == 0(pipe/fifo),max_lengthstays atMAX_SIZEand the loop correctly reads to EOF; whenst_size != 0,max_lengthis already clamped before the call. bun_sys::read/writeon macOS use$NOCANCELvariants without an EINTR retry loop (check_once!), same as the existingcopy_file_using_read_write_loopfallback, so no behavior divergence there.- The 64 KiB stack buffer pattern is used in
read_file.rs,node_fs.rs, and elsewhere insrc/sys/, so it's an accepted idiom on the work-pool thread. - Tests follow the harness conventions (
tempDir,Buffer.alloc(n, fill), pipes drained viaPromise.all, cleanup infinallybefore assertions could throw, exit code asserted last).
Given the platform-specific behavior change, limited CI exercise, and the explicit coordination note with two other PRs, I'm deferring rather than approving.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
test/js/bun/io/bun-write.test.js:490— Changingit.skipIf(!isLinux)→it.skipIf(isWindows)removed the only use ofisLinuxin this file, so theisLinuximport fromharnesson line 9 is now dead — drop it from the destructured import list.Extended reasoning...
What
The PR changes the FIFO copy test's guard from
it.skipIf(!isLinux)toit.skipIf(isWindows)(so it runs on macOS/FreeBSD as well). That was the only reference to theisLinuxhelper intest/js/bun/io/bun-write.test.js; the destructured import on line 9 now binds a name nothing reads.Verification
$ rg -n isLinux test/js/bun/io/bun-write.test.js 9: isLinux,The one place elsewhere in the file that still needs a Linux gate uses
process.platform === "linux"directly (thecopyFileRangeblock), so it does not depend on this import.Why it matters
REVIEW.md ("Code style & idioms reviewers enforce") lists this as required scope for the same PR:
Delete dead code in the same PR that makes it dead (required scope — name the deletions in the description): … helpers whose last caller you rewired …
An unused named import is exactly that case — the last consumer was rewired to
isWindows.Impact
None at runtime — the test file still executes correctly and Bun's test runner does not fail on unused bindings. This is purely hygiene / repo-convention compliance, hence nit severity (non-blocking).
Fix
Remove
isLinuxfrom the destructuredharnessimport on line 9:import { bunEnv, bunExe, exampleHtml, exampleSite, gcTick, - isLinux, isWindows, tempDir, withoutAggressiveGC, } from "harness"; -
🔴
src/runtime/webcore/blob/copy_file.rs:889-893— On macOS/FreeBSD,fstat()on a pipe reports the bytes currently buffered inst_size(unlike Linux's 0), so the earlierif stat.st_size != 0 { self.max_length = min(st_size, max_length)... }clamp fires and capsmax_lengthto ~16–64 KiB. The new fd-dest branch then callsdo_read_write_loop_capped(self.max_length), which hard-stops at that cap — soBun.write(Bun.stdout, Bun.stdin)now copies only what was in the pipe buffer at fstat time and drops the rest, and the newly-un-skipped 1 MiB pipe test (skipIf(isWindows)instead ofskipIf(!isLinux)) will fail/flake on macOS CI. Skip thest_sizeclamp when!S::ISREG(stat.st_mode), or passMAX_SIZEtodo_read_write_loop_cappedfor non-regular sources.Extended reasoning...
What the bug is
On macOS and FreeBSD,
fstat(2)on a pipe/FIFO reports the number of bytes currently available for reading inst_size. XNU'spipe_stat(bsd/kern/sys_pipe.c) setssb->st_size = rpipe->pipe_buffer.cnt, and FreeBSD'ssys/kern/sys_pipe.cdoes the same. Linux always reports 0 for pipes — which is why the existingif stat.st_size != 0guard inrun_asyncwas sufficient there, and why the test comment ("fstat on a FIFO reports st_size == 0") is only true on Linux.In
run_async, afterfstat(source_fd), this block runs unconditionally (noS_ISREGguard) before the platform-specific copy blocks:if stat.st_size != 0 { self.max_length = (SizeType::try_from(stat.st_size).expect("int cast") .min(self.max_length)) .max(self.offset) - self.offset; ... }
So on macOS/FreeBSD with a pipe source that has data queued,
self.max_lengthis clamped to whatever happened to be in the pipe buffer at the moment of the fstat (typically ≤ 16–64 KiB).Why it wasn't a problem before this PR
Before this PR, on macOS the fd-destination path went through
do_fcopy_file_with_read_write_loop_fallback();fcopyfileon a non-seekable pipe fails withEBADFand falls back tocopy_file_using_read_write_loop(..., stat_size=0, ...), which reads the source to EOF regardless ofmax_length. On FreeBSD it calledcopy_file_using_read_write_loop(..., 0, ...)directly. The subsequentif st_size > max_length { ftruncate }check evaluatesN > N == false(they're equal after the clamp), so the clampedmax_lengthwas harmless — the whole stream was copied and nothing was truncated.Why it's a problem now
After this PR, when the destination is a caller-supplied fd, the macOS/FreeBSD blocks call
self.do_read_write_loop_capped(self.max_length).read_write_loop_capped's loop is:let mut remaining = cap; while remaining > 0 { let want = (buf.len() as SizeType).min(remaining) as usize; let amt = bun_sys::read(src_fd, &mut buf[..want])?; if amt == 0 { break; } remaining -= amt as SizeType; ... }
It stops after exactly
capbytes even if the source has more. So only the bytes that were in the pipe buffer at fstat time are copied; the rest of the stream is silently dropped.Step-by-step proof
For the test
Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe (> 4096 bytes)on macOS:sh -c 'head -c 1048576 /dev/zero | bun -e ... | wc -c'— stdin and stdout are both real kernel pipes (fd-backed, not paths).- By the time bun starts and reaches
fstat(stdin),headhas almost certainly filled some of the pipe buffer (bun startup latency ≫ head write latency), sost_sizeis non-zero and ≤ ~16 KiB (macOS default pipe buffer). stat.st_size != 0→self.max_length = min(16384, MAX_SIZE) = 16384.S_ISDIRis false; the Linux#[cfg]block is skipped; the macOS block runs.destination_file_store.pathlikeisFd(_)(Bun.stdout), so the else-branch callsdo_read_write_loop_capped(16384).- The loop reads/writes exactly 16384 bytes and returns;
read_len = 16384. - The test asserts
{ piped: '1048576', resolved: '1048576' }but gets{ piped: '16384', resolved: '16384' }(or whatever was buffered) → fail.
The PR changes this test from
it.skipIf(!isLinux)toit.skipIf(isWindows), so it now runs on macOS CI and will fail (or flake, if fstat happens to race ahead ofhead's first write and seest_size == 0).Impact
This is a functional regression:
Bun.write(fd_dest, pipe_source)on macOS/FreeBSD — including the commonBun.write(Bun.stdout, Bun.stdin)shell-pipe pattern — now silently truncates the stream to whatever fit in the pipe buffer at startup.Fix
Either skip the
st_sizeclamp for non-regular sources:if stat.st_size != 0 && bun_sys::S::ISREG(stat.st_mode as _) { self.max_length = ...; }
or, in the macOS/FreeBSD fd-dest branches, pass
MAX_SIZEtodo_read_write_loop_cappedwhen!S::ISREG(stat.st_mode)so it reads to EOF.
|
Good catch on the BSD pipe |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/webcore/blob/copy_file.rs (1)
869-935: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winAssign
self.read_lenon successful macOS path copies. Thefcopyfilehelper does not update it on success, including the successful fallback path. The Path-destination branch then resolvesBun.write()with the initial0despite copying data.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/webcore/blob/copy_file.rs` around lines 869 - 935, Update the successful macOS Path-destination branch around do_fcopy_file_with_read_write_loop_fallback so self.read_len is assigned to the number of bytes copied, including when fcopyfile or its fallback succeeds. Preserve the existing truncation behavior and ensure Bun.write resolves with the copied length instead of the initial zero.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 950-973: The read_write_fallback function must enforce finite cap
values when bun_opened_dest is true. Route finite caps through
read_write_loop_capped, while preserving the existing
copy_file_using_read_write_loop and final truncation behavior for cap ==
MAX_SIZE.
---
Outside diff comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 869-935: Update the successful macOS Path-destination branch
around do_fcopy_file_with_read_write_loop_fallback so self.read_len is assigned
to the number of bytes copied, including when fcopyfile or its fallback
succeeds. Preserve the existing truncation behavior and ensure Bun.write
resolves with the copied length instead of the initial zero.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 55fe91f7-971b-4e39-82ef-d8f7f76895a1
📒 Files selected for processing (2)
src/runtime/webcore/blob/copy_file.rstest/js/bun/io/bun-write.test.js
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/runtime/webcore/blob/copy_file.rs (2)
792-806: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winPreserve the destination length during preallocation.
preallocate_filecallsfallocatewith mode0, which extendsst_size. A failed or short Linux copy can therefore leave trailing zeroes for bothPathandFddestinations. UseFALLOC_FL_KEEP_SIZEor truncate to the bytes written on every incomplete copy path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/webcore/blob/copy_file.rs` around lines 792 - 806, Update the preallocation flow around preallocate_file and the incomplete-copy cleanup paths so allocation does not extend the destination’s logical length: use FALLOC_FL_KEEP_SIZE when supported, or truncate the destination to the number of bytes actually written before returning. Apply this consistently for both Path and Fd destinations while preserving normal completion behavior.
322-326: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPass only the remaining copy budget to fallback paths.
fallback_capremains unchanged after optimized bytes are written. The fallback can therefore copy beyondmax_length. Also,NodeFS::copy_file_using_read_write_loopcontinues to EOF after a finitestat_size. Use the remaining budget and disable that unbounded phase for bounded copies.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/webcore/blob/copy_file.rs` around lines 322 - 326, Update the fallback setup around fallback_cap so it reflects the remaining copy budget after optimized bytes are written, rather than the original limit. For bounded copies, pass that remaining budget to fallback paths and disable the unbounded NodeFS::copy_file_using_read_write_loop phase that continues to EOF after a finite stat_size; preserve unlimited behavior for unknown-size copies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 975-978: Update the documentation comment for the read/write
helper in src/runtime/webcore/blob/copy_file.rs to remove the inaccurate claim
that it never changes the destination length. State only the durable guarantees:
it never truncates the destination, never reads past cap, and stops at cap or
EOF.
---
Outside diff comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 792-806: Update the preallocation flow around preallocate_file and
the incomplete-copy cleanup paths so allocation does not extend the
destination’s logical length: use FALLOC_FL_KEEP_SIZE when supported, or
truncate the destination to the number of bytes actually written before
returning. Apply this consistently for both Path and Fd destinations while
preserving normal completion behavior.
- Around line 322-326: Update the fallback setup around fallback_cap so it
reflects the remaining copy budget after optimized bytes are written, rather
than the original limit. For bounded copies, pass that remaining budget to
fallback paths and disable the unbounded NodeFS::copy_file_using_read_write_loop
phase that continues to EOF after a finite stat_size; preserve unlimited
behavior for unknown-size copies.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 64418344-a8c6-4107-9ae4-dd3a2655b5da
📒 Files selected for processing (1)
src/runtime/webcore/blob/copy_file.rs
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/webcore/blob/copy_file.rs (1)
344-355: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winApply
self.offsetto every source read path.
do_open_fileopens path sources at offset zero.self.read_offis incremented but never used, and allcopy_file_range, fallback, macOS, and FreeBSD reads use the descriptor’s current position. Sliced copies can start at byte zero while reporting the shortenedread_len. Apply the offset before dispatch and test the first copied byte andread_len.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/webcore/blob/copy_file.rs` around lines 344 - 355, Update the copy operation’s source-read setup to apply self.offset (including self.read_off as it advances) before every read path, covering copy_file_range, fallback, macOS, and FreeBSD dispatches. Ensure do_open_file sources begin at the requested offset rather than descriptor position zero, while preserving accurate read_len and validating that the first copied byte matches the slice start.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 344-355: Update the copy operation’s source-read setup to apply
self.offset (including self.read_off as it advances) before every read path,
covering copy_file_range, fallback, macOS, and FreeBSD dispatches. Ensure
do_open_file sources begin at the requested offset rather than descriptor
position zero, while preserving accurate read_len and validating that the first
copied byte matches the slice start.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0a8083b9-84f3-4cd2-9e6e-7454c6d766b6
📒 Files selected for processing (1)
src/runtime/webcore/blob/copy_file.rs
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/webcore/blob/copy_file.rs:803-808—preallocate_filehere still runs on caller-supplied destination fds. On Linux it isfallocate(fd, mode=0, 0, max_length), and mode-0 fallocate extends the file tooffset+len— the same length-mutating-syscall-on-caller-fd class this PR fixes forftruncate/fcopyfile. With the PR's own>>-redirect test but a >2 MiB source,fallocate(1, 0, 0, 3_000_000)zero-extends the caller's log file before the copy starts (fallocate addresses by explicit offset, soO_APPENDdoesn't help), and the subsequentO_APPENDwrites then append after the zeros. Gate the preallocate onmatches!(self.destination_file_store.pathlike, PathOrFileDescriptor::Path(_))— same guard as the other sites.Extended reasoning...
What the bug is
bun_sys::preallocate_file(src/sys/lib.rs:7546-7558) isfallocate(fd, /*mode=*/0, offset, len)on Linux. Per fallocate(2), mode 0 (the default "allocate" mode) says: "If the size of the file is less than offset+len, then the file size will be changed to offset+len." It is a length-mutating syscall on the destination fd — exactly the invariant this PR establishes must not happen for caller-supplied fds.The call at copy_file.rs:805-813 (which this PR's diff touches — it removed the
ISREGclause from this exact condition) has nodestination_file_store.pathlikeguard, so it fires for a caller-supplied fd whenever the source is a regular file with a computedmax_length > PREALLOCATE_LENGTH(2 MiB) and!= MAX_SIZE.Step-by-step proof (the PR's own
>>scenario, source > 2 MiB)head -c 3000000 /dev/zero > /tmp/src.bin # 3 MB regular-file source printf 'AAAAAAAAAA' > /tmp/log.txt # 10-byte caller file bun -e 'await Bun.write(Bun.stdout, Bun.file("/tmp/src.bin"))' >> /tmp/log.txt
run_async: sourcefstat→st_size = 3_000_000,S_IFREG. Enter the outerif stat.st_size != 0 && ISREG(...)block.self.max_length = min(3_000_000, MAX_SIZE).max(0) - 0 = 3_000_000.PREALLOCATE_SUPPORTED(Linux)&& 3_000_000 > 2_097_152 && 3_000_000 != MAX_SIZE→fallocate(1, 0, 0, 3_000_000).fallocateaddresses by explicit offset (onlywrite(2)honoursO_APPEND), solog.txtis zero-extended from 10 bytes to 3 000 000 bytes right here.- Linux dispatch: source ISREG →
do_copy_file_range::<CopyFileRange, _>.copy_file_range(2)on anO_APPENDfd_outreturnsEBADF(man page: "the O_APPEND flag is set for the open file description referred to by fd_out").EBADFis not in the{ENOSYS, EXDEV, ENOTSUP, EINVAL}fallback set, sodo_copy_file_rangesetssystem_errorandBun.writerejects — but the caller's file has already been silently blown out to 3 MB of zeros. - With
BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1(the env the PR's tests set), step 4 instead goes straight toread_write_fallback→read_write_loop_capped, whosewrite()s honourO_APPENDand land after the fallocate-created 3 MB, giving ~6 MB instead of the expected 3 000 010 bytes.
Either way the caller's file layout is corrupted before the copy proper even starts.
Why existing code doesn't prevent it
The PR adds
matches!(destination_file_store.pathlike, Path(_))guards to the macOSfcopyfile/ftruncateblock, the FreeBSD block, and the three Linux read/write fallback sites (viabun_opened_destinread_write_fallback). Thepreallocate_filecall sits above all of those, in the sharedISREG-source setup, with no equivalent guard.PREALLOCATE_SUPPORTEDis Linux-only, so this is a Linux/Android-specific hole.The PR's new tests use 200 KB and 1 KB sources, both under
PREALLOCATE_LENGTH = 2 MiB, so they don't exercise this branch — per REVIEW.md "Cover the variant matrix", the >2 MiB source variant is the missing case in the same test.Why this belongs in this PR
This is pre-existing on main (the PR only hoisted the
ISREGguard from the innerpreallocatecondition to the outerif; reachability is unchanged). But REVIEW.md's "Fix the whole class in the same PR — same-class sites are ONE concern, not scope creep" applies directly: it is the identical bug class (length-mutating syscall against a caller-supplied destination fd), in the same function, on lines the diff already edits, and the PR's title/description explicitly establishes "never mutate a caller-supplied fd's length" as the invariant. This is not the path-destination slice handling deferred to #32859 — this is the caller-fd class this PR owns.Fix
One line, same shape as the other guards:
if PREALLOCATE_SUPPORTED && matches!(self.destination_file_store.pathlike, PathOrFileDescriptor::Path(_)) && self.max_length > PREALLOCATE_LENGTH && self.max_length != MAX_SIZE {
and bump the
>>-redirect test's source above 2 MiB (e.g.Buffer.alloc(3_000_000, "S"), slice(0, 3_000_000)) so it fail-befores on Linux without needingBUN_CONFIG_DISABLE_COPY_FILE_RANGE.
|
Good catch on |
There was a problem hiding this comment.
My earlier findings (BSD pipe st_size clamp, missing #[inline(never)]) are both addressed; the follow-up commits also gate preallocate_file on a path destination and derive the fallback cap from the live remain. No new issues in this pass, but this is cross-platform I/O with macOS/FreeBSD-specific branches that only CI can exercise, and it intersects three other in-flight PRs (#36692, #32859, #33715) — worth a human look before merging.
Checked: read_write_loop_capped short-write loop and 0-write early-return; ISREG gate now covers both the max_length clamp and the preallocate block (source-is-regular still enforced by the outer condition); fallback_cap(remain) is evaluated at each of the three Linux fallback sites and remain is undecremented on the error branches, so the cap is correct even if the first kernel-copy iteration errored.
Ruled out: the widened Bun.write(Bun.stdout, Bun.stdin) pipe test on macOS now reaches do_read_write_loop_capped(MAX_SIZE) because the ISREG gate keeps max_length at MAX_SIZE for a FIFO source.
Extended reasoning...
Overview
The PR stops Bun.write(Bun.file(fd), Bun.file(path)) from over-copying and ftruncate-ing a caller-supplied destination fd. It adds a bounded read_write_loop_capped helper, factors the three identical Linux fallback sites into read_write_fallback (which branches on whether Bun opened the destination), gates the macOS fcopyfile+ftruncate and FreeBSD read/write+ftruncate paths on a path destination, gates the st_size→max_length clamp on S_ISREG (so BSD pipe st_size doesn't cap the copy), and gates preallocate_file on a path destination. Three new subprocess tests exercise the fd-destination path with BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1, and the existing FIFO→FIFO test is widened from Linux-only to all POSIX.
Since my previous review
Commits 0f8c74d, b13543b, 0fbca83, d9aa656, da64c88 landed after my last comments. They (a) gate the st_size clamp on ISREG, (b) add #[inline(never)] to read_write_loop_capped, (c) drop a doc comment, (d) compute fallback_cap from the live remain at each call site, and (e) gate preallocate_file on a path destination with a new >2 MiB test. All of my previous findings and CodeRabbit's are addressed and resolved in the thread.
Security risks
None identified. This is local file I/O; no untrusted-length parsing, no auth/crypto. The bounded loop uses a fixed 64 KB stack buffer with #[inline(never)] matching the sibling copy_file_using_read_write_loop convention.
Level of scrutiny
High. This is core Bun.write behaviour with per-OS branches (#[cfg(target_os = "macos")], freebsd, linux|android) whose correctness depends on kernel semantics (fcopyfile rewriting from offset 0, BSD pipe_stat reporting buffered bytes in st_size, fallocate extending an O_APPEND fd). The PR description itself defers macOS/FreeBSD verification to CI. It also explicitly overlaps with #36692, #32859, and #33715, so a maintainer should sequence the merges.
Other factors
- Path-destination fallback semantics are intentionally left unchanged (verbatim factor-out); the author and CodeRabbit agreed the sliced-path-destination cap is #32859's scope.
- Tests follow harness conventions (
tempDir,bunEnvspread, combined-object assertions beforeexitCode,Buffer.alloc(n, fill)overrepeat). - The macOS
ftruncatecall site was also switched from rawunsafe { darwin::ftruncate }to thebun_sys::ftruncatewrapper, a small cleanup.
Given the platform-specific reach and the coordination needed with three related PRs, I'm deferring rather than approving.
|
Updated 11:05 PM PT - Aug 3rd, 2026
@autofix-ci[bot], your commit 43b197b is building: |
|
CI on a559220: 194/196 jobs passed.
Ready for review. |
|
@robobun fix conflicts, rebase, close if unneeded |
…FreeBSD On macOS CopyFile::run_async used fcopyfile(COPYFILE_DATA), which rewrites the destination from offset 0, and then called ftruncate(dest, max_length) when the source was larger than the requested slice. On FreeBSD the read/write loop copied the whole source to EOF and then ftruncated to max_length. Both paths ran regardless of whether the destination was a path this function opened itself or a caller-supplied fd such as Bun.stdout redirected with '>>' or Bun.file(fd), so pre-existing bytes in the caller's file were destroyed. Route fd-backed destinations through a bounded read()/write() loop that writes exactly max_length bytes and never touches the destination's length. The fcopyfile/ftruncate path is kept for path destinations, which this function opens O_TRUNC itself.
The three fallback arms in do_copy_file_range (no copy_file_range support, ENOSYS/EXDEV/ENOTSUP, EINVAL) copied the source to EOF and then ftruncated the destination, which is the same caller-fd data loss this PR fixes for macOS/FreeBSD. Route Fd destinations through the bounded loop there too, and factor the three identical fallback sites into a single helper. The tests now run on every POSIX lane via BUN_CONFIG_DISABLE_COPY_FILE_RANGE, and the existing Bun.stdout/Bun.stdin pipe test is widened to macOS/FreeBSD so the bounded loop's EOF termination is covered.
BSD fstat on a pipe reports bytes currently buffered in st_size, not the stream length. Clamping max_length to that value and then handing it to the bounded loop stopped after the buffered chunk and dropped the rest of the stream. Linux reports 0 for pipes so the clamp never fired there.
fallocate(fd, 0, 0, len) extends the file to len, which for a caller-supplied fd (Bun.stdout with >>, Bun.file(fd)) zero-fills the caller's file before any bytes are copied. Same bug class as the ftruncate/fcopyfile guards; only preallocate a destination Bun opened O_TRUNC itself.
a559220 to
43b197b
Compare
|
Rebased onto main (43b197b) and resolved the import conflict in |
There was a problem hiding this comment.
No bugs found, but this reworks platform-gated file-copy dispatch across macOS/FreeBSD/Linux with a new hand-written bounded read/write loop and known merge interactions with #36692/#32859/#33715 on the same block — worth a human look before landing.
What was reviewed:
read_write_loop_cappedhandles partial writes and EOF correctly;#[inline(never)]matches the sibling helper's 64 KB stack-buffer convention.read_write_fallback'sbun_opened_destarm is a verbatim factor-out of the three pre-PR call sites (copy-to-EOF +ftruncate(total)); path-destination behavior is unchanged.- The
st_sizeclamp's newISREGguard subsumes the old innerpreallocate_fileISREGcheck, so swapping that for thePath(_)gate doesn't lose the source-is-regular condition. fallback_cap(remain)at the mid-loopENOSYS/EXDEV/ENOTSUPsite uses the already-decrementedremain, so a partial fast-path copy before fallback keeps the correct budget.
Extended reasoning...
Overview
This PR fixes data loss in Bun.write(Bun.file(fd), Bun.file(path)) when the destination is a caller-supplied file descriptor rather than a path Bun opened with O_CREAT|O_TRUNC. It touches src/runtime/webcore/blob/copy_file.rs (~150 lines net across macOS, FreeBSD, and Linux/Android #[cfg] blocks) and adds three subprocess tests plus widens one existing test in test/js/bun/io/bun-write.test.js.
The core change: fd-backed destinations now route through a new read_write_loop_capped that copies at most cap bytes and never calls ftruncate/fallocate. Path destinations keep the existing semantics unchanged (verified by tracing read_write_fallback's bun_opened_dest arm against the three pre-PR call sites — same stat_size derivation, same trailing ftruncate, only the raw libc::ftruncate swapped for the bun_sys::ftruncate wrapper).
Security risks
None identified. This is local file I/O with no parsing of untrusted input; the change strictly narrows what Bun does to a caller-owned fd (stops writing past the requested window, stops truncating). No new unsafe blocks are introduced — two raw libc::ftruncate calls are replaced with the safe bun_sys::ftruncate wrapper.
Level of scrutiny
High. This is cross-platform I/O in a user-facing API with per-OS #[cfg] branches whose correctness depends on kernel-specific semantics (XNU/FreeBSD pipe_stat reporting buffered bytes in st_size, fcopyfile rewriting from offset 0, fallocate extending an O_APPEND fd). The PR has already been through several review rounds that caught real issues (BSD pipe st_size clamp, missing #[inline(never)], preallocate_file gating, deriving the fallback cap from live remain), which is exactly the signal that this code is subtle enough to want a maintainer's eyes on the final state.
Other factors
- CI on the pre-rebase head was green on all Linux and macOS lanes for the new tests (194/196 jobs, both failures unrelated).
- The PR description explicitly names three open PRs (#36692, #32859, #33715) that touch the same block and will need rebasing against whichever lands first — a maintainer should decide merge order.
- Jarred already engaged ("fix conflicts, rebase, close if unneeded") and robobun rebased; the post-rebase state hasn't had a human sign-off.
- The tests follow harness conventions well (
tempDir,Buffer.allocover.repeat, concurrent pipe drain, combined-object assertion beforeexitCode,BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1to force fail-before on Linux).
… fd (oven-sh#36758) ## Repro ```sh head -c 200000 /dev/zero | tr '\0' S > /tmp/src.bin printf 'DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD' > /tmp/dst.bin BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1 bun -e ' const fs = require("fs"); const fd = fs.openSync("/tmp/dst.bin", "r+"); console.log(await Bun.write(Bun.file(fd).slice(0, 5), Bun.file("/tmp/src.bin"))); fs.closeSync(fd); ' wc -c /tmp/dst.bin # Linux (fallback): 200000 (expected 30; whole source over-copied then ftruncated) # macOS: 5 (expected 30; fcopyfile rewrote from 0 then ftruncate(5)) ``` Same for `Bun.stdout` under `>>` redirection: the pre-existing bytes in the redirected file are gone after the write. ## Cause `CopyFile::run_async` dispatched fd-backed destinations through the same path as path destinations. - **macOS:** `fcopyfile(COPYFILE_DATA)` rewrites the destination from offset 0, and when `stat.st_size > max_length` Bun then calls `ftruncate(dest, max_length)` to trim the over-copied tail. Both are safe when Bun opened the destination with `O_CREAT|O_TRUNC`, but for a caller-supplied fd they discard whatever the file already contained. - **FreeBSD** and the **Linux read/write fallback** (kernel < 4.5, `EXDEV`, `ENOTSUP`, `EINVAL`, or `BUN_CONFIG_DISABLE_COPY_FILE_RANGE`): `copy_file_using_read_write_loop` reads the source to EOF regardless of `max_length` and then `ftruncate(dest, total_written)` sets the file length, with the same effect. The Linux `copy_file_range`/`sendfile`/`splice` happy path already copies exactly `max_length` bytes and does not truncate, so this only shows up there when the kernel syscall is unavailable for the fd pair. ## Fix Add `read_write_loop_capped`, a bounded `read()`/`write()` loop that transfers exactly `cap` bytes (or to EOF) and never touches the destination's length. Route every fd-backed destination through it: - macOS/FreeBSD `run_async`: take the `fcopyfile`/`ftruncate` branch only when `destination_file_store.pathlike` is a path. - Linux `do_copy_file_range`: the three identical fallback sites are factored into `read_write_fallback`, which keeps the existing copy-to-EOF + `ftruncate` for path destinations and uses the bounded loop for fd destinations. ## Verification Two tests in `test/js/bun/io/bun-write.test.js` under `Bun.write(Bun.file(fd), Bun.file(path)) does not truncate the fd` spawn a child with `BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1` so they fail-before on every POSIX lane: ``` === system bun === (fail) preserves bytes past the slice window in an r+ fd resolved: "200000", content: 200000 x "S" (expected "5", "SSSSS" + 25 x "D") (fail) preserves pre-existing bytes when stdout is redirected with >> resolved: "1000", content: 1000 bytes (expected "100", 110 bytes) === this branch === (pass) preserves bytes past the slice window in an r+ fd (pass) preserves pre-existing bytes when stdout is redirected with >> ``` The existing `Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe` test is widened from Linux-only to every POSIX lane, covering the bounded loop's EOF-termination branch on macOS/FreeBSD. `cargo check -p bun_runtime --target {aarch64,x86_64}-apple-darwin` and `--target x86_64-unknown-freebsd` pass. ## Related Found during review of oven-sh#36692 (which routes a FIFO source through `splice` on Linux; the `bun_opened_dest` guard it adds to the same three fallback sites is subsumed by `read_write_fallback` here). oven-sh#32859 reworks the same block to honour source slices and oven-sh#33715 sets `read_len` after `fcopyfile`; both are complementary and will need a small rebase against whichever lands first. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Repro
Same for
Bun.stdoutunder>>redirection: the pre-existing bytes in the redirected file are gone after the write.Cause
CopyFile::run_asyncdispatched fd-backed destinations through the same path as path destinations.fcopyfile(COPYFILE_DATA)rewrites the destination from offset 0, and whenstat.st_size > max_lengthBun then callsftruncate(dest, max_length)to trim the over-copied tail. Both are safe when Bun opened the destination withO_CREAT|O_TRUNC, but for a caller-supplied fd they discard whatever the file already contained.EXDEV,ENOTSUP,EINVAL, orBUN_CONFIG_DISABLE_COPY_FILE_RANGE):copy_file_using_read_write_loopreads the source to EOF regardless ofmax_lengthand thenftruncate(dest, total_written)sets the file length, with the same effect.The Linux
copy_file_range/sendfile/splicehappy path already copies exactlymax_lengthbytes and does not truncate, so this only shows up there when the kernel syscall is unavailable for the fd pair.Fix
Add
read_write_loop_capped, a boundedread()/write()loop that transfers exactlycapbytes (or to EOF) and never touches the destination's length. Route every fd-backed destination through it:run_async: take thefcopyfile/ftruncatebranch only whendestination_file_store.pathlikeis a path.do_copy_file_range: the three identical fallback sites are factored intoread_write_fallback, which keeps the existing copy-to-EOF +ftruncatefor path destinations and uses the bounded loop for fd destinations.Verification
Two tests in
test/js/bun/io/bun-write.test.jsunderBun.write(Bun.file(fd), Bun.file(path)) does not truncate the fdspawn a child withBUN_CONFIG_DISABLE_COPY_FILE_RANGE=1so they fail-before on every POSIX lane:The existing
Bun.write(Bun.stdout, Bun.stdin) copies the whole pipetest is widened from Linux-only to every POSIX lane, covering the bounded loop's EOF-termination branch on macOS/FreeBSD.cargo check -p bun_runtime --target {aarch64,x86_64}-apple-darwinand--target x86_64-unknown-freebsdpass.Related
Found during review of #36692 (which routes a FIFO source through
spliceon Linux; thebun_opened_destguard it adds to the same three fallback sites is subsumed byread_write_fallbackhere). #32859 reworks the same block to honour source slices and #33715 setsread_lenafterfcopyfile; both are complementary and will need a small rebase against whichever lands first.no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js