Bun.write: don't over-copy or ftruncate a caller-supplied destination fd

Merged
RO
3mo ago
+245-85

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 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 #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