Bun.write(file, file): cancel a copy that waits when its VM stops
RO
7d ago+1285+1285+0123456789012345678901234567890123456789-217-217-012345678901234567890123456789
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 aBun.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 noJobContext::cancel, unlikeReadFileandWriteFile. Its pool thread sleeps inopen(2),splice,sendfile,readorwrite.
Fix
CopyFilegets acancel: a flag and an eventfd. A cancelled copy fails withECANCELED.- No call sleeps in a pipe or socket. Opens use
O_NONBLOCK, pipes usesplice(SPLICE_F_NONBLOCK), sockets and terminals use aMSG_DONTWAITread/write loop. - Verified: 20 new tests in
test/js/web/workers/worker-late-completion.test.tsandtest/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
cancelon each job withCANCELLABLE = true(VmHandle::close_and_wait). - Considered
ReadFile's park on the io thread. It cannot end a wait inopen(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_NONBLOCKreader that leaves within 50 ms, where main settles.
Notes
Merge order
- For a socket, the read/write loop calls
bun_sys::send_non_blockandbun_sys::recv_non_block(write_now,copy_with_waits). They aresendtoandrecvfromwithMSG_DONTWAIT. main usessendfile, andreadandwritewheresendfileis refused. - #47100 makes those two functions fall back to
writeandreadwhen 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
EPERMfor the one call (the helper oftest/regression/issue/47085.test.tsin #47100). main is the canary build. The other two are debug builds.
| build | file into a socket, sendto denied | socket into a file, recvfrom denied |
|---|---|---|
| main 11c367163a | all bytes | all bytes |
| this PR (805b29d6de) | EPERM from send, 0 bytes | EPERM from recv, 0 bytes |
| this PR merged with #47100 (25092c34eb) | all bytes | all 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
sendtodenied,terminate()still ends a copy into a socket that nobody drains (3 runs, kernel 6.1). - No test in this PR covers a denied
sendtoorrecvfrom. The seccomp helper comes with #47100. - #41685 (an
O_APPENDdestination fd) conflicts with this PR in 3 places incopy_file.rs. The second of the two to land needs a rebase by hand. #44389 conflicts intest/js/bun/io/bun-write.test.jsonly. Checked withgit merge-treeagainst their heads 190b97c98d and 5a558f35de.
How the cancel works
- Each wait is one
pollof the copy's fd and the eventfd. Cancelhasrequested(a flag) andwake(an eventfd, or a pipe on macOS). The pool thread makeswakeat the first wait. A copy that never waits makes none.- A regular file never waits for a peer. Its copy keeps
copy_file_range,clonefileandfcopyfile. JobContext::cancelruns on the JS thread. It sets the flag, then writes towake. The pool thread storeswake, 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)ispollon Linux and FreeBSD. It isselecton macOS:polland kqueue there report a FIFO for its bytes or room, and not for the close of its other end.
Which call copies (Linux)
| source | destination | call |
|---|---|---|
| regular | regular, or not known yet | copy_file_range |
| any | pipe | splice(SPLICE_F_NONBLOCK) |
| socket or terminal | not a pipe | read/write loop |
| regular or device | socket or terminal | read/write loop |
| regular or device | other | sendfile |
| pipe | not a pipe | none: 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.
splicekeeps 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 onepollbefore eachsplice.- A destination path opens with
O_NONBLOCK. A FIFO with no reader fails that open withENXIO, 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_NONBLOCKand closes it between two tries is not seen. On main the reader's open ends the copy'sopen(2). The copy then fails withEPIPE, 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 mostPIPE_BUFbytes. This loop runs for a pipe only whenspliceis refused. - The early exit for a kernel without
copy_file_rangenow applies tocopy_file_rangeonly.sendfileandsplicereportENOSYSthemselves, and the existing arm falls back. - The
O_APPENDreset forsendfileinto a terminal is gone, because a terminal no longer getssendfile. - A regular file into a pipe uses
splice, which puts references to page-cache pages into the pipe.sendfileinto a pipe is the same call on current kernels (since 5.12). On older kernelssendfilecopied 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
polland then writes up to 64 KiB with awrite(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 inselectorpollwith the wake fd. That is also true for a path destination:fcopyfileand 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
fcopyfilerefuses that source withENOTSUP(#14054). - The destination path opens as before, with an
open(2)that waits for the reader of a FIFO.fcopyfileand the node:fs loop fail on a full FIFO that hasO_NONBLOCK. - An fd destination that nothing has
stated gets onefstat, so that a socket is written withsend(MSG_DONTWAIT). Linux does not need it for a regular file:copy_file_rangerefuses the other kinds. - The macOS wake pipe goes through
bun_sys::move_above_stdio, as the eventfd does.Dropcloses a wake fd withclose_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.jstests that apply there pass on both. x64: macOS 14 at 538cc5f23b, wherebun-write.test.jspasses. The x64 lane that has the worker rows did not start. Not run on FreeBSD:cargo checkpasses forx86_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, 1fstat, 1copy_file_range, 2closeon both builds. WithBUN_CONFIG_DISABLE_COPY_FILE_RANGE=1: +1fstat. - 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
sendfileon main. 1843 to 1863spliceand 819 to 839pollhere. That is +2 calls per stall, and +1eventfd2and +1closeper copy that waits. - 64 MiB pipe into a pipe with stalls: 1025
spliceon main, about 1960spliceand 935pollhere. - 64 MiB socket into a pipe: 1036
sendfileon main, 1036polland 1036splicehere. - 8 MiB file into a socket: 1
sendfileon main, 128readand 128 to 130sendtohere. - A file into a socket takes more time because
readandsendreplacesendfile. - 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.
- 1 GiB file to
- 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,statandpollper 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 nostrace,perf,valgrind,bloatyorhyperfine.
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(). Aterminate()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 andO_NONBLOCK),ENXIOfor a socket file, and two refused copies that create no destination. The late reader and theENXIOtests 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 oneO_RDWRfd for both ends, a test whose child ended early kept two threads of the test process inread(2)andwrite(2). - Release builds: all of
bun-write.test.jspasses 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-lintspasses (196 tests).cargo clippy -p bun_runtimereports 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
spliceagain, with onepollbefore each call. - A file into a socket kept
sendfilebehind apoll. One chunk could still sleep in a socket with little room. It now usesreadandsend(MSG_DONTWAIT). - Each stall of a pipe copy cost a third system call (a
pollwith 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.jscovers 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
EAGAINfrom an fd that already hasO_NONBLOCK. This PR covers that case forBun.write(file, file)through the same waits. - #14054 reports
ENOTSUPfor 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.Archivewrite and extract, and aWriteFilethat sleeps inwrite(2)afterpollsaid "ready". - A copy that waits still holds one pool thread while its VM lives, as on main: the thread is in
pollwhere main is inspliceorread. 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) withUV_THREADPOOL_SIZE=2and two copies: anfs.promises.stattakes 1.3 to 1.7 s, against 3 ms with no copy. This PR does not change that. - #40221 rewrites
copy_file.rsto removeunsafe. 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