Bun.write(file, file): copy a pipe or block-device source with the read/write loop
Fixes #14054
Problem
Bun.write(file, Bun.stdin)rejects when stdin is a pipe. Linux:Non-regular files aren't supported yet. macOS:ENOTSUPfromfcopyfile.- Linux: the last branch of
CopyFile::run_asyncrefuses the pair (src/runtime/webcore/blob/copy_file.rs:838). macOS:fcopyfilecopies only a regular file (copy_file.rs:847).
Fix
- A pipe or block-device source takes the capped read/write loop. Linux still refuses an fd with no file type (an eventfd).
- The cap is that of a socket source on main (
read_write_fallback): none for a path, the blob's size for an fd. 16 of 16 cases match. - Verified:
test/js/bun/io/bun-write-pipe-source.test.ts(32 tests, main fails 26),rust:check-all, darwin CI (27 pass). - Self-reviewed twice: 22 concerns raised, 20 addressed. Not done: the #47157 rebase, Windows tests (no machine).
Background
CopyFile, the job behindBun.write(file, file), blocks one work-pool thread until the copy ends.- Considered
splice(#36692): it fixes 5 of 11 refused pairs. A stream route inBlob.rsneeds the source type, which is cached only forBun.stdinor after a stat.
Downsides
- A waiting pipe copy holds a pool thread until the writer closes:
worker.terminate()takes 3979 ms (main: 0.3 ms).Bun.write(dest, Bun.stdin.stream())holds none. - A reader that started on
Bun.stdinshares fd 0 with the copy: the file has holes (13184 of 25600 bytes). Main has this race for a socket source. - A non-blocking end can reject with
EAGAINmid-copy (#46858). A source slice (#41209), a path-destination slice and an empty fd slice have no effect. A regular copy runs 580 pool-thread instructions (main: 579), and text grows 256 B.
Notes
Repro (Linux x64, bun 1.4.3-canary.1+367d939d9 and main 3f3a9ff87c)
echo "hello world" | bun -e 'await Bun.write(Bun.file("foo.txt"), Bun.stdin)'
# error: Non-regular files aren't supported yet, syscall: "fstat", errno: 95. foo.txt has 0 bytes.
macOS did not run locally. The original report is the macOS half, and the darwin lanes prove it. On the final head (f848bc2138) bun-write-pipe-source.test.ts gave 27 pass, 5 skip, 0 fail on darwin aarch64 and on darwin x64. The skipped tests are Linux-only.
Which pairs change
- Linux, by source type and cached destination type: a FIFO into a regular file, an unknown destination, a character device, a socket or a block device (5 pairs), and a block device into each of the 6 destination types.
- Linux: a source with no file type (eventfd, timerfd, epoll) is still refused. Without that check,
Bun.write(path, Bun.file(eventfd))never settles and the process does not exit. Main rejects it in 7 ms. A symlink fd (O_PATH) now rejects withEBADFfromread(main: errno 95). - macOS: every source that is not a regular file, into a path. On main a pipe, a socket or a device fails with
ENOTSUP. By reading Apple'scopyfile.c, a symlink fd makesfcopyfilereturn 0 with nothing copied, so main resolves with an empty file. It now gets whatreadgives. - macOS and FreeBSD, an fd destination whose blob size is 0: the loop now copies to EOF, as the Linux branch does on main. Main copies nothing there. A regular source with a size returns before the loop, and an empty one reads EOF at once. A file that reports a size of 0 and has content (procfs) is now copied.
- macOS and FreeBSD have no refusal: every source that
fstataccepts goes to the loop, as on main for an fd destination. - FreeBSD: #14054 does not reproduce there. Main copies a pipe into a path to EOF with the node:fs loop, then trims by
st_size, which is the count of buffered bytes for a pipe. So a stat first cuts the copy. With this PR the capped loop does that copy, with no trim.
FreeBSD and Windows
- FreeBSD: two lanes build it (
.buildkite/ci.ts:282) and none runs tests. I compiled the FreeBSD branch ofrun_asyncfor Linux (itscfgswitched) and ran the test file: 31 of 32 pass, and the one failure is the Linux-only refusal test. The same build of main fails 3, and 8 whenfstatreports the buffered bytes of a pipe or socket as FreeBSD does. The kernel was Linux. - Windows:
CopyFileWindowsis a separate loop, and this PR does not change it. Nothing here ran on Windows. By reading, it sends a pipe on fd 0 to its own read/write loop, andon_completetrims the file to the cached size, soexists()first leaves an empty file (#4930, #43910).loop_capis not ported there: #43910 owns that fix.
Tests (32. The loop-device test needs root, so the CI lanes run 31.)
- Pipe source, pass after and fail before on Linux (25):
Bun.stdin,Bun.file(0),/dev/stdin,.write(),new Response(Bun.stdin),[Bun.stdin], the.blob()workaround from the issue, a FIFO path, 1 MiB, an empty pipe,exists()or.sizefirst (4 rows), a slice of a destination fd, a destination fd whose cached size is 0,{ mode }, destinations>,>>,/dev/null, a socket and a terminal, a read error (EBADF), and a write error (ENOSPCfrom/dev/full, 2 forms). - Block-device source (1): a read-only loop device, copied to EOF. It fails before on Linux. It runs where
losetupcan attach a device, which is not the case on the CI lanes. - Socket source (4): into a path,
exists()first, an fd whose cached size is 0, a slice of an fd. Linux passes them before and after: they pin the cap that the new branch takes. By reading, macOS fails the first three before. - Pass before and after on Linux (2 more): a
/dev/nullsource (it fails before on macOS only), and the refusal of Bun's own epoll fd. - Not tested: the other 5 block-device pairs and a symlink fd.
- CI on f848bc2138: 27 pass, 5 skip on darwin aarch64 and darwin x64. 31 pass, 1 skip (the loop device) on 7 Linux lanes: debian x64, aarch64 and ASAN, ubuntu x64 and aarch64, alpine x64 and aarch64. The Windows lanes skip the file.
- Also ran on a debug build, all pass:
test/regression/issue/25903.test.ts,blob-write.test.ts,bun-stdin-slice.test.ts,bun-file.test.ts,worker-terminate-lifetime.test.ts.bun-write.test.json the release builds: 104 pass, 3 skip on main and on this PR. - The tests are in a new file.
bun-write.test.jstimes out on debug builds of main without this PR:Bun.file(0) survives GCfailed 4 of 4 whole-file runs (5.1 to 5.3 s against the 5 s limit). 23 more spawned processes in that file made a second test time out.
Measurements (release builds of main 3f3a9ff87c and of this PR, Linux x64, kernel 6.1)
- regular 4 KiB copy, pool thread: 579 -> 580 instructions (gdb stepi, third call, 5 of 5 runs each). Main masks
st_modefrom the stack in 2 instructions. The PR loads it into a register first, for the file-type test of the new branch, in 3 - regular copy: 7 syscalls and 1 allocation (32 B) per call on the pool thread on main, 7 and 1 on the PR. JS thread: 598 instructions, 1 futex and 1 allocation (560 B) on both (gdb, counted per instruction)
- release binary: 140288017 -> 140288273 B text (size).
Job<CopyFile>::run_on_pool4677 -> 4711 B, frame 0x108 -> 0x108. New on Linux:do_read_write_loop_capped, 217 B.loop_capis inlined (nm, objdump) - 1 MiB pipe -> file: 17 read, 16 write and 1 wake of the JS thread, 0 splice, 0 ftruncate on one pool thread (path / > / >>: 37 / 35 / 35 total).
straceis not installed: counted with gdb - silent writer: 1 pool thread parked in read(2), main thread in epoll_pwait2 (/proc//task/*/syscall)
- worker.terminate() with a pending pipe copy, 4 s writer: median 3979 ms (range 3967-3985) on the PR vs 0.3 ms (0.3-0.3) on main. Control 0.3 ms (10 interleaved runs). With
Bun.write(dest, Bun.stdin.stream())on the PR: 10.2 ms (7.6-14.6, 5 runs) - fs.promises.stat behind 2 parked pipe copies on a 2-thread pool (
UV_THREADPOOL_SIZE=2): median 1998 ms (range 1987-2001) vs 2.5 ms (2.4-11.3) without (10 interleaved runs) - O_NONBLOCK empty stdin pipe: 5 of 5 runs reject EAGAIN from read. Blocking pipe: 0 of 5
- slice of 5 of a destination fd, 12-byte pipe: resolved 5, 7 bytes left in the pipe (bun-write-pipe-source.test.ts)
- macOS path-destination copy: instructions not measured. The darwin lanes passed the tests of the final head
The pool thread
- The wait is not new: pipe to pipe parks a pool thread on main. This PR makes it reachable from 5 more destination types. If the writer never closes, the wait has no end.
- Two waiting copies stall a 2-thread pool (the stat line above). The pool has one thread per CPU, and 2 at the least.
- Main has the same stall for the pairs it copies. Two pipe-to-pipe copies with silent writers make
fs.promises.stattake 1973 to 1986 ms on a 2-thread pool (3 of 3 runs), against 2.4 to 2.9 ms without them. Bun.write(dest, Bun.stdin.stream())copies a pipe on main since 1.4.1 and holds no pool thread. It is not the same call: it ignores a slice of a destination fd.- #47157 makes
CopyFilecancellable, soworker.terminate()does not wait. As written it still refuses a pipe into a file on Linux, so it does not cover this branch until it is rebased. - No open PR frees the pool thread of a waiting copy on POSIX.
A reader that started on Bun.stdin
Bun.stdin.stream()is cached, andprocess.stdinand theconsoleiterator read through it. Once that reader started, it polls fd 0 and reads ahead. The copy reads the same fd on a pool thread, and each chunk goes to the one that wakes first.- Probe: 400 lines of 64 bytes, 2 ms apart. The script reads once (
getReader(),for await (const line of console)orprocess.stdin.once("data")), then callsBun.write(file, Bun.stdin). Main rejects all three. This PR resolves 13184, 13696 and 13184 of 25600 bytes, with 83, 74 and 69 holes. - Main has the same race for the pairs it copies, 3 of 3 runs each: a socket source into a path resolves 15232 to 19328 bytes with 52 to 145 holes, and a pipe into a stdout pipe resolves 12096 to 18176 bytes with 64 to 74 holes.
- This PR keeps no refusal for that case. The check needs the cached stream of the source on the JS thread, for every source, and it changes the pairs that main copies too. That work has its own change. #35833 was the same class for
Bun.stdin.text().
The cap
loop_capis the rule ofread_write_fallback, the loop that main falls back to whensendfilerefuses a socket source. A path that Bun opened and truncated takes all of the source. An fd takes the size of its blob, and 0 is unknown, asdo_copy_file_rangereads it.- Checked on the release builds with 12 bytes, a socket source on main against a pipe source on this PR. The 16 rows give the same result and the same file. Main rejects all 16 for a pipe source.
| Destination | Resolved | Destination | Resolved |
|---|---|---|---|
| path, no stat | 12 | fd, no stat | 12 |
path, exists() on a missing file | 12 | fd, .size on an empty file | 12 |
path, .size on an empty file | 12 | fd, .size on a 5-byte file | 5 |
path, .size on a 5-byte file | 12 | fd, .size on a 20-byte file | 12 |
path, .size on a 20-byte file | 12 | fd, slice(0, 5) | 5 |
path, slice(0, 5) | 12 | fd, slice(0, 0) | 12 |
path, slice(0, 0) | 12 | fd, .size on a 5-byte file, then slice(0, 5) | 5 |
path, .size on a 5-byte file, then slice(0, 5) | 12 | ||
path, .size on 5 bytes, grow to 20, .lastModified | 12 |
- 5 of the 16 rows are wrong, for a socket source on main too: a path ignores its slice (3 rows),
.sizeon a 5-byte file caps an fd at 5, andslice(0, 0)of an fd copies to EOF. - The first push took the blob's size as the cap for a path too, so
exists()or.sizeon a 5-byte file cut the copy to 5 of 12 bytes. The second push guessed which sizes a stat had cached. A stale size and a slice as long as the cached size defeated that guess. This push has no guess. - The cause is in
Blob.rs: the blob keeps one size for a slice and for a cached stat. #41209 adds a flag for a size thatslice()set. #43910 keeps the size of an unsliced file blob unknown. By its description, a size that reachesCopyFileafter #43910 is a slice. Thenread_write_fallbackandloop_capcan take it for a path too. - Not changed: a regular-file source keeps the cached size as its budget (#4930), 5 of 12 bytes on main and on this PR.
Non-blocking ends
- Main, existing branches: with a non-blocking stdout pipe, a 1 MiB file rejects with
EAGAINfromsendfileafter 65536 bytes, and a 1 MiB pipe rejects withEAGAINfromspliceafter 65536 bytes. - This PR, new branch: an empty stdin pipe with
O_NONBLOCKrejects withEAGAINfromread(5 of 5 runs). A 1 MiB pipe into a stdout socket thatprocess.stdoutmade non-blocking rejected withEAGAINfromwriteafter 757445 bytes in 1 of 2 runs. Main refuses both pairs before it reads the pipe. - #46858 makes
read_write_loop_cappedwait onEAGAIN(read_retrying,write_retrying), so it covers the new branch with no more lines. The two PRs do not conflict, in either order.
Source slices
Bun.write(dest, Bun.stdin.slice(0, 5))copies the whole pipe (12 of 12 bytes).CopyFilenever gets the window of the source blob. A regular-file source is the same on main (12 of 12 bytes). #41209 and #46634 are the open PRs for that.
Order with other open PRs (conflict regions in copy_file.rs, git merge-file of each PR head with main and with this branch)
| PR | with main | with this branch |
|---|---|---|
| #47157 | 0 | 6 |
| #44541 | 0 | 3 |
| #41209 | 0 | 2 |
| #46634 | 0 | 1 |
| #46858, #41685 | 0 | 0 |
- #47157 rewrites the Linux dispatch into
plan(), which returns no plan for a pipe into a file, and its test expects the refusal text. After this PR that row is the read/write plan and that test changes. Its macOS check must keep every non-regular source offfcopyfile. - Draft #38186 expects the refusal text too. It conflicts with main already.
- #43910 does not touch
copy_file.rs. - #47157, #46858 and #46022 name #36692 as the fix for this pair. This PR replaces #36692.
Also considered
splicefor a pipe into a file. It leaves block-device sources refused. No CPU win in a C probe, 1 GiB from a pipe, 6 interleaved rounds: tmpfs 2316 ms (range 1982-2448) with splice against 2365 ms (655-2659) with read/write, ext4 2664 ms (2521-2802) against 2324 ms (1580-2453). It halves the system calls (16385 against 32769).- A stream route in
Blob.rs, which sends a pipe source to the path thatBun.write(dest, stream)takes. It needs the file type of the source on the JS thread. That type is cached forBun.stdinand after a stat. It is not cached forBun.file(0),/dev/stdinor a FIFO path, so those still need this change. - A park on the io loop, as
ReadFileandWriteFiledo. It is the design that ends the wait, but it changes every regular copy and rewrites most of POSIXCopyFile, which #47157 and #46858 also edit. - Adding
ENOTSUPto theEBADFfallback on macOS. It calls anfcopyfilethat always fails for a pipe, and its loop ignores the cap. - A slice flag from
Blob.rstoCopyFile, for an exact cap. #41209 has that flag, and #43910 removes the need for it. A third variant in this PR conflicts with both.
Left out
- The open of the destination before the source check (#38186).
bun_sys::copy_file_with_state(src/sys/copy_file.rs:112), the other caller offcopyfile. It falls back onEOPNOTSUPPonly, andfcopyfilesetsENOTSUP. #46022 is the open PR for it.- An array of several parts.
Bun.write(dest, ["head\n", Bun.stdin])resolves 5 and drops the pipe, on main and on this PR (#25851, open PR #33600). A one-element array reachesCopyFileand works. node:fs:fs.copyFilefrom a FIFO throwsENOTSUPin Bun and resolves with a 0-byte file in Node 26.3.fs.cpthrowsERR_FS_CP_FIFO_PIPEin both. These checks answer to Node and are not changed here.- The docs table in
docs/runtime/file-io.mdxsaidsendfilefor a pipe into a file and for a pipe into a socket. The first was this refusal. For the second,can_sendfile(src/runtime/server/FileResponseStream.rs:658) takes a regular file only.
no test proof · iteration 10 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/io/bun-write-pipe-source.test.ts