From 9a5f62a947ac89db16a70ac2e24e22f03a49d448 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:33:05 +0000 Subject: [PATCH 01/11] Bun.write: don't ftruncate a caller-supplied destination fd on macOS/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. --- src/runtime/webcore/blob/copy_file.rs | 138 +++++++++++++++++++------- test/js/bun/io/bun-write.test.js | 50 ++++++++++ 2 files changed, 152 insertions(+), 36 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 1b0833eacfc6..2a7935692cbc 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -527,6 +527,53 @@ impl<'a> CopyFile<'a> { Ok(()) } + /// read() from `source_fd`, write() to `destination_fd`, stopping after + /// `cap` bytes have been read (or EOF). Unlike + /// `copy_file_using_read_write_loop`, this never reads past `cap` and + /// never touches the destination's length, so it is safe for a + /// caller-supplied fd (Bun.stdout redirected to a file, Bun.file(fd)). + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + fn do_read_write_loop_capped(&mut self, cap: SizeType) -> Result<(), crate::Error> { + let mut buf = [0u8; 64 * 1024]; + let mut remaining = cap; + let mut total: u64 = 0; + while remaining > 0 { + let want = (buf.len() as SizeType).min(remaining) as usize; + let amt = match bun_sys::read(self.source_fd, &mut buf[..want]) { + bun_sys::Result::Ok(n) => n, + bun_sys::Result::Err(err) => { + self.read_len = total as SizeType; + self.system_error = Some(err.to_system_error()); + return Err(bun_errno::from_errno(err.errno as i32).into()); + } + }; + if amt == 0 { + break; + } + remaining -= amt as SizeType; + let mut slice = &buf[..amt]; + while !slice.is_empty() { + match bun_sys::write(self.destination_fd, slice) { + bun_sys::Result::Ok(0) => { + self.read_len = total as SizeType; + return Ok(()); + } + bun_sys::Result::Ok(n) => { + total += n as u64; + slice = &slice[n..]; + } + bun_sys::Result::Err(err) => { + self.read_len = total as SizeType; + self.system_error = Some(err.to_system_error()); + return Err(bun_errno::from_errno(err.errno as i32).into()); + } + } + } + } + self.read_len = total as SizeType; + Ok(()) + } + #[cfg(target_os = "macos")] pub(crate) fn do_fcopy_file_with_read_write_loop_fallback( &mut self, @@ -870,21 +917,32 @@ impl<'a> CopyFile<'a> { #[cfg(target_os = "macos")] { - if self.do_fcopy_file_with_read_write_loop_fallback().is_err() { + // fcopyfile(COPYFILE_DATA) rewrites the destination from + // offset 0 and the slice-trim below is implemented as + // ftruncate(dest); both destroy bytes in a file the caller + // already had open (Bun.stdout redirected with `>>`, + // Bun.file(fd)). Only take the fcopyfile path for a + // destination this function opened itself. + if matches!( + self.destination_file_store.pathlike, + PathOrFileDescriptor::Path(_) + ) { + if self.do_fcopy_file_with_read_write_loop_fallback().is_err() { + self.do_close(); + return; + } + if stat.st_size != 0 + && SizeType::try_from(stat.st_size).expect("int cast") > self.max_length + { + let _ = bun_sys::ftruncate( + self.destination_fd, + i64::try_from(self.max_length).expect("int cast"), + ); + } + } else if self.do_read_write_loop_capped(self.max_length).is_err() { self.do_close(); return; } - if stat.st_size != 0 - && SizeType::try_from(stat.st_size).expect("int cast") > self.max_length - { - // SAFETY: `destination_fd` is open; libc ftruncate(2). - let _ = unsafe { - bun_sys::darwin::ftruncate( - self.destination_fd.native(), - i64::try_from(self.max_length).expect("int cast"), - ) - }; - } self.do_close(); return; @@ -892,32 +950,40 @@ impl<'a> CopyFile<'a> { #[cfg(target_os = "freebsd")] { - let mut total_written: u64 = 0; - match node_fs::NodeFS::copy_file_using_read_write_loop( - bun_core::ZStr::EMPTY, - bun_core::ZStr::EMPTY, - self.source_fd, - self.destination_fd, - 0, - &mut total_written, + if matches!( + self.destination_file_store.pathlike, + PathOrFileDescriptor::Path(_) ) { - bun_sys::Result::Err(err) => { - self.system_error = Some(err.to_system_error()); - self.do_close(); - return; - } - bun_sys::Result::Ok(()) => {} - } - if stat.st_size != 0 - && SizeType::try_from(stat.st_size).expect("int cast") > self.max_length - { - let _ = bun_sys::ftruncate( + let mut total_written: u64 = 0; + match node_fs::NodeFS::copy_file_using_read_write_loop( + bun_core::ZStr::EMPTY, + bun_core::ZStr::EMPTY, + self.source_fd, self.destination_fd, - i64::try_from(self.max_length).expect("int cast"), - ); - self.read_len = total_written.min(self.max_length as u64) as SizeType; - } else { - self.read_len = total_written as SizeType; + 0, + &mut total_written, + ) { + bun_sys::Result::Err(err) => { + self.system_error = Some(err.to_system_error()); + self.do_close(); + return; + } + bun_sys::Result::Ok(()) => {} + } + if stat.st_size != 0 + && SizeType::try_from(stat.st_size).expect("int cast") > self.max_length + { + let _ = bun_sys::ftruncate( + self.destination_fd, + i64::try_from(self.max_length).expect("int cast"), + ); + self.read_len = total_written.min(self.max_length as u64) as SizeType; + } else { + self.read_len = total_written as SizeType; + } + } else if self.do_read_write_loop_capped(self.max_length).is_err() { + self.do_close(); + return; } self.do_close(); return; diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index be0c08c29c02..6f2b82b82f08 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -425,6 +425,56 @@ const IS_UV_FS_COPYFILE_DISABLED = await Bun.write(Bun.stderr, Bun.file(path.join(import.meta.dir, "hello-world.txt"))); }); + // macOS fcopyfile(COPYFILE_DATA) rewrites dst from offset 0, and slice copies + // used to ftruncate(dst, N) afterwards on macOS/FreeBSD; both destroy bytes in + // a file the caller already had open. Linux is skipped: copy_file_range rejects + // O_APPEND with EBADF and the non-append case already preserves bytes, so the + // test cannot fail-before there. + describe.skipIf(isWindows || isLinux)("Bun.write(Bun.file(fd), Bun.file(path)) does not truncate the fd", () => { + it("preserves bytes past the slice window in an r+ fd", async () => { + using dir = tempDir("bun-write-fd-slice", { + "src.bin": Buffer.alloc(200_000, "S").toString(), + "dst.bin": Buffer.alloc(30, "D").toString(), + }); + const dst = join(String(dir), "dst.bin"); + const fd = fs.openSync(dst, "r+"); + try { + const written = await Bun.write(Bun.file(fd).slice(0, 5), Bun.file(join(String(dir), "src.bin"))); + expect({ written, content: fs.readFileSync(dst, "utf8") }).toEqual({ + written: 5, + content: "SSSSS" + Buffer.alloc(25, "D").toString(), + }); + } finally { + fs.closeSync(fd); + } + }); + + it("preserves pre-existing bytes when stdout is redirected with >>", async () => { + using dir = tempDir("bun-write-stdout-append", { + "src.bin": Buffer.alloc(1000, "S").toString(), + "log.txt": "AAAAAAAAAA", + }); + const src = join(String(dir), "src.bin"); + const log = join(String(dir), "log.txt"); + const script = `process.stderr.write(String(await Bun.write(Bun.stdout.slice(0, 100), Bun.file(${JSON.stringify(src)}))))`; + + await using proc = Bun.spawn({ + cmd: ["sh", "-c", `"$BUN" -e ${JSON.stringify(script)} >> ${JSON.stringify(log)}`], + env: { ...bunEnv, BUN: bunExe() }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, resolved: stderr, content: fs.readFileSync(log, "utf8") }).toEqual({ + stdout: "", + resolved: "100", + content: "AAAAAAAAAA" + Buffer.alloc(100, "S").toString(), + }); + expect(exitCode).toBe(0); + }); + }); + // On Linux, FIFO -> FIFO goes through splice(2). fstat on a FIFO reports // st_size == 0, and the copy loop used to treat its unknown-size probe as // the total byte budget, silently dropping the rest of the stream. From d06a0f9d64107789159c4c4c2b721fbd9354c1bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:25:25 +0000 Subject: [PATCH 02/11] extend the caller-fd guard to Linux's read/write fallback paths 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. --- src/runtime/webcore/blob/copy_file.rs | 170 ++++++++++++++------------ test/js/bun/io/bun-write.test.js | 50 ++++---- 2 files changed, 118 insertions(+), 102 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 2a7935692cbc..2060166704e4 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -315,6 +315,15 @@ impl<'a> CopyFile<'a> { let mut total_written: u64 = 0; let src_fd = self.source_fd; let dest_fd = self.destination_fd; + let bun_opened_dest = matches!( + self.destination_file_store.pathlike, + PathOrFileDescriptor::Path(_) + ); + let fallback_cap = if unknown_size { + MAX_SIZE + } else { + remain as SizeType + }; // defer { this.read_len = @truncate(total_written); } let read_len_slot: *mut SizeType = &raw mut self.read_len; @@ -330,28 +339,18 @@ impl<'a> CopyFile<'a> { // If they can't use copy_file_range, they probably also can't // use sendfile() or splice() if !bun_sys::copy_file::can_use_copy_file_range_syscall() { - match node_fs::NodeFS::copy_file_using_read_write_loop( - bun_core::ZStr::EMPTY, - bun_core::ZStr::EMPTY, + match read_write_fallback( src_fd, dest_fd, - if unknown_size { 0 } else { remain }, + bun_opened_dest, + fallback_cap, &mut total_written, ) { bun_sys::Result::Err(err) => { self.system_error = Some(err.to_system_error()); return Err(bun_errno::from_errno(err.errno as i32).into()); } - bun_sys::Result::Ok(()) => { - // SAFETY: dest_fd is a valid open fd; raw ftruncate(2). - let _ = unsafe { - libc::ftruncate( - dest_fd.native(), - i64::try_from(total_written).expect("int cast"), - ) - }; - return Ok(()); - } + bun_sys::Result::Ok(()) => return Ok(()), } } @@ -405,28 +404,18 @@ impl<'a> CopyFile<'a> { // OPNOTSUPP: filesystem doesn't support this operation bun_sys::E::ENOSYS | bun_sys::E::EXDEV | bun_sys::E::ENOTSUP => { // TODO: this should use non-blocking I/O. - match node_fs::NodeFS::copy_file_using_read_write_loop( - bun_core::ZStr::EMPTY, - bun_core::ZStr::EMPTY, + match read_write_fallback( src_fd, dest_fd, - if unknown_size { 0 } else { remain }, + bun_opened_dest, + fallback_cap, &mut total_written, ) { bun_sys::Result::Err(err) => { self.system_error = Some(err.to_system_error()); return Err(bun_errno::from_errno(err.errno as i32).into()); } - bun_sys::Result::Ok(()) => { - // SAFETY: dest_fd is a valid open fd; raw ftruncate(2). - let _ = unsafe { - libc::ftruncate( - dest_fd.native(), - i64::try_from(total_written).expect("int cast"), - ) - }; - return Ok(()); - } + bun_sys::Result::Ok(()) => return Ok(()), } } @@ -462,28 +451,18 @@ impl<'a> CopyFile<'a> { // to a read/write loop if total_written == 0 { // TODO: this should use non-blocking I/O. - match node_fs::NodeFS::copy_file_using_read_write_loop( - bun_core::ZStr::EMPTY, - bun_core::ZStr::EMPTY, + match read_write_fallback( src_fd, dest_fd, - if unknown_size { 0 } else { remain }, + bun_opened_dest, + fallback_cap, &mut total_written, ) { bun_sys::Result::Err(err) => { self.system_error = Some(err.to_system_error()); return Err(bun_errno::from_errno(err.errno as i32).into()); } - bun_sys::Result::Ok(()) => { - // SAFETY: dest_fd is a valid open fd; raw ftruncate(2). - let _ = unsafe { - libc::ftruncate( - dest_fd.native(), - i64::try_from(total_written).expect("int cast"), - ) - }; - return Ok(()); - } + bun_sys::Result::Ok(()) => return Ok(()), } } @@ -527,51 +506,20 @@ impl<'a> CopyFile<'a> { Ok(()) } - /// read() from `source_fd`, write() to `destination_fd`, stopping after - /// `cap` bytes have been read (or EOF). Unlike - /// `copy_file_using_read_write_loop`, this never reads past `cap` and - /// never touches the destination's length, so it is safe for a - /// caller-supplied fd (Bun.stdout redirected to a file, Bun.file(fd)). #[cfg(any(target_os = "macos", target_os = "freebsd"))] fn do_read_write_loop_capped(&mut self, cap: SizeType) -> Result<(), crate::Error> { - let mut buf = [0u8; 64 * 1024]; - let mut remaining = cap; let mut total: u64 = 0; - while remaining > 0 { - let want = (buf.len() as SizeType).min(remaining) as usize; - let amt = match bun_sys::read(self.source_fd, &mut buf[..want]) { - bun_sys::Result::Ok(n) => n, - bun_sys::Result::Err(err) => { - self.read_len = total as SizeType; - self.system_error = Some(err.to_system_error()); - return Err(bun_errno::from_errno(err.errno as i32).into()); - } - }; - if amt == 0 { - break; + match read_write_loop_capped(self.source_fd, self.destination_fd, cap, &mut total) { + bun_sys::Result::Ok(()) => { + self.read_len = total as SizeType; + Ok(()) } - remaining -= amt as SizeType; - let mut slice = &buf[..amt]; - while !slice.is_empty() { - match bun_sys::write(self.destination_fd, slice) { - bun_sys::Result::Ok(0) => { - self.read_len = total as SizeType; - return Ok(()); - } - bun_sys::Result::Ok(n) => { - total += n as u64; - slice = &slice[n..]; - } - bun_sys::Result::Err(err) => { - self.read_len = total as SizeType; - self.system_error = Some(err.to_system_error()); - return Err(bun_errno::from_errno(err.errno as i32).into()); - } - } + bun_sys::Result::Err(err) => { + self.read_len = total as SizeType; + self.system_error = Some(err.to_system_error()); + Err(bun_errno::from_errno(err.errno as i32).into()) } } - self.read_len = total as SizeType; - Ok(()) } #[cfg(target_os = "macos")] @@ -1002,6 +950,66 @@ impl<'a> CopyFile<'a> { } } +/// The read/write fallback used when Linux's kernel copy syscalls are +/// unavailable for this fd pair. For a destination Bun opened itself with +/// `O_TRUNC`, copy the whole source and then `ftruncate` (cheap, matches the +/// historical behaviour). For a caller-supplied fd, copy exactly `cap` bytes +/// so nothing outside the requested window is touched. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn read_write_fallback( + src_fd: Fd, + dest_fd: Fd, + bun_opened_dest: bool, + cap: SizeType, + total: &mut u64, +) -> bun_sys::Result<()> { + if bun_opened_dest { + let stat_size = if cap == MAX_SIZE { 0 } else { cap as usize }; + node_fs::NodeFS::copy_file_using_read_write_loop( + bun_core::ZStr::EMPTY, + bun_core::ZStr::EMPTY, + src_fd, + dest_fd, + stat_size, + total, + )?; + let _ = bun_sys::ftruncate(dest_fd, i64::try_from(*total).expect("int cast")); + Ok(()) + } else { + read_write_loop_capped(src_fd, dest_fd, cap, total) + } +} + +#[cfg(not(windows))] +fn read_write_loop_capped( + src_fd: Fd, + dest_fd: Fd, + cap: SizeType, + total: &mut u64, +) -> bun_sys::Result<()> { + let mut buf = [0u8; 64 * 1024]; + 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; + let mut slice = &buf[..amt]; + while !slice.is_empty() { + match bun_sys::write(dest_fd, slice)? { + 0 => return Ok(()), + n => { + *total += n as u64; + slice = &slice[n..]; + } + } + } + } + Ok(()) +} + // Ownership is encoded in the types, so cleanup is all field `Drop`: // `source_file_store.pathlike` is a `PathLike` clone that is independently // droppable — `PathLike::clone` dupes owned string buffers (freed by the diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 6f2b82b82f08..ebcea9177766 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -425,28 +425,37 @@ const IS_UV_FS_COPYFILE_DISABLED = await Bun.write(Bun.stderr, Bun.file(path.join(import.meta.dir, "hello-world.txt"))); }); - // macOS fcopyfile(COPYFILE_DATA) rewrites dst from offset 0, and slice copies - // used to ftruncate(dst, N) afterwards on macOS/FreeBSD; both destroy bytes in - // a file the caller already had open. Linux is skipped: copy_file_range rejects - // O_APPEND with EBADF and the non-append case already preserves bytes, so the - // test cannot fail-before there. - describe.skipIf(isWindows || isLinux)("Bun.write(Bun.file(fd), Bun.file(path)) does not truncate the fd", () => { + // macOS fcopyfile(COPYFILE_DATA) rewrites dst from offset 0, and the + // slice trim on macOS/FreeBSD (and the Linux read/write fallback) was + // ftruncate(dst, N); both destroy bytes in a file the caller already had + // open. BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1 routes Linux through the + // fallback so the assertion fail-befores on every POSIX lane. + describe.skipIf(isWindows)("Bun.write(Bun.file(fd), Bun.file(path)) does not truncate the fd", () => { + const fallbackEnv = { ...bunEnv, BUN_CONFIG_DISABLE_COPY_FILE_RANGE: "1" }; + it("preserves bytes past the slice window in an r+ fd", async () => { using dir = tempDir("bun-write-fd-slice", { "src.bin": Buffer.alloc(200_000, "S").toString(), "dst.bin": Buffer.alloc(30, "D").toString(), }); + const src = join(String(dir), "src.bin"); const dst = join(String(dir), "dst.bin"); - const fd = fs.openSync(dst, "r+"); - try { - const written = await Bun.write(Bun.file(fd).slice(0, 5), Bun.file(join(String(dir), "src.bin"))); - expect({ written, content: fs.readFileSync(dst, "utf8") }).toEqual({ - written: 5, - content: "SSSSS" + Buffer.alloc(25, "D").toString(), - }); - } finally { - fs.closeSync(fd); - } + const script = ` + const fs = require("fs"); + const fd = fs.openSync(${JSON.stringify(dst)}, "r+"); + try { + process.stderr.write(String(await Bun.write(Bun.file(fd).slice(0, 5), Bun.file(${JSON.stringify(src)})))); + } finally { fs.closeSync(fd); } + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: fallbackEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, resolved: stderr, content: fs.readFileSync(dst, "utf8") }).toEqual({ + stdout: "", + resolved: "5", + content: "SSSSS" + Buffer.alloc(25, "D").toString(), + }); + expect(exitCode).toBe(0); }); it("preserves pre-existing bytes when stdout is redirected with >>", async () => { @@ -460,7 +469,7 @@ const IS_UV_FS_COPYFILE_DISABLED = await using proc = Bun.spawn({ cmd: ["sh", "-c", `"$BUN" -e ${JSON.stringify(script)} >> ${JSON.stringify(log)}`], - env: { ...bunEnv, BUN: bunExe() }, + env: { ...fallbackEnv, BUN: bunExe() }, stdout: "pipe", stderr: "pipe", }); @@ -475,12 +484,11 @@ const IS_UV_FS_COPYFILE_DISABLED = }); }); - // On Linux, FIFO -> FIFO goes through splice(2). fstat on a FIFO reports - // st_size == 0, and the copy loop used to treat its unknown-size probe as - // the total byte budget, silently dropping the rest of the stream. + // fstat on a FIFO reports st_size == 0, so the kernel-copy / bounded loop + // must terminate on EOF, not on the stat-derived budget. // Bun.spawn({stdin:"pipe"}) hands the child a socketpair, not a FIFO, so // run the pipeline under sh to get real kernel pipes on fd 0/1. - it.skipIf(!isLinux)("Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe (> 4096 bytes)", async () => { + it.skipIf(isWindows)("Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe (> 4096 bytes)", async () => { const size = 1024 * 1024; const script = `process.stderr.write(String(await Bun.write(Bun.stdout, Bun.stdin)))`; From ca5e59ba2bf4864e43d290d4a5e37ff6184928d3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:27:38 +0000 Subject: [PATCH 03/11] [autofix.ci] apply automated fixes --- test/js/bun/io/bun-write.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index ebcea9177766..83c238b82ee2 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -7,7 +7,6 @@ import { exampleSite, gcTick, isASAN, - isLinux, isWindows, tempDir, withoutAggressiveGC, From 5622ee128b7a69b805409a4d6ee23d82e4d81623 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:27:56 +0000 Subject: [PATCH 04/11] trim explanatory comments --- src/runtime/webcore/blob/copy_file.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 2060166704e4..30860d244bc0 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -865,12 +865,8 @@ impl<'a> CopyFile<'a> { #[cfg(target_os = "macos")] { - // fcopyfile(COPYFILE_DATA) rewrites the destination from - // offset 0 and the slice-trim below is implemented as - // ftruncate(dest); both destroy bytes in a file the caller - // already had open (Bun.stdout redirected with `>>`, - // Bun.file(fd)). Only take the fcopyfile path for a - // destination this function opened itself. + // fcopyfile rewrites dest from offset 0 and the slice trim is + // ftruncate; both are only safe for a dest Bun opened O_TRUNC. if matches!( self.destination_file_store.pathlike, PathOrFileDescriptor::Path(_) @@ -950,11 +946,6 @@ impl<'a> CopyFile<'a> { } } -/// The read/write fallback used when Linux's kernel copy syscalls are -/// unavailable for this fd pair. For a destination Bun opened itself with -/// `O_TRUNC`, copy the whole source and then `ftruncate` (cheap, matches the -/// historical behaviour). For a caller-supplied fd, copy exactly `cap` bytes -/// so nothing outside the requested window is touched. #[cfg(any(target_os = "linux", target_os = "android"))] fn read_write_fallback( src_fd: Fd, From d3afee1a59f4b91b492feb41c0a1569088862351 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:51:51 +0000 Subject: [PATCH 05/11] only clamp max_length from st_size for regular-file sources 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. --- src/runtime/webcore/blob/copy_file.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 30860d244bc0..25399514fca5 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -789,7 +789,9 @@ impl<'a> CopyFile<'a> { return; } - if stat.st_size != 0 { + // BSD fstat on a pipe reports bytes currently buffered in st_size; + // only a regular-file st_size is a length. + if stat.st_size != 0 && bun_sys::S::ISREG(stat.st_mode as _) { self.max_length = (SizeType::try_from(stat.st_size) .expect("int cast") .min(self.max_length)) @@ -801,7 +803,6 @@ impl<'a> CopyFile<'a> { } if PREALLOCATE_SUPPORTED - && bun_sys::S::ISREG(stat.st_mode as _) && self.max_length > PREALLOCATE_LENGTH && self.max_length != MAX_SIZE { From 7ebc05e461d661dd3d129c8e7250177474f82f91 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:20:28 +0000 Subject: [PATCH 06/11] mark read_write_loop_capped inline(never) for its 64 KB stack buffer --- src/runtime/webcore/blob/copy_file.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 25399514fca5..468a3fc42b30 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -972,6 +972,11 @@ fn read_write_fallback( } } +/// read() from `src_fd`, write() to `dest_fd`, stopping after `cap` bytes (or +/// EOF). Never reads past `cap` and never touches the destination's length, so +/// it is safe for a caller-supplied fd (Bun.stdout redirected to a file, +/// Bun.file(fd)). +#[inline(never)] // 64 KB stack buffer #[cfg(not(windows))] fn read_write_loop_capped( src_fd: Fd, From ca9fd5d6714bf271bb2fedebe5bf6a8f42fdc92a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:21:36 +0000 Subject: [PATCH 07/11] drop doc comment on private read_write_loop_capped --- src/runtime/webcore/blob/copy_file.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 468a3fc42b30..6eff89d51923 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -972,10 +972,6 @@ fn read_write_fallback( } } -/// read() from `src_fd`, write() to `dest_fd`, stopping after `cap` bytes (or -/// EOF). Never reads past `cap` and never touches the destination's length, so -/// it is safe for a caller-supplied fd (Bun.stdout redirected to a file, -/// Bun.file(fd)). #[inline(never)] // 64 KB stack buffer #[cfg(not(windows))] fn read_write_loop_capped( From d04d3fa31c433848fb3c6f94a9f82116b507148e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:31:59 +0000 Subject: [PATCH 08/11] derive fallback cap from the live remain at each call site --- src/runtime/webcore/blob/copy_file.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 6eff89d51923..cf4388fafe4f 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -319,10 +319,8 @@ impl<'a> CopyFile<'a> { self.destination_file_store.pathlike, PathOrFileDescriptor::Path(_) ); - let fallback_cap = if unknown_size { - MAX_SIZE - } else { - remain as SizeType + let fallback_cap = |remain: usize| -> SizeType { + if unknown_size { MAX_SIZE } else { remain as SizeType } }; // defer { this.read_len = @truncate(total_written); } @@ -343,7 +341,7 @@ impl<'a> CopyFile<'a> { src_fd, dest_fd, bun_opened_dest, - fallback_cap, + fallback_cap(remain), &mut total_written, ) { bun_sys::Result::Err(err) => { @@ -408,7 +406,7 @@ impl<'a> CopyFile<'a> { src_fd, dest_fd, bun_opened_dest, - fallback_cap, + fallback_cap(remain), &mut total_written, ) { bun_sys::Result::Err(err) => { @@ -455,7 +453,7 @@ impl<'a> CopyFile<'a> { src_fd, dest_fd, bun_opened_dest, - fallback_cap, + fallback_cap(remain), &mut total_written, ) { bun_sys::Result::Err(err) => { From 7e7d9a0a062e1e434842e3e245c2ceec006307fb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:34:12 +0000 Subject: [PATCH 09/11] [autofix.ci] apply automated fixes --- src/runtime/webcore/blob/copy_file.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index cf4388fafe4f..bb908d8d8f07 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -320,7 +320,11 @@ impl<'a> CopyFile<'a> { PathOrFileDescriptor::Path(_) ); let fallback_cap = |remain: usize| -> SizeType { - if unknown_size { MAX_SIZE } else { remain as SizeType } + if unknown_size { + MAX_SIZE + } else { + remain as SizeType + } }; // defer { this.read_len = @truncate(total_written); } From 93b4e97081c41e59598414f991caed75829e0511 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:50:38 +0000 Subject: [PATCH 10/11] gate preallocate_file on a path destination 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. --- src/runtime/webcore/blob/copy_file.rs | 4 ++++ test/js/bun/io/bun-write.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index bb908d8d8f07..cf4aae0668e7 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -805,6 +805,10 @@ impl<'a> CopyFile<'a> { } if PREALLOCATE_SUPPORTED + && matches!( + self.destination_file_store.pathlike, + PathOrFileDescriptor::Path(_) + ) && self.max_length > PREALLOCATE_LENGTH && self.max_length != MAX_SIZE { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 83c238b82ee2..77d6d66de3a3 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -481,6 +481,31 @@ const IS_UV_FS_COPYFILE_DISABLED = }); expect(exitCode).toBe(0); }); + + it("does not fallocate an O_APPEND fd for a source above the preallocate threshold", async () => { + const size = 3_000_000; + using dir = tempDir("bun-write-fd-preallocate", { "dst.bin": "AAAAAAAAAA" }); + const src = join(String(dir), "src.bin"); + const dst = join(String(dir), "dst.bin"); + fs.writeFileSync(src, Buffer.alloc(size, "S")); + const script = ` + const fs = require("fs"); + const fd = fs.openSync(${JSON.stringify(dst)}, "a"); + try { + process.stderr.write(String(await Bun.write(Bun.file(fd), Bun.file(${JSON.stringify(src)})))); + } finally { fs.closeSync(fd); } + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: fallbackEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, resolved: stderr, head: fs.readFileSync(dst).subarray(0, 15).toString(), size: fs.statSync(dst).size }).toEqual({ + stdout: "", + resolved: String(size), + head: "AAAAAAAAAASSSSS", + size: 10 + size, + }); + expect(exitCode).toBe(0); + }); }); // fstat on a FIFO reports st_size == 0, so the kernel-copy / bounded loop From 43b197b7ac63c8e401abe48600df8935a0309275 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:53:01 +0000 Subject: [PATCH 11/11] [autofix.ci] apply automated fixes --- test/js/bun/io/bun-write.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 77d6d66de3a3..f02bdf4f8e17 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -498,7 +498,12 @@ const IS_UV_FS_COPYFILE_DISABLED = await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: fallbackEnv, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, resolved: stderr, head: fs.readFileSync(dst).subarray(0, 15).toString(), size: fs.statSync(dst).size }).toEqual({ + expect({ + stdout, + resolved: stderr, + head: fs.readFileSync(dst).subarray(0, 15).toString(), + size: fs.statSync(dst).size, + }).toEqual({ stdout: "", resolved: String(size), head: "AAAAAAAAAASSSSS",