Bun.write(file, file): cancel a copy that waits when its VM stops

RO
7d ago
+1285-217

Merge #47100 first. Without it, this PR makes a copy to or from a socket fail with EPERM where seccomp denies sendto or recvfrom. This PR and #41685 conflict in copy_file.rs.

Problem

  • worker.terminate() never settles while the worker has a Bun.write(file, file) whose other end is silent: a FIFO with no writer, an idle or full pipe or socket. Debug build: teardown has waited 10s for 1 ticket(s) still held off-thread.
  • CopyFile (src/runtime/webcore/blob/copy_file.rs) has no JobContext::cancel, unlike ReadFile and WriteFile. Its pool thread sleeps in open(2), splice, sendfile, read or write.

Fix

  • CopyFile gets a cancel: a flag and an eventfd. A cancelled copy fails with ECANCELED.
  • No call sleeps in a pipe or socket. Opens use O_NONBLOCK, pipes use splice(SPLICE_F_NONBLOCK), sockets and terminals use a MSG_DONTWAIT read/write loop.
  • Verified: 20 new tests in test/js/web/workers/worker-late-completion.test.ts and test/js/bun/io/bun-write.test.js. main fails 18.
  • Self-reviewed: 5 concerns raised, 5 addressed.

Background

  • A VM's teardown waits for its pool jobs and calls cancel on each job with CANCELLABLE = true (VmHandle::close_and_wait).
  • Considered ReadFile's park on the io thread. It cannot end a wait in open(2).

Downsides

  • Regular copy: +127 instructions, 0 system calls, 0 allocations, +8 job bytes.
  • On one core a pipe copy takes 1.04x to 1.11x the time, and a file into a socket 1.05x (64 MiB) to 1.56x (1 GiB).
  • Still waits: a reader-less FIFO destination or a full inherited pipe (macOS, FreeBSD), an inherited terminal that takes no output. Linux, FIFO destination: a late reader waits about 30 ms more. The copy can miss an O_NONBLOCK reader that leaves within 50 ms, where main settles.
Notes

