Bun.write: don't over-copy or ftruncate a caller-supplied destination fd
Repro
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 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. - FreeBSD and the Linux read/write fallback (kernel < 4.5,
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/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 thefcopyfile/ftruncatebranch only whendestination_file_store.pathlikeis a path. - Linux
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.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 #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). #32859 reworks the same block to honour source slices and #33715 sets read_len after fcopyfile; 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