Bun.write, fs.writeFile: do not resize the file behind /dev/stdout or /dev/fd/N (macOS, FreeBSD)

RO
23d ago
+353-17

Problem

  • On macOS, bun -e 'fs.writeFileSync("/dev/stdout", "new\n")' >> app.log cuts app.log to its first 4 bytes. Bun.write("/dev/stdout", ...) does the same. Node keeps the file.
  • macOS and FreeBSD open /dev/stdin, /dev/stdout, /dev/stderr and /dev/fd/N as a dup of that descriptor. Bun then sets the length: ftruncate(fd, written) (src/runtime/webcore/Blob.rs:5200, src/runtime/node/node_fs.rs:7482) or fcopyfile (src/runtime/webcore/blob/copy_file.rs:869).
  • To a pipe, the file copy resolves 0 and writes nothing.

Fix

  • bun_sys::path_opens_as_dup names those paths. Such a destination is the caller's descriptor (#36758).
  • Bun.write(path, Bun.file(src)) copies to it as to a descriptor: a read/write loop, no fcopyfile, no trim.
  • Bun.write(path, string or bytes) and fs.writeFile skip that ftruncate. The open keeps O_TRUNC, which a dup ignores.
  • Verified: test/js/bun/io/bun-write.test.js, test/js/node/fs/fs.test.ts. On main, 46 rows fail on macOS 15 and 26, 25 on FreeBSD 14.3. All pass with the fix. Node passes the same fs.writeFile rows.

Background

  • A dup shares the offset and flags of the descriptor. Linux opens the file again for these names.
  • Bun opens a truncating write without O_TRUNC and cuts the old tail after it.
  • Considered: O_TRUNC at each open as in Node (over 10 times slower on ext4). Also an lseek before each resize.

