Bun.write(file, file): check the source before opening the destination
Status: draft, waits for #43388. #43388 changes the same block of CopyFile::run_async and covers the directory and closed-fd rows below. After #43388 merges, this PR shrinks to the FIFO source on Linux (the copy syscall choice before the destination open) and the Windows fallback order. Details: https://github.com/oven-sh/bun/pull/38186#issuecomment-5959244842
Problem
Bun.write(dest, Bun.file(dir))rejects withThat doesn't work on folders, butdestis 0 bytes afterwards. Same with a directory fd as the source, with aBun.file(fd)source whose fd is closed (EBADFfromfstat), with a FIFO source on Linux (Non-regular files aren't supported yet), and with aBun.file(dest)destination; a destination that did not exist is left behind as an empty file.- Cause:
CopyFile::do_open_fileinsrc/runtime/webcore/blob/copy_file.rsopened the source and right after it the destination withO_CREAT | O_WRONLY | O_TRUNC. The sourcefstat, the directory check and the Linux source-kind dispatch (where the FIFO rejection comes from) all ran later, inrun_async. fs.copyFileandcprefuse the same inputs without touching the destination, andBun.writewith a missing source already did, because the source open happened first.
Fix
run_asyncnow opens the source (if it is a path),fstats it, rejects a directory, picks the Linux copy syscall, and only then callsopen_destination(). The position of that call is the fix; the checks themselves are the existing ones.do_open_file<IOWhich>is split intoopen_source/open_destination. ItsBothmode encoded the old order; the dest-open failure path now closes a Bun-opened source through the existingdo_close().pick_copy_syscallis the dispatch block's three conditions, unchanged, returningNonefor the unsupported case so it can be evaluated before the destination exists; the dispatch block now matches on its result.- The macOS
clonefileattempt, size clamp, preallocate and copy code are unchanged apart from indentation. The deadif destination is Fd {}block and the redundantsystem_error.is_some()check in the rewritten region are removed. - Windows default path (
uv_fs_copyfile) never touches the destination for an unusable source (probed with a directory, a directory fd and a missing source), so it is unchanged. - Windows read/write fallback (
prepare_read_write_loop) had the same order: underBUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1a missing source truncated the destination. It now opens the source first, and closes it before a mkdirp retry becausecopyfile()re-runs from the top. It still cannot recognise a directory source at open time (GetFileTypereports directories as files); with a path source that fallback is only reachable under the test flag, so that is left alone. - Verification:
test/js/bun/io/bun-write.test.js,Bun.write(dest, Bun.file(unusable source)) leaves the destination alone: directory source for {path,Bun.file} destination x {path, fd} source, directory source with an absent destination, FIFO source (Linux only; macOS copies FIFOs through its read/write fallback), closed fd source (Bun.file(987)in a fresh process, POSIX), missing source. The seven directory/FIFO/closed-fd cases fail on the released build and pass with this change. The missing-source case passes on POSIX either way; it is there for the Windows fallback, where it failed before (the file re-runs itself with the flag on Windows).- Windows x64 debug build: the file passes in both modes.
cargo check -p bun_runtimeforaarch64-apple-darwinandx86_64-unknown-freebsd,cargo clippy -p bun_runtime,cargo fmt --check: clean.
- Related open PRs, not duplicates: #32859 (copying a file onto itself) moves the truncation to an explicit
ftruncateafter the sourcefstat, which would still create an absent destination and still truncate before the Linux FIFO rejection. #36692 would make Linux copy FIFO sources; if it lands, the FIFO case here becomes a successful copy and that test goes away. #31515 (source slice windows) contains the same Windowsprepare_read_write_loopreorder as part of its Windows plumbing but leaves the POSIX order alone; whichever lands second has a trivial conflict in that one Windows function.
Background
- A file-to-file
Bun.writeis handled byCopyFile(POSIX) andCopyFileWindows, not by the generic blob write path.CopyFile::run_asyncruns on the work pool and reports throughself.system_error: set it and return, andthen()rejects the promise with it. - Each side is either a path, which Bun opens and closes itself, or a caller-supplied fd (
Bun.stdout,Bun.file(fd)), which Bun never opens, truncates or closes. That is why both opens are conditional on*_fd == Fd::INVALIDand whydo_close()only closes what Bun opened. - On Linux the copy uses
copy_file_range(regular to regular),splice(pipe to pipe) orsendfile(regular file, char device or socket source), each with a read/write fallback; any other source kind is rejected. The choice depends only on the source'sst_modeand the destination store's cached mode, so it can be made before the destination is opened. destination_file_store.modeis 0 unless thatBunFilewas stat'ed earlier (.size,.lastModified, or an fd store likeBun.stdout);pick_copy_syscallkeeps the existing rule that 0 counts as a regular file.
Local note on an unrelated test in the same file
should work when copyFileRange is not available > on large files exceeds the 5 s default per-test timeout in my environment with both the released and the patched build (it moves 256 MB through the disk; CI runs the file with a much larger per-test timeout). #37792 is already about that test.
no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js