diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 1b0833eacfc6..cf4aae0668e7 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -315,6 +315,17 @@ 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 = |remain: usize| -> SizeType { + 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 +341,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(remain), &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 +406,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(remain), &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 +453,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(remain), &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,6 +508,22 @@ impl<'a> CopyFile<'a> { Ok(()) } + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + fn do_read_write_loop_capped(&mut self, cap: SizeType) -> Result<(), crate::Error> { + let mut total: u64 = 0; + 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(()) + } + 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()) + } + } + } + #[cfg(target_os = "macos")] pub(crate) fn do_fcopy_file_with_read_write_loop_fallback( &mut self, @@ -794,7 +791,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)) @@ -806,7 +805,10 @@ impl<'a> CopyFile<'a> { } if PREALLOCATE_SUPPORTED - && bun_sys::S::ISREG(stat.st_mode as _) + && matches!( + self.destination_file_store.pathlike, + PathOrFileDescriptor::Path(_) + ) && self.max_length > PREALLOCATE_LENGTH && self.max_length != MAX_SIZE { @@ -870,21 +872,28 @@ impl<'a> CopyFile<'a> { #[cfg(target_os = "macos")] { - if self.do_fcopy_file_with_read_write_loop_fallback().is_err() { + // 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(_) + ) { + 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 +901,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; @@ -936,6 +953,62 @@ impl<'a> CopyFile<'a> { } } +#[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) + } +} + +#[inline(never)] // 64 KB stack buffer +#[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 be0c08c29c02..f02bdf4f8e17 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, @@ -425,12 +424,100 @@ const IS_UV_FS_COPYFILE_DISABLED = await Bun.write(Bun.stderr, Bun.file(path.join(import.meta.dir, "hello-world.txt"))); }); - // 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. + // 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 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 () => { + 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: { ...fallbackEnv, 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); + }); + + 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 + // 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)))`;