Merge order

  • For a socket, the read/write loop calls bun_sys::send_non_block and bun_sys::recv_non_block (write_now, copy_with_waits). They are sendto and recvfrom with MSG_DONTWAIT. main uses sendfile, and read and write where sendfile is refused.
  • #47100 makes those two functions fall back to write and read when the kernel denies the socket call. The two PRs merge without a conflict.
  • Runs on Linux x64, kernel 6.1: 70,000 bytes, 10 runs per cell. The socket is the piped stdio of a child. A seccomp filter answers EPERM for the one call (the helper of test/regression/issue/47085.test.ts in #47100). main is the canary build. The other two are debug builds.
buildfile into a socket, sendto deniedsocket into a file, recvfrom denied
main 11c367163aall bytesall bytes
this PR (805b29d6de)EPERM from send, 0 bytesEPERM from recv, 0 bytes
this PR merged with #47100 (25092c34eb)all bytesall bytes
  • With nothing denied, all three builds copy all bytes. With 8 MiB and 5 runs per cell the table is the same.
  • With both PRs and sendto denied, terminate() still ends a copy into a socket that nobody drains (3 runs, kernel 6.1).
  • No test in this PR covers a denied sendto or recvfrom. The seccomp helper comes with #47100.
  • #41685 (an O_APPEND destination fd) conflicts with this PR in 3 places in copy_file.rs. The second of the two to land needs a rebase by hand. #44389 conflicts in test/js/bun/io/bun-write.test.js only. Checked with git merge-tree against their heads 190b97c98d and 5a558f35de.

How the cancel works

  • Each wait is one poll of the copy's fd and the eventfd.
  • Cancel has requested (a flag) and wake (an eventfd, or a pipe on macOS). The pool thread makes wake at the first wait. A copy that never waits makes none.
  • A regular file never waits for a peer. Its copy keeps copy_file_range, clonefile and fcopyfile.
  • JobContext::cancel runs on the JS thread. It sets the flag, then writes to wake. The pool thread stores wake, then reads the flag. So one of the two always sees the other.
  • The flag stays set. A copy that is not in a wait yet fails at its next wait.
  • bun_sys::wait_for_io(fd, wake, timeout) is poll on Linux and FreeBSD. It is select on macOS: poll and kqueue there report a FIFO for its bytes or room, and not for the close of its other end.

Which call copies (Linux)

sourcedestinationcall
regularregular, or not known yetcopy_file_range
anypipesplice(SPLICE_F_NONBLOCK)
socket or terminalnot a piperead/write loop
regular or devicesocket or terminalread/write loop
regular or deviceothersendfile
pipenot a pipenone: ENOTSUP, as before
  • The copy reads the kind of the source before it opens the destination. A refused copy (a directory, a pipe into a file) leaves no empty file.
  • splice keeps its flag only for a pipe. A TCP socket with nothing to read sleeps in it (measured on 6.1). So a socket or terminal source gets one poll before each splice.
  • A destination path opens with O_NONBLOCK. A FIFO with no reader fails that open with ENXIO, and no event reports a reader. The copy tries again after 1, 2, 4 ... 50 ms, until a reader opens or the cancel comes.
  • A reader that opens that FIFO with O_NONBLOCK and closes it between two tries is not seen. On main the reader's open ends the copy's open(2). The copy then fails with EPIPE, or it writes if the reader is still there.
  • Measured with 40 copies per stay, the reader at a random point between two tries. Not seen: 40 for a stay of 0 ms, 36 for 5 ms, 21 for 20 ms, 7 for 40 ms, 0 for 60 ms. The next reader ended each of those copies. main settled 80 of 80.
  • A pipe that Bun did not open has no O_NONBLOCK. In the read/write loop the copy waits for room and then writes at most PIPE_BUF bytes. This loop runs for a pipe only when splice is refused.
  • The early exit for a kernel without copy_file_range now applies to copy_file_range only. sendfile and splice report ENOSYS themselves, and the existing arm falls back.
  • The O_APPEND reset for sendfile into a terminal is gone, because a terminal no longer gets sendfile.
  • A regular file into a pipe uses splice, which puts references to page-cache pages into the pipe. sendfile into a pipe is the same call on current kernels (since 5.12). On older kernels sendfile copied the bytes. There a reader can now see bytes that the program changed in the file after the copy resolved, for data that is still in the pipe.
  • A write into a terminal that Bun did not open waits for poll and then writes up to 64 KiB with a write(2) that can sleep, as on main. A terminal does not report how much room it has.

macOS and FreeBSD

  • The source opens with O_NONBLOCK. A copy with an end that waits for a peer uses the read/write loop, which waits in select or poll with the wake fd. That is also true for a path destination: fcopyfile and the node:fs loop get only ends that never wait.
  • A pipe, socket or terminal source into a path therefore copies on macOS now. On main fcopyfile refuses that source with ENOTSUP (#14054).
  • The destination path opens as before, with an open(2) that waits for the reader of a FIFO. fcopyfile and the node:fs loop fail on a full FIFO that has O_NONBLOCK.
  • An fd destination that nothing has stated gets one fstat, so that a socket is written with send(MSG_DONTWAIT). Linux does not need it for a regular file: copy_file_range refuses the other kinds.
  • The macOS wake pipe goes through bun_sys::move_above_stdio, as the eventfd does. Drop closes a wake fd with close_allowing_standard_io, so it is also closed if it kept the number 0, 1 or 2.
  • Run on macOS in CI. aarch64: macOS 26 at 538cc5f23b and macOS 27 at 96e4ff936e. The 9 worker rows and the 4 bun-write.test.js tests that apply there pass on both. x64: macOS 14 at 538cc5f23b, where bun-write.test.js passes. The x64 lane that has the worker rows did not start. Not run on FreeBSD: cargo check passes for x86_64-unknown-freebsd.

Measurements (release builds without LTO, linux x64, main 11c367163a against this PR)

  • Instructions for one copy of a 4 KiB file, counted with gdb stepi: JS thread 1355 to 1375, pool thread 599 to 706.
  • Job bytes: +8 (same 640-byte class).
  • System calls for that copy: 2 openat, 1 fstat, 1 copy_file_range, 2 close on both builds. With BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1: +1 fstat.
  • Text size: 131,891,252 to 131,887,396 bytes (3,856 bytes smaller).
  • 64 MiB file into a pipe that a reader drains 64 KiB at a time: 1024 sendfile on main. 1843 to 1863 splice and 819 to 839 poll here. That is +2 calls per stall, and +1 eventfd2 and +1 close per copy that waits.
  • 64 MiB pipe into a pipe with stalls: 1025 splice on main, about 1960 splice and 935 poll here.
  • 64 MiB socket into a pipe: 1036 sendfile on main, 1036 poll and 1036 splice here.
  • 8 MiB file into a socket: 1 sendfile on main, 128 read and 128 to 130 sendto here.
  • A file into a socket takes more time because read and send replace sendfile.
  • Time of the copy alone on one core, medians of 20 interleaved runs, with the noise floor:
    • 1 GiB file to | wc -c: 121.8 to 127.6 ms (1.05x, 2 to 3%).
    • 1 GiB cat | bun | wc -c: 180.3 to 186.7 ms (1.04x, 2 to 8%).
    • 1 GiB file to | cat > /dev/null: 120.0 to 133.5 ms (1.11x, 0 to 3%).
    • 256 MiB socket into a pipe: 34.6 to 36.3 ms (1.05x, 1%).
    • File into a socket: 64 MiB 17.0 to 17.8 ms, 256 MiB 70.2 to 78.9 ms, 1 GiB 185.4 to 289.1 ms.
  • Without a CPU pin the machine's numbers vary by more than 2x between equal runs. 256 MiB file into a socket: 5307 ms on main, 1743 ms here.
  • A copy that waits on idle pipes for 10 s: 0 wakeups of the pool thread on both builds, +1 fd here.
  • A FIFO destination with no reader: 17 to 20 open, stat and poll per second. A reader that opens 400 ms later gets its first byte after 33.9 ms on average (main: 3.0 ms), 100 runs.
  • Tools: a ptrace syscall counter, gdb, size, nm. The machine has no strace, perf, valgrind, bloaty or hyperfine.

Tests

  • Worker rows: each row has one copy. The worker starts it and waits until the process has the descriptors of a copy that waits: the files it opens and the wake fd. Then the main thread calls terminate(). A terminate() that comes before a pool thread takes the copy skips the copy, so the rows wait for those descriptors. On main the wake fd never appears.
  • bun-write.test.js: a late writer on a FIFO source, a FIFO source that the process has as a blocking fd (two parts, and the copy ends only when the writer closes), a late reader on a FIFO destination, a socket source into a pipe, a full socket, a full pipe (blocking and O_NONBLOCK), ENXIO for a socket file, and two refused copies that create no destination. The late reader and the ENXIO tests also pass on main: they guard behaviour that the change must keep.
  • The FIFO tests give the child one end of each FIFO and close their own copy of that end. When the child is gone, the read of the test gets the end of the FIFO and its write gets EPIPE. With one O_RDWR fd for both ends, a test whose child ended early kept two threads of the test process in read(2) and write(2).
  • Release builds: all of bun-write.test.js passes here. On main that file fails only in 8 of its 10 new tests, and all 10 new worker rows fail.
  • Debug build: the 20 new tests pass on a quiet machine. On this 2-core machine some older tests of both files time out, for example Bun.file(0) survives GC, the 256 MiB fallback test, and rows of the late-completion block.
  • test/internal/source-lints passes (196 tests). cargo clippy -p bun_runtime reports nothing for the changed files.

Self-review
The first draft had five problems. All are fixed in this diff.

  • A socket source into a pipe went through 4 KiB writes and was 5.4x slower than main. It is on splice again, with one poll before each call.
  • A file into a socket kept sendfile behind a poll. One chunk could still sleep in a socket with little room. It now uses read and send(MSG_DONTWAIT).
  • Each stall of a pipe copy cost a third system call (a poll with a zero timeout, to find the end that waits). The loop now remembers that end.
  • The worker rows could pass on main: a terminate() that came before the pool thread took the copy skipped the copy. The rows now wait until the copies are on the pool.
  • One row copied a FIFO into a file, which Linux refuses at once. A test in bun-write.test.js covers that form now.

Related

  • #38312 is an earlier fix for the same wait. It dates from before #38299, which made the teardown wait for pool jobs by design. This PR replaces it.
  • #46858 makes the copy wait on EAGAIN from an fd that already has O_NONBLOCK. This PR covers that case for Bun.write(file, file) through the same waits.
  • #14054 reports ENOTSUP for a pipe source into a file on macOS. The read/write loop now takes that copy there. Linux still refuses a pipe source into a file, now before it creates the destination. #47667 is the PR for that. #36692, an earlier one, is closed.
  • Not covered: Bun.Archive write and extract, and a WriteFile that sleeps in write(2) after poll said "ready".
  • A copy that waits still holds one pool thread while its VM lives, as on main: the thread is in poll where main is in splice or read. With as many such copies as the pool has threads, the other pool jobs wait. Measured on main (bun 1.4.3-canary.1+367d939d9) with UV_THREADPOOL_SIZE=2 and two copies: an fs.promises.stat takes 1.3 to 1.7 s, against 3 ms with no copy. This PR does not change that.
  • #40221 rewrites copy_file.rs to remove unsafe. The two PRs change the same functions.

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/web/workers/worker-late-completion.test.ts, test/js/bun/io/bun-write.test.js