Bun.write, fs.writeFile: do not resize the file behind /dev/stdout or /dev/fd/N (macOS, FreeBSD)
Problem
- On macOS,
bun -e 'fs.writeFileSync("/dev/stdout", "new\n")' >> app.logcutsapp.logto its first 4 bytes.Bun.write("/dev/stdout", ...)does the same. Node keeps the file. - macOS and FreeBSD open
/dev/stdin,/dev/stdout,/dev/stderrand/dev/fd/Nas 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) orfcopyfile(src/runtime/webcore/blob/copy_file.rs:869). - To a pipe, the file copy resolves
0and writes nothing.
Fix
bun_sys::path_opens_as_dupnames 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, nofcopyfile, no trim.Bun.write(path, string or bytes)andfs.writeFileskip thatftruncate. The open keepsO_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 samefs.writeFilerows.
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_TRUNCand cuts the old tail after it. - Considered:
O_TRUNCat each open as in Node (over 10 times slower on ext4). Also anlseekbefore each resize.
Downsides
- Through these names,
1<> filekeeps 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.
| descriptor | expected | main |
|---|---|---|
>>, 2>>, 3>>, 100 bytes | A×200 S×100 | A×100 |
> after 200 bytes of output | H×200 S×100 | H×100 |
1<>, 0<>, 3<>, 100 bytes | S×100 A×100 | S×100 |
>>, file source of 300000 bytes | A×200 S×300000 | A×200 S×299800 |
>>, empty string, empty bytes, empty file | A×200 | empty file |
>>, Bun.file("/dev/stdout").slice(0, 50) | A×200 S×50, resolves 50 | A×50 |
| pipe, file source | the bytes, resolves the count | nothing, 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.
| build | source | macOS 14.8.9 x64 | macOS 15.7 | macOS 26 arm64 |
|---|---|---|---|---|
| 123299 | main, the first 30 rows | 21 | no run | 21 |
| 124518 | main, all rows | no run | 31 + 15 (arm64), 31 + 15 (x64) | 31 + 15 |
| 124568 | 8b2ff19 | 0 | 0 (arm64) | 0 |
| 124816 | 45064ac, with the rows for Node | no run | 0 (arm64), 0 (x64) | 0 |
| 124830 | 886c89d | no run | 0 (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/fd | file | main | PR |
|---|---|---|---|
| devfs (default) | bun-write.test.js | 13 of 38 | 0 of 38 |
| devfs (default) | fs.test.ts | 11 of 13 | 0 of 13 |
| fdescfs mounted | bun-write.test.js | 14 of 38 | 0 of 38 |
| fdescfs mounted | fs.test.ts | 11 of 13 | 0 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/./stdoutand a relativedev/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 markedfailingrecord these cases. There is no tracking issue. fs.copyFileandfs.cp. Node (libuv) runsftruncate(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 trailingftruncate.- The removal of
fcopyfilefrom 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 sharesO_NONBLOCKwith the descriptor. A file copy through one of the names to a full non-blocking pipe rejects withEAGAINafter a partial write, asBun.write(Bun.stdout, Bun.file(path))does on main (#37128 makes that loop wait). The first bytes that go out two times afterEAGAINin 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.
| call | main | PR |
|---|---|---|
Bun.write("/dev/stdout", "hello") >> log | openat, write, ftruncate(fd, 5), close: 5 bytes | openat(O_TRUNC), write, close: 205 bytes |
fs.writeFileSync("/dev/stdout", "hello") >> log | openat, write, ftruncate(fd, 5), close: 5 bytes | openat(O_TRUNC), write, close: 205 bytes |
Bun.write("/dev/stdout", "") >> log, and fs.writeFileSync | openat, ftruncate(fd, 0), close: 0 bytes | openat(O_TRUNC), close: 200 bytes |
Bun.write("/dev/stdout", new Blob([])) >> log, fdescfs mounted | truncate(path, 0): 0 bytes | openat(O_TRUNC), close: 200 bytes |
Bun.write("out.txt", "hello"), fs.writeFileSync("out.txt", "hello") | openat, write, ftruncate(fd, 5), close | the 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:
| destination | Bun.write(path, "hello") | Bun.write(path, bytes) | fs.writeFileSync(path, "hello") |
|---|---|---|---|
a path of 21 bytes in /tmp | 569 to 595 | 383 to 418 | 245 to 268 |
/dev/null | 582 to 609 | 396 to 431 | 258 to 281 |
out.txt | 573 to 592 | 387 to 415 | 249 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:
| call | merge base | this PR |
|---|---|---|
Bun.write(path, "hello") | 569 | 569 |
Bun.write(path, bytes), 5 bytes | 383 | 383 |
fs.writeFileSync(path, "hello") | 245 | 245 |
Bun.write(Bun.stdout, "hello") | 455 | 455 |
Bun.write(Bun.stdout, bytes) | 283 | 283 |
Bun.write(path, new Blob(["hello"])) | 2048 | 2047 |
Bun.write(path, Bun.file(src)) | 2225 | 2224 |
Bun.write(path, new Blob([])) | 2046 | 2047 |
The last three go through write_file_with_source_destination.
- Sizes of the stripped binaries from CI (
binary-sizeof 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:
.text61,001,120 and 61,003,040 bytes (+1,920). The emptyBlobchange has 1,239 of those, the copy gate 273,path_opens_as_dup265, 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