Downsides

  • Through these names, 1<> file keeps its tail and an empty write keeps the file, as in Node. Bun.write(Bun.stdout, "") still empties it (#32890).
  • Still cut: a symlink to one of the names, other spellings (//dev/stdout), fs.copyFile, fs.cp, fs.promises.writeFile(path, iterable).
  • Cost per write by path on macOS and FreeBSD: 16 to 35 instructions (x86-64 count), no syscall. Linux: none in the fast paths.
Notes

Where this came from. No issue reports the path spelling. It was found in the sources during #43388 and then confirmed in CI. The same scenario (bun x.js >> log) with Bun.stdout as the destination has its own reports and its own open PRs. They are #44387 and #44425 (NUL bytes after fallocate), #41685 (copy_file_range to an O_APPEND descriptor), #13477 and #32890 (an empty input). This PR does not change those cases. The trailing resize was changed before for #2931, #10222 and #33355, and by #36758 for descriptor destinations.

Before the fix, on macOS. Build 124518 ran the tests of this PR with no source change, on macOS 15.7.7 arm64, macOS 15.7.9 x64 and macOS 26.4 arm64. The three machines agree. The file held 200 bytes of A, and each call wrote S bytes.

descriptorexpectedmain
>>, 2>>, 3>>, 100 bytesA×200 S×100A×100
> after 200 bytes of outputH×200 S×100H×100
1<>, 0<>, 3<>, 100 bytesS×100 A×100S×100
>>, file source of 300000 bytesA×200 S×300000A×200 S×299800
>>, empty string, empty bytes, empty fileA×200empty file
>>, Bun.file("/dev/stdout").slice(0, 50)A×200 S×50, resolves 50A×50
pipe, file sourcethe bytes, resolves the countnothing, resolves 0

The rows for a string above 256 KiB pass on main: that path opens with O_TRUNC and does not resize.

The runs on macOS. The macOS versions are from the job logs. The lanes with 14 in their name run macOS 14.8 or macOS 15.7. A cell gives the failed rows in bun-write.test.js plus those in fs.test.ts.

buildsourcemacOS 14.8.9 x64macOS 15.7macOS 26 arm64
123299main, the first 30 rows21no run21
124518main, all rowsno run31 + 15 (arm64), 31 + 15 (x64)31 + 15
1245688b2ff1900 (arm64)0
12481645064ac, with the rows for Nodeno run0 (arm64), 0 (x64)0
124830886c89dno run0 (arm64), 0 (x64)0

Node. fs.writeFile in Node opens with O_TRUNC and does not resize. In builds 124816 and 124830 the Node of the CI machines (v26.3.0) ran the 17 fs.writeFile rows on macOS 15.7.7 arm64, macOS 15.7.9 x64 and macOS 26.4 arm64. All pass, with the same expected values as the rows for Bun: A×200 S×100 under >>, S×100 A×100 under 1<>, and A×200 for an empty string. The Linux build of Node 26.3.0, run through the Linux ABI of the FreeBSD 14.3 kernel, gives the same values. It also keeps the file for //dev/stdout, for /dev/./stdout and for a symlink to /dev/stdout.

The runs on FreeBSD. FreeBSD 14.3-RELEASE amd64 in QEMU. "main" is the canary build of the merge base (6c1eb067e). "PR" is a local cross-build of 886c89d. The rows for fd 3 and the rows for Node do not run there. A cell gives failed rows of the rows that ran.

/dev/fdfilemainPR
devfs (default)bun-write.test.js13 of 380 of 38
devfs (default)fs.test.ts11 of 130 of 13
fdescfs mountedbun-write.test.js14 of 380 of 38
fdescfs mountedfs.test.ts11 of 130 of 13

On FreeBSD main, the rows with a file source pass (FreeBSD has no fcopyfile), except the row with the slice. The string, bytes and fs.writeFile rows fail as on macOS.

An empty input. An empty string and empty bytes emptied the file on main through ftruncate(fd, 0). They keep it now, as fs.writeFileSync(path, "") does in Node. An empty Blob calls truncate(path, 0) on main. For these names it now opens with O_TRUNC and closes. With fdescfs mounted on FreeBSD, the truncate of main empties the file behind the descriptor (the difference between 13 and 14 above). On macOS 15.7 and 26.4 it does not: build 124816 ran that row with the code of main, and it passes. Bun.write(Bun.stdout, "") still empties the file on every platform (eebcd300678). #32890 has that question.

The kernel side. XNU fdesc_open returns ENODEV with the descriptor number, and open1 then calls dupfdopen and returns before its O_TRUNC step. FreeBSD kern_openat has the same path. The man page fd(4) documents it. Apple copyfile_data writes at the current offset and then calls ftruncate(dst_fd, totalCopied).

The pipe. This part is from the source of Apple's copyfile, not from a run. fstatfs fails on a pipe, the output block size becomes 0, and fcopyfile fails after it read one block of the source. Bun's fallback loop then reads from that position, so a source of up to one block gives nothing. The names of this PR no longer reach fcopyfile. Other spellings still do.

Why the open keeps O_TRUNC for these names. The name is a hint. If the name opens a file of its own (fdescfs mounted with nodup, or a /dev that is not devfs), the kernel empties it at the open, and the result is that of main. Seen on FreeBSD 14.3 with mount -t fdescfs -o nodup: S×100 under 1<> for main and for this PR.

Why Bun does not open with O_TRUNC as Node does. Then no name test is necessary, but each rewrite of a file is slower. A C loop that rewrites an existing file of the same size on ext4 (Linux 6.1, 2000 rewrites): 11 µs for 5 bytes with ftruncate after the write, 144 to 208 µs with O_TRUNC at the open. For 255 KiB: 38 to 42 µs and 1136 to 1252 µs. No number for APFS.

What is not in this PR.

  • A symlink to one of the names, //dev/stdout, /dev/./stdout and a relative dev/stdout. The file is still cut there (seen on macOS for the first two and on FreeBSD for all four), and on macOS a file copy through the symlink still gives a pipe nothing. Node keeps the file for these spellings. The set of exact names is the one that bash and gawk use. Three rows marked failing record these cases. There is no tracking issue.
  • fs.copyFile and fs.cp. Node (libuv) runs ftruncate(dstfd, 0) on the destination before the copy, so the result there is a decision for a maintainer.
  • fs.promises.writeFile(path, iterable). #39186 removes its trailing ftruncate.
  • The removal of fcopyfile from every macOS overwrite copy. It needs numbers from a Mac.
  • A path source that opens as a dup. It is read at the position of the descriptor, as Bun.file(fd) is.
  • EAGAIN. A dup shares O_NONBLOCK with the descriptor. A file copy through one of the names to a full non-blocking pipe rejects with EAGAIN after a partial write, as Bun.write(Bun.stdout, Bun.file(path)) does on main (#37128 makes that loop wait). The first bytes that go out two times after EAGAIN in the string and bytes paths are on main for each destination (#44389, #35953).

Other open PRs on these lines. Trial merges with git merge-tree at 886c89d: #32890, #32885, #42618 and #41209 conflict with this PR in source hunks (the resize statements, the opens, and the two copy_file.rs tests). #44425, #43388 and #41685 conflict only in bun-write.test.js. #38216 and #35953 conflict with main already. #32885 has a private copy of the same name test and can call bun_sys::path_opens_as_dup. Its rows for these names compare only the first 3 bytes on macOS and FreeBSD, so they also pass with the cut of main.

Measurements.

  • Syscalls of one small write on FreeBSD 14.3 (ktrace), from the open of the destination to its close. The file had 200 bytes.
callmainPR
Bun.write("/dev/stdout", "hello") >> logopenat, write, ftruncate(fd, 5), close: 5 bytesopenat(O_TRUNC), write, close: 205 bytes
fs.writeFileSync("/dev/stdout", "hello") >> logopenat, write, ftruncate(fd, 5), close: 5 bytesopenat(O_TRUNC), write, close: 205 bytes
Bun.write("/dev/stdout", "") >> log, and fs.writeFileSyncopenat, ftruncate(fd, 0), close: 0 bytesopenat(O_TRUNC), close: 200 bytes
Bun.write("/dev/stdout", new Blob([])) >> log, fdescfs mountedtruncate(path, 0): 0 bytesopenat(O_TRUNC), close: 200 bytes
Bun.write("out.txt", "hello"), fs.writeFileSync("out.txt", "hello")openat, write, ftruncate(fd, 5), closethe same
  • Instructions where the name test runs. An x86-64 Linux build with the test forced on, against the merge base, single-stepped under gdb from the entry of the function to its return, third call in a process:
destinationBun.write(path, "hello")Bun.write(path, bytes)fs.writeFileSync(path, "hello")
a path of 21 bytes in /tmp569 to 595383 to 418245 to 268
/dev/null582 to 609396 to 431258 to 281
out.txt573 to 592387 to 415249 to 265

Bun.write(Bun.stdout, "hello") stays at 455: a descriptor destination has no name test.

  • The name test alone, out of line, per call (qemu user mode): 8 to 20 instructions on arm64 and 11 to 20 on x86-64 for a path that is not one of the names.
  • Linux x64 release, merge base against 886c89d, both local builds: stripped size 89,318,984 bytes both. One function has another size (write_file_with_source_destination, 31 bytes less). Instruction counts for the third call in a process:
callmerge basethis PR
Bun.write(path, "hello")569569
Bun.write(path, bytes), 5 bytes383383
fs.writeFileSync(path, "hello")245245
Bun.write(Bun.stdout, "hello")455455
Bun.write(Bun.stdout, bytes)283283
Bun.write(path, new Blob(["hello"]))20482047
Bun.write(path, Bun.file(src))22252224
Bun.write(path, new Blob([]))20462047

The last three go through write_file_with_source_destination.

  • Sizes of the stripped binaries from CI (binary-size of build 124363 on the merge base and of build 124830 on 886c89d): darwin arm64 71,128,207 bytes both. darwin x64 78,447,960 and 78,447,968. FreeBSD x64 97,539,352 both. Linux x64 89,319,040 both.
  • FreeBSD x64 release, both local cross-builds: .text 61,001,120 and 61,003,040 bytes (+1,920). The empty Blob change has 1,239 of those, the copy gate 273, path_opens_as_dup 265, and the three write paths 143.

Self-review. Two reviews. The first was of a wider version (no fcopyfile, an O_NOFOLLOW probe for symlinks, gates in fs.copyFile and fs.cp, the fs.promises change). It raised 34 concerns and asked for this narrower shape. The second was of this shape: 10 concerns raised, 10 addressed. They gave the numbers above, the run on FreeBSD, the rows for Node, the comments at the O_TRUNC terms, and the text on the pipe and on the empty input. One of them also asked for a tracking issue for the spellings that are still cut: there is none.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/fs/fs.test.ts, test/js/bun/io/bun-write.test.js