Bun.write(file, file): copy a pipe or block-device source with the read/write loop

RO
7d ago
+442-7

Fixes #14054

Problem

  • Bun.write(file, Bun.stdin) rejects when stdin is a pipe. Linux: Non-regular files aren't supported yet. macOS: ENOTSUP from fcopyfile.
  • Linux: the last branch of CopyFile::run_async refuses the pair (src/runtime/webcore/blob/copy_file.rs:838). macOS: fcopyfile copies 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 behind Bun.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 in Blob.rs needs the source type, which is cached only for Bun.stdin or 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.stdin shares 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 EAGAIN mid-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 with EBADF from read (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's copyfile.c, a symlink fd makes fcopyfile return 0 with nothing copied, so main resolves with an empty file. It now gets what read gives.
  • 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 fstat accepts 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 of run_async for Linux (its cfg switched) 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 when fstat reports the buffered bytes of a pipe or socket as FreeBSD does. The kernel was Linux.
  • Windows: CopyFileWindows is 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, and on_complete trims the file to the cached size, so exists() first leaves an empty file (#4930, #43910). loop_cap is 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 .size first (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 (ENOSPC from /dev/full, 2 forms).
  • Block-device source (1): a read-only loop device, copied to EOF. It fails before on Linux. It runs where losetup can 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/null source (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.js on the release builds: 104 pass, 3 skip on main and on this PR.
  • The tests are in a new file. bun-write.test.js times out on debug builds of main without this PR: Bun.file(0) survives GC failed 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_mode from 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_pool 4677 -> 4711 B, frame 0x108 -> 0x108. New on Linux: do_read_write_loop_capped, 217 B. loop_cap is 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). strace is 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.stat take 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 CopyFile cancellable, so worker.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, and process.stdin and the console iterator 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) or process.stdin.once("data")), then calls Bun.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_cap is the rule of read_write_fallback, the loop that main falls back to when sendfile refuses 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, as do_copy_file_range reads 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.
DestinationResolvedDestinationResolved
path, no stat12fd, no stat12
path, exists() on a missing file12fd, .size on an empty file12
path, .size on an empty file12fd, .size on a 5-byte file5
path, .size on a 5-byte file12fd, .size on a 20-byte file12
path, .size on a 20-byte file12fd, slice(0, 5)5
path, slice(0, 5)12fd, slice(0, 0)12
path, slice(0, 0)12fd, .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, .lastModified12
  • 5 of the 16 rows are wrong, for a socket source on main too: a path ignores its slice (3 rows), .size on a 5-byte file caps an fd at 5, and slice(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 .size on 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 that slice() set. #43910 keeps the size of an unsliced file blob unknown. By its description, a size that reaches CopyFile after #43910 is a slice. Then read_write_fallback and loop_cap can 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 EAGAIN from sendfile after 65536 bytes, and a 1 MiB pipe rejects with EAGAIN from splice after 65536 bytes.
  • This PR, new branch: an empty stdin pipe with O_NONBLOCK rejects with EAGAIN from read (5 of 5 runs). A 1 MiB pipe into a stdout socket that process.stdout made non-blocking rejected with EAGAIN from write after 757445 bytes in 1 of 2 runs. Main refuses both pairs before it reads the pipe.
  • #46858 makes read_write_loop_capped wait on EAGAIN (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). CopyFile never 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)

PRwith mainwith this branch
#4715706
#4454103
#4120902
#4663401
#46858, #4168500
  • #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 off fcopyfile.
  • 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

  • splice for 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 that Bun.write(dest, stream) takes. It needs the file type of the source on the JS thread. That type is cached for Bun.stdin and after a stat. It is not cached for Bun.file(0), /dev/stdin or a FIFO path, so those still need this change.
  • A park on the io loop, as ReadFile and WriteFile do. It is the design that ends the wait, but it changes every regular copy and rewrites most of POSIX CopyFile, which #47157 and #46858 also edit.
  • Adding ENOTSUP to the EBADF fallback on macOS. It calls an fcopyfile that always fails for a pipe, and its loop ignores the cap.
  • A slice flag from Blob.rs to CopyFile, 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 of fcopyfile. It falls back on EOPNOTSUPP only, and fcopyfile sets ENOTSUP. #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 reaches CopyFile and works.
  • node:fs: fs.copyFile from a FIFO throws ENOTSUP in Bun and resolves with a 0-byte file in Node 26.3. fs.cp throws ERR_FS_CP_FIFO_PIPE in both. These checks answer to Node and are not changed here.
  • The docs table in docs/runtime/file-io.mdx said sendfile for 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