From e1c701f010d9963387730ea0df00c6a08d7db683 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:59:13 +0000 Subject: [PATCH 1/5] Bun.write: handle an O_APPEND destination fd copy_file_range(2), sendfile(2) and splice(2) reject a destination whose open file description has O_APPEND. Bun treated the EBADF as fatal, so Bun.write(Bun.stdout, Bun.file(p)) failed under '>> log', under a GNU make recipe, and on an fd opened with 'a'. On a tty destination Bun cleared O_APPEND on the shared description with F_SETFL and never restored it. For a string or Blob source above 1 KiB Bun preallocated the destination from offset 0, so the appended data landed after a hole of NUL bytes. Check F_GETFL on a caller's destination fd. If O_APPEND is set, copy a file source with the read/write loop and skip the preallocate for a memory source. Remove the flag clearing. --- src/runtime/webcore/blob/copy_file.rs | 66 ++++---------- src/runtime/webcore/blob/write_file.rs | 15 +++- test/js/bun/io/bun-write.test.js | 116 +++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 49 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index d5a6edc8bafb..9d65ce89f7a8 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -19,8 +19,6 @@ use bun_sys::windows::libuv; use bun_sys::{self, Fd, FdExt, Mode, SystemError}; #[cfg(windows)] use bun_sys_jsc::ErrorJsc as _; -#[cfg(any(target_os = "linux", target_os = "android"))] -use core::ffi::c_int; #[cfg(windows)] use core::ffi::c_void; use core::marker::ConstParamTy; @@ -340,9 +338,7 @@ impl CopyFile { } #[cfg(any(target_os = "linux", target_os = "android"))] - pub(crate) fn do_copy_file_range( - &mut self, - ) -> Result<(), crate::Error> { + pub(crate) fn do_copy_file_range(&mut self) -> Result<(), crate::Error> { use bun_sys::linux; let mut remain: usize = self.max_length as usize; @@ -367,14 +363,26 @@ impl CopyFile { unsafe { *read_len_slot = *total_written_slot as SizeType }; } - let mut has_unset_append = false; - // 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() { return self.fallback_read_write(remain, unknown_size, &mut total_written); } + // copy_file_range(2), sendfile(2) and splice(2) refuse a destination + // whose open file description has O_APPEND (EBADF or EINVAL). Bun + // never opens its own destination that way, but an inherited fd can + // carry it: `bun x.js >> log`, a GNU make recipe, `fs.openSync(p, "a")`. + // The description is shared with the parent, so the flag must stay. + if matches!( + self.destination_file_store.pathlike, + PathOrFileDescriptor::Fd(_) + ) && bun_sys::get_fcntl_flags(dest_fd) + .is_ok_and(|flags| flags as i32 & bun_sys::O::APPEND != 0) + { + return self.fallback_read_write(remain, unknown_size, &mut total_written); + } + loop { // TODO: this should use non-blocking I/O. let written: isize = match USE { @@ -431,29 +439,6 @@ impl CopyFile { // EINVAL: eCryptfs and other filesystems may not support copy_file_range. // Also returned when the file descriptor is incompatible with the syscall. bun_sys::E::EINVAL => { - if CLEAR_APPEND_IF_INVALID { - if !has_unset_append { - // https://kylelaker.com/2018/08/31/stdout-oappend.html - // make() can set STDOUT / STDERR to O_APPEND - // this messes up sendfile() - has_unset_append = true; - // SAFETY: dest_fd is a valid open fd; raw fcntl(2). - let flags = - unsafe { libc::fcntl(dest_fd.native(), libc::F_GETFL, 0 as c_int) }; - if (flags & bun_sys::O::APPEND) != 0 { - // SAFETY: dest_fd is a valid open fd; raw fcntl(2). - let _ = unsafe { - libc::fcntl( - dest_fd.native(), - libc::F_SETFL, - flags ^ bun_sys::O::APPEND, - ) - }; - continue; - } - } - } - // If the Linux machine doesn't support // copy_file_range or the file descriptor is // incompatible with the chosen syscall, fall back @@ -820,12 +805,7 @@ impl CopyFile { && (bun_sys::S::ISREG(self.destination_file_store.mode as _) || self.destination_file_store.mode == 0) { - if self.destination_file_store.is_atty.unwrap_or(false) { - let _ = self.do_copy_file_range::<{ TryWith::CopyFileRange }, true>(); - } else { - let _ = self.do_copy_file_range::<{ TryWith::CopyFileRange }, false>(); - } - + let _ = self.do_copy_file_range::<{ TryWith::CopyFileRange }>(); self.do_close(); return; } @@ -834,12 +814,7 @@ impl CopyFile { if bun_sys::S::ISFIFO(stat.st_mode as _) && bun_sys::S::ISFIFO(self.destination_file_store.mode as _) { - if self.destination_file_store.is_atty.unwrap_or(false) { - let _ = self.do_copy_file_range::<{ TryWith::Splice }, true>(); - } else { - let _ = self.do_copy_file_range::<{ TryWith::Splice }, false>(); - } - + let _ = self.do_copy_file_range::<{ TryWith::Splice }>(); self.do_close(); return; } @@ -848,12 +823,7 @@ impl CopyFile { || bun_sys::S::ISCHR(stat.st_mode as _) || bun_sys::S::ISSOCK(stat.st_mode as _) { - if self.destination_file_store.is_atty.unwrap_or(false) { - let _ = self.do_copy_file_range::<{ TryWith::Sendfile }, true>(); - } else { - let _ = self.do_copy_file_range::<{ TryWith::Sendfile }, false>(); - } - + let _ = self.do_copy_file_range::<{ TryWith::Sendfile }>(); self.do_close(); return; } diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 9de9be8c72bf..788451e84071 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -411,6 +411,16 @@ impl WriteFile { .is_path() } + /// Preallocating grows the file from offset 0. A caller's fd opened + /// O_APPEND (`>> log`, `fs.openSync(p, "a")`) writes after the grown end, + /// which leaves a hole of NUL bytes before the data. Bun opens its own + /// destination with O_TRUNC, so only a caller's fd can append. + #[cfg(any(target_os = "linux", target_os = "android"))] + fn appends_to_caller_fd(&self, fd: Fd) -> bool { + !self.is_allowed_to_close() + && sys::get_fcntl_flags(fd).is_ok_and(|flags| flags as i32 & sys::O::APPEND != 0) + } + #[cfg(not(windows))] fn on_finish(&mut self) { bun_output::scoped_log!(WriteFile, "WriteFile.onFinish()"); @@ -473,7 +483,10 @@ impl WriteFile { // We only do this on Linux because the equivalent on macOS // seemed to have zero performance impact in // microbenchmarks. - if !self.could_block && self.bytes_blob.shared_view().len() > 1024 { + if !self.could_block + && self.bytes_blob.shared_view().len() > 1024 + && !self.appends_to_caller_fd(fd) + { let _ = sys::preallocate_file( fd.native(), 0, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 1806305f3200..876653432413 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -7,6 +7,7 @@ import { exampleSite, gcTick, isASAN, + isLinux, isWindows, tempDir, withoutAggressiveGC, @@ -515,6 +516,121 @@ const IS_UV_FS_COPYFILE_DISABLED = }); }); + // copy_file_range(2) and sendfile(2) reject a destination whose open file + // description has O_APPEND (EBADF / EINVAL). `>> log`, a GNU make recipe + // and fs.openSync(p, "a") all hand Bun such a destination. + describe.skipIf(isWindows)("Bun.write(dest, Bun.file(src)) with an O_APPEND destination", () => { + it("appends to stdout redirected with >>", async () => { + using dir = tempDir("bun-write-stdout-append-cfr", { + "src.txt": "file-content\n", + "log.txt": "header\n", + }); + const src = join(String(dir), "src.txt"); + const log = join(String(dir), "log.txt"); + const script = `process.stderr.write(String(await Bun.write(Bun.stdout, 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: "13", + content: "header\nfile-content\n", + }); + expect(exitCode).toBe(0); + }); + + it("appends to an fd opened with 'a' and 'a+'", async () => { + using dir = tempDir("bun-write-fd-append-cfr", { + "src.txt": "payload\n", + "log.txt": "header\n", + }); + const src = join(String(dir), "src.txt"); + const log = join(String(dir), "log.txt"); + const script = ` + const fs = require("fs"); + const out = []; + for (const flag of ["a", "a+"]) { + const fd = fs.openSync(${JSON.stringify(log)}, flag); + try { + out.push(await Bun.write(Bun.file(fd), Bun.file(${JSON.stringify(src)}))); + } finally { fs.closeSync(fd); } + } + process.stdout.write(JSON.stringify(out)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, content: fs.readFileSync(log, "utf8") }).toEqual({ + stdout: "[8,8]", + stderr: "", + content: "header\npayload\npayload\n", + }); + expect(exitCode).toBe(0); + }); + + it("appends a string above the preallocate threshold to stdout redirected with >>", async () => { + using dir = tempDir("bun-write-stdout-append-string", { "log.txt": "header\n" }); + const log = join(String(dir), "log.txt"); + const size = 4000; + const script = `process.stderr.write(String(await Bun.write(Bun.stdout, Buffer.alloc(${size}, "y").toString() + "\\n")))`; + + 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: String(size + 1), + content: "header\n" + Buffer.alloc(size, "y").toString() + "\n", + }); + expect(exitCode).toBe(0); + }); + + // A GNU make recipe on a terminal gets a tty stdout with O_APPEND. The + // description is shared with the parent shell, so Bun must not clear it. + it.skipIf(!isLinux)("keeps O_APPEND on an inherited tty description", async () => { + using dir = tempDir("bun-write-tty-append-flag", { "src.txt": "payload\n" }); + const src = join(String(dir), "src.txt"); + const flags = "grep flags /proc/$$/fdinfo/1 | tr -d '\\t'"; + const sh = [ + "exec 1>>/dev/tty", + `before=$(${flags})`, + `"$BUN" -e 'await Bun.write(Bun.stdout, Bun.file(${JSON.stringify(src)}))'`, + `echo "rc=$? before=$before after=$(${flags})" >&2`, + ].join("; "); + let output = ""; + await using proc = Bun.spawn({ + cmd: ["sh", "-c", sh], + env: { ...bunEnv, BUN: bunExe() }, + terminal: { + data(_, chunk) { + output += chunk.toString(); + }, + }, + }); + const exitCode = await proc.exited; + + const [, rc, before, after] = /rc=(\d+) before=flags:(\d+) after=flags:(\d+)/.exec(output) ?? []; + expect({ output: output.replace(/\r/g, ""), rc, appendBefore: Number(`0o${before}`) & 0o2000 }).toEqual({ + output: "payload\n" + `rc=0 before=flags:${before} after=flags:${before}\n`, + rc: "0", + appendBefore: 0o2000, + }); + expect(after).toBe(before); + 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 From 1a16cbc0168acee65c4e1679fc6aa7a757ebdc86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:32:23 +0000 Subject: [PATCH 2/5] One-line comments --- src/runtime/webcore/blob/copy_file.rs | 6 +----- src/runtime/webcore/blob/write_file.rs | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 7ca6baa3e017..01454e64f687 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -369,11 +369,7 @@ impl CopyFile { return self.fallback_read_write(remain, unknown_size, &mut total_written); } - // copy_file_range(2), sendfile(2) and splice(2) refuse a destination - // whose open file description has O_APPEND (EBADF or EINVAL). Bun - // never opens its own destination that way, but an inherited fd can - // carry it: `bun x.js >> log`, a GNU make recipe, `fs.openSync(p, "a")`. - // The description is shared with the parent, so the flag must stay. + // The kernel copies reject an O_APPEND destination (`>> log`, make, an "a" fd); the description is shared, so keep the flag. if matches!( self.destination_file_store.pathlike, PathOrFileDescriptor::Fd(_) diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 29b3367352a7..7ed7d1101bd0 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -395,10 +395,7 @@ impl WriteFile { .is_path() } - /// Preallocating grows the file from offset 0. A caller's fd opened - /// O_APPEND (`>> log`, `fs.openSync(p, "a")`) writes after the grown end, - /// which leaves a hole of NUL bytes before the data. Bun opens its own - /// destination with O_TRUNC, so only a caller's fd can append. + /// `fallocate` on a caller's O_APPEND fd leaves a hole of NUL bytes before the appended data. #[cfg(any(target_os = "linux", target_os = "android"))] fn appends_to_caller_fd(&self, fd: Fd) -> bool { !self.is_allowed_to_close() From 74e5aedaea3b5db85cbf2a669281e3c0f611f272 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:01:38 +0000 Subject: [PATCH 3/5] Bun.write: leave the preallocation of a memory source as it is on main This PR no longer skips the WriteFile preallocation for an O_APPEND fd. The NUL bytes that the preallocation leaves before appended data come from fallocate growing the file, and #44425 changes that in preallocate_file for every caller. The test for that case goes with it. The remaining tests move next to the Bun.write(Bun.stdout, Bun.file(path)) tests, and the stdout test gets a name of its own, so the two PRs do not touch the same lines of bun-write.test.js. --- src/runtime/webcore/blob/write_file.rs | 12 +- test/js/bun/io/bun-write.test.js | 202 +++++++++++-------------- 2 files changed, 91 insertions(+), 123 deletions(-) diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 7ed7d1101bd0..1b0cf6612a57 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -395,13 +395,6 @@ impl WriteFile { .is_path() } - /// `fallocate` on a caller's O_APPEND fd leaves a hole of NUL bytes before the appended data. - #[cfg(any(target_os = "linux", target_os = "android"))] - fn appends_to_caller_fd(&self, fd: Fd) -> bool { - !self.is_allowed_to_close() - && sys::get_fcntl_flags(fd).is_ok_and(|flags| flags as i32 & sys::O::APPEND != 0) - } - #[cfg(not(windows))] fn on_finish(&mut self) { bun_output::scoped_log!(WriteFile, "WriteFile.onFinish()"); @@ -464,10 +457,7 @@ impl WriteFile { // We only do this on Linux because the equivalent on macOS // seemed to have zero performance impact in // microbenchmarks. - if !self.could_block - && self.bytes_blob.shared_view().len() > 1024 - && !self.appends_to_caller_fd(fd) - { + if !self.could_block && self.bytes_blob.shared_view().len() > 1024 { let _ = sys::preallocate_file( fd.native(), 0, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index c42cf58b1e49..5affd5e8e526 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -450,100 +450,11 @@ 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 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); - }); - }); - // copy_file_range(2) and sendfile(2) reject a destination whose open file // description has O_APPEND (EBADF / EINVAL). `>> log`, a GNU make recipe // and fs.openSync(p, "a") all hand Bun such a destination. describe.skipIf(isWindows)("Bun.write(dest, Bun.file(src)) with an O_APPEND destination", () => { - it("appends to stdout redirected with >>", async () => { + it("appends a file to stdout redirected with >>", async () => { using dir = tempDir("bun-write-stdout-append-cfr", { "src.txt": "file-content\n", "log.txt": "header\n", @@ -597,28 +508,6 @@ const IS_UV_FS_COPYFILE_DISABLED = expect(exitCode).toBe(0); }); - it("appends a string above the preallocate threshold to stdout redirected with >>", async () => { - using dir = tempDir("bun-write-stdout-append-string", { "log.txt": "header\n" }); - const log = join(String(dir), "log.txt"); - const size = 4000; - const script = `process.stderr.write(String(await Bun.write(Bun.stdout, Buffer.alloc(${size}, "y").toString() + "\\n")))`; - - 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: String(size + 1), - content: "header\n" + Buffer.alloc(size, "y").toString() + "\n", - }); - expect(exitCode).toBe(0); - }); - // A GNU make recipe on a terminal gets a tty stdout with O_APPEND. The // description is shared with the parent shell, so Bun must not clear it. it.skipIf(!isLinux)("keeps O_APPEND on an inherited tty description", async () => { @@ -654,6 +543,95 @@ const IS_UV_FS_COPYFILE_DISABLED = }); }); + // 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 From f8ed5ad8390aee08459a85a58f641e497233789c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:03:18 +0000 Subject: [PATCH 4/5] Bun.write: use read/write when copy_file_range rejects an O_APPEND destination copy_file_range(2) fails with EBADF on a destination opened with O_APPEND. When the first copy_file_range fails with EBADF and F_GETFL shows a writable destination with O_APPEND, copy with the read/write loop. A destination that is not writable stays the same error, and nothing is read from the source. This replaces the F_GETFL check before the copy, so a copy to a destination without O_APPEND makes the same syscalls as before. Tests: a destination fd that is not writable still rejects and leaves the source fd unread. The tty test reads the flags of fd 1 from the bun script (no grep or tr), and then checks that a log that takes the place of the tty is appended to. The "a" and "a+" test runs in the test process with a source above 64 KiB. Co-authored-by: Juraj Kirchheim --- src/runtime/webcore/blob/copy_file.rs | 28 ++++-- test/js/bun/io/bun-write.test.js | 133 +++++++++++++++++++------- 2 files changed, 114 insertions(+), 47 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 01454e64f687..420c6b5374e2 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -369,16 +369,6 @@ impl CopyFile { return self.fallback_read_write(remain, unknown_size, &mut total_written); } - // The kernel copies reject an O_APPEND destination (`>> log`, make, an "a" fd); the description is shared, so keep the flag. - if matches!( - self.destination_file_store.pathlike, - PathOrFileDescriptor::Fd(_) - ) && bun_sys::get_fcntl_flags(dest_fd) - .is_ok_and(|flags| flags as i32 & bun_sys::O::APPEND != 0) - { - return self.fallback_read_write(remain, unknown_size, &mut total_written); - } - loop { // TODO: this should use non-blocking I/O. let written: isize = match USE { @@ -432,6 +422,15 @@ impl CopyFile { return self.fallback_read_write(remain, unknown_size, &mut total_written); } + // EBADF: copy_file_range rejects an O_APPEND destination (`>> log`, an "a" fd). + bun_sys::E::EBADF + if matches!(USE, TryWith::CopyFileRange) + && total_written == 0 + && is_writable_append(dest_fd) => + { + return self.fallback_read_write(remain, unknown_size, &mut total_written); + } + // EINVAL: eCryptfs and other filesystems may not support copy_file_range. // Also returned when the file descriptor is incompatible with the syscall. bun_sys::E::EINVAL => { @@ -914,6 +913,15 @@ impl CopyFile { } } +#[cfg(any(target_os = "linux", target_os = "android"))] +fn is_writable_append(fd: Fd) -> bool { + bun_sys::get_fcntl_flags(fd).is_ok_and(|flags| { + let flags = flags as i32; + let writable = matches!(flags & libc::O_ACCMODE, libc::O_WRONLY | libc::O_RDWR); + writable && flags & libc::O_APPEND != 0 + }) +} + #[cfg(any(target_os = "linux", target_os = "android"))] fn read_write_fallback( src_fd: Fd, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 3c9749754f46..ac58e060f12f 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -454,7 +454,9 @@ const IS_UV_FS_COPYFILE_DISABLED = // copy_file_range(2) and sendfile(2) reject a destination whose open file // description has O_APPEND (EBADF / EINVAL). `>> log`, a GNU make recipe - // and fs.openSync(p, "a") all hand Bun such a destination. + // redirected to a file and fs.openSync(p, "a") all hand Bun such a + // destination. Windows copies a disk file by path (uv_fs_copyfile), which + // does not append to an fd destination. describe.skipIf(isWindows)("Bun.write(dest, Bun.file(src)) with an O_APPEND destination", () => { it("appends a file to stdout redirected with >>", async () => { using dir = tempDir("bun-write-stdout-append-cfr", { @@ -482,65 +484,122 @@ const IS_UV_FS_COPYFILE_DISABLED = }); it("appends to an fd opened with 'a' and 'a+'", async () => { - using dir = tempDir("bun-write-fd-append-cfr", { - "src.txt": "payload\n", - "log.txt": "header\n", - }); + // Above 64 KiB, so the read/write loop takes more than one step. + const body = Buffer.alloc(100_000, "S").toString(); + using dir = tempDir("bun-write-fd-append-cfr", { "src.txt": body, "log.txt": "header\n" }); const src = join(String(dir), "src.txt"); const log = join(String(dir), "log.txt"); - const script = ` - const fs = require("fs"); - const out = []; - for (const flag of ["a", "a+"]) { - const fd = fs.openSync(${JSON.stringify(log)}, flag); - try { - out.push(await Bun.write(Bun.file(fd), Bun.file(${JSON.stringify(src)}))); - } finally { fs.closeSync(fd); } + const written = []; + for (const flag of ["a", "a+"]) { + const fd = fs.openSync(log, flag); + try { + written.push(await Bun.write(Bun.file(fd), Bun.file(src))); + } finally { + fs.closeSync(fd); } - process.stdout.write(JSON.stringify(out)); - `; - await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + } + + const content = fs.readFileSync(log, "utf8"); + expect({ written, size: content.length, intact: content === "header\n" + body + body }).toEqual({ + written: [100_000, 100_000], + size: 7 + 200_000, + intact: true, + }); + }); + + // copy_file_range also reports a destination that is not writable as + // EBADF. That stays an error, and nothing is read from the source fd. + it.skipIf(!isLinux)("rejects a destination fd that is not writable and leaves the source fd unread", async () => { + using dir = tempDir("bun-write-fd-not-writable", { + "src.txt": "payload\n", + "log.txt": "header\n", + "write.mjs": ` + import fs from "node:fs"; + const results = []; + for (const flags of [fs.constants.O_RDONLY, fs.constants.O_RDONLY | fs.constants.O_APPEND]) { + const src = fs.openSync("src.txt", "r"); + const dest = fs.openSync("log.txt", flags); + const code = await Bun.write(Bun.file(dest), Bun.file(src)).then(() => "resolved", e => e.code); + results.push({ code, unread: fs.readSync(src, Buffer.alloc(64)) }); + fs.closeSync(dest); + fs.closeSync(src); + } + console.log(JSON.stringify(results)); + `, + }); + // In a child: another test in this process can switch the kernel copy off. + await using proc = Bun.spawn({ + cmd: [bunExe(), "write.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, stderr, content: fs.readFileSync(log, "utf8") }).toEqual({ - stdout: "[8,8]", + expect({ + results: stdout.trim(), + stderr, + content: fs.readFileSync(join(String(dir), "log.txt"), "utf8"), + }).toEqual({ + results: JSON.stringify([ + { code: "EBADF", unread: 8 }, + { code: "EBADF", unread: 8 }, + ]), stderr: "", - content: "header\npayload\npayload\n", + content: "header\n", }); expect(exitCode).toBe(0); }); - // A GNU make recipe on a terminal gets a tty stdout with O_APPEND. The - // description is shared with the parent shell, so Bun must not clear it. - it.skipIf(!isLinux)("keeps O_APPEND on an inherited tty description", async () => { - using dir = tempDir("bun-write-tty-append-flag", { "src.txt": "payload\n" }); - const src = join(String(dir), "src.txt"); - const flags = "grep flags /proc/$$/fdinfo/1 | tr -d '\\t'"; - const sh = [ - "exec 1>>/dev/tty", - `before=$(${flags})`, - `"$BUN" -e 'await Bun.write(Bun.stdout, Bun.file(${JSON.stringify(src)}))'`, - `echo "rc=$? before=$before after=$(${flags})" >&2`, - ].join("; "); + // GNU make 4.0 to 4.3 sets O_APPEND on a recipe's stdout when it is a + // terminal. The description is shared with the parent shell, so Bun must + // not clear the flag. Then fd 1 becomes a log opened for append, as in a + // process that detaches from its terminal: the copy must land at the end. + it.skipIf(!isLinux)("keeps O_APPEND on a tty description and on a log that takes its place", async () => { + using dir = tempDir("bun-write-tty-append-flag", { + "src.txt": "payload\n", + "log.txt": "header-header\n", + "write.mjs": ` + import fs from "node:fs"; + const flags = () => /^flags:\\s*(\\d+)/m.exec(fs.readFileSync("/proc/self/fdinfo/1", "utf8"))[1]; + const stdout = Bun.stdout; + const before = flags(); + await Bun.write(stdout, Bun.file("src.txt")); + process.stderr.write("before=" + before + " after=" + flags() + "\\n"); + + fs.closeSync(1); + const fd = fs.openSync("log.txt", "a"); + const written = await Bun.write(stdout, Bun.file("src.txt")); + process.stderr.write(JSON.stringify({ fd, written }) + "\\n"); + `, + }); let output = ""; + const { promise: ptyClosed, resolve } = Promise.withResolvers(); await using proc = Bun.spawn({ - cmd: ["sh", "-c", sh], + cmd: ["sh", "-c", `exec 1>>/dev/tty; exec "$BUN" write.mjs`], + cwd: String(dir), env: { ...bunEnv, BUN: bunExe() }, terminal: { data(_, chunk) { output += chunk.toString(); }, + exit: () => resolve(), }, }); const exitCode = await proc.exited; + await ptyClosed; - const [, rc, before, after] = /rc=(\d+) before=flags:(\d+) after=flags:(\d+)/.exec(output) ?? []; - expect({ output: output.replace(/\r/g, ""), rc, appendBefore: Number(`0o${before}`) & 0o2000 }).toEqual({ - output: "payload\n" + `rc=0 before=flags:${before} after=flags:${before}\n`, - rc: "0", + const [, before] = /before=(\d+) after=\d+/.exec(output) ?? []; + expect({ + output: output.replace(/\r/g, ""), + appendBefore: Number(`0o${before}`) & 0o2000, + log: fs.readFileSync(join(String(dir), "log.txt"), "utf8"), + }).toEqual({ + output: `payload\nbefore=${before} after=${before}\n{"fd":1,"written":8}\n`, appendBefore: 0o2000, + log: "header-header\npayload\n", }); - expect(after).toBe(before); expect(exitCode).toBe(0); }); }); From 190b97c98d79e464b757700e38f2b6094eacf052 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:48:41 +0000 Subject: [PATCH 5/5] Bun.write: read the access mode through bun_sys::O is_writable_append compared the F_GETFL flags with raw libc constants. The rest of the runtime uses bun_sys::O. Add ACCMODE to bun_sys::O for unix (it was defined for Windows only) and use that table. --- src/runtime/webcore/blob/copy_file.rs | 4 ++-- src/sys/lib.rs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 420c6b5374e2..44dfb21bd994 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -915,10 +915,10 @@ impl CopyFile { #[cfg(any(target_os = "linux", target_os = "android"))] fn is_writable_append(fd: Fd) -> bool { + use bun_sys::O; bun_sys::get_fcntl_flags(fd).is_ok_and(|flags| { let flags = flags as i32; - let writable = matches!(flags & libc::O_ACCMODE, libc::O_WRONLY | libc::O_RDWR); - writable && flags & libc::O_APPEND != 0 + matches!(flags & O::ACCMODE, O::WRONLY | O::RDWR) && flags & O::APPEND != 0 }) } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 14ee170f6bb6..33f37210ae1c 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1189,6 +1189,8 @@ pub mod O { pub const NOCTTY: i32 = libc::O_NOCTTY; #[cfg(windows)] pub const NOCTTY: i32 = 0; + #[cfg(unix)] + pub const ACCMODE: i32 = libc::O_ACCMODE; #[cfg(windows)] pub const ACCMODE: i32 = 3; #[cfg(target_os = "macos")]