blob(windows): free CopyFileWindows and ReadFileUV through the task pointer, not under &mut self
Problem
- On Windows, the task behind a file-to-file
Bun.writecopy and the task behind reading aBun.file()each free themselves from inside a&mut selfmethod, while callers up the stack still hold a&mutto the same allocation. - Freeing an allocation while a reference to it is a live argument is undefined behaviour even if the reference is never touched again. Miri (
bun run rust:miri) reports it asthe strongly protected tag disallows deallocations, and thedereferenceableattribute on the argument lets the optimizer move a load ofselfpast the free. - No crash is known from these sites. Same family as #37672, #37685, #37693 and #37551; these are the two sites in the blob module.
Fix
- The
&mut selfsteps of both tasks no longer free anything. Each returnsPendingorDone(result); the entry points that hold the raw task pointer run one step and hand the result tofinish(*mut Self), which settles the promise or completion and drops theBox. Teardown (opened descriptors, the libuv request, the event-loop reference) moves intoDrop. - This is correct because the only frame that frees the task holds it as a raw pointer, never as a reference: every
&mut selfhas returned by then, and the back pointers stored in libuv requests are only used after that. - Also: the read task opens its file through libuv itself (the shared open helper becomes POSIX-only, body unchanged); the mkdirp hop in the copy and write tasks posts to the JS thread through a cloned handle, since the JS thread could free the task mid-post; the copy task holds one event-loop reference for its lifetime instead of a pair per hop.
- Verification: a new source lint fails on
mainat exactly the three converted sites and passes here; a new copy-with-modetest covers the chmod chain, which nothing ran on Windows before; 13 blob andBun.writesuites pass on Windows (175 tests), the touched tests and lints pass under ASAN on Linux, and a script exercising each converted path prints identical output on the base build and this one. No behaviour is intended to change.
Background
- The blob file tasks are hand-written state machines: a
Boxis leaked at creation, its raw pointer is stashed in the libuv request'sdatafield, and each libuv completion recovers the pointer and runs the next step. Whichever step ends the operation must free the task exactly once. - Windows uses these libuv-based tasks (
CopyFileWindows,ReadFileUV) instead of the blocking POSIX ones; only the Windows paths change here. - A
&mut selfargument is protected for the whole call: Rust promises the callee the allocation stays valid until it returns, so freeing it inside the callee is UB with no later access needed. Miri's Tree Borrows checks this, and it is whatdereferenceabletells LLVM. - The mkdirp hop creates the missing directory on a thread pool, then posts a task back to the JS thread; once posted, the JS thread may run and free the task before the posting call returns.
test/internal/source-lints/holds tests that grep the source for banned patterns behind a ratcheting allowlist. #37685 and #37693 add the same lint file at the same path, so whichever lands last merges the allowlists.
no test proof · iteration 0 · 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
Original description
Problem
Two of the Windows blob tasks free themselves from inside &mut self methods:
CopyFileWindows::throw(&mut self)andresolve_promise(&mut self)(src/runtime/webcore/blob/copy_file.rs) end inSelf::destroy(ptr::from_mut(self)), an unconditionalheap::take. They are reached from further&mut selfframes:copyfile,prepare_read_write_loop,mkdirp,on_mkdirp_complete,on_complete,on_read_write_loop_complete, and the&mutthe four libuv callbacks andinitformed to call them.ReadFileUV::on_finish(&mut self)(src/runtime/webcore/blob/read_file.rs) ends inSelf::finalize(ptr::from_mut(self)), also aheap::take. Above it siton_file_open(&mut self),queue_read(&mut self), the libuv callbacks, and on the open pathFileOpener::get_fd(&mut self, callback)/get_fd_by_opening(&mut self, ..)in src/runtime/webcore/Blob.rs, whose own comment notes thatcallbackmay freeself.
A reference argument has to stay valid until the call it was passed to returns. Freeing the allocation underneath one is undefined behaviour under both aliasing models whether or not the reference is touched again (Stacked Borrows: deallocating while item is strongly protected; Tree Borrows, which bun run rust:miri uses: the strongly protected tag disallows deallocations), and the protector is the model of the dereferenceable attribute rustc puts on reference arguments, so the optimizer is entitled to move a load of self past the free. No crash is known from these sites. Same family as #37672 (Blob::deinit), #37685 (bundler Worker), #37693 (node:fs tasks) and #37551 (lifecycle scripts); these are the two sites in the blob module.
Fix
Both state machines get the same shape: the &mut self steps never free, they return a #[must_use] Step, and the entry points that hold the heap pointer run one step through it and hand the result to finish(this: *mut Self). On Step::Done, finish turns the pointer back into the Box the constructor leaked and drops it; teardown is the types' Drop impls (ReadWriteLoop queues aio::Closer closes for the two descriptors it opened, which are always libuv-owned, CopyFileWindows / ReadFileUV clean up their libuv request and release the loop reference their constructor took, and the store references, the promise handle and, for a read that never finished, the self-cancelling completion go with the field drops). The destroy / finalize routines are gone; heap::take in the two finish functions is the only place either task's allocation is touched as a whole.
CopyFileWindows: copyfile, prepare_read_write_loop, mkdirp, on_mkdirp_complete, on_complete and on_read_write_loop_complete return Step::Pending (a libuv request or the mkdirp pool task is in flight) or Step::Done(Result<usize, bun_sys::Error>). throw and resolve_promise, which differed only in which way they settled the promise, become finish, which keeps their order (take the promise handle, build the error instance, enter the event loop scope, drop the task, then resolve or reject). The bodies of the four libuv callbacks become &mut self steps (on_copyfile_complete, on_chmod_complete, on_read_complete, on_write_complete) and the extern "C" thunks all go through one on_uv_complete that recovers the task pointer from req.data, runs the step and calls finish; init and the ManagedTask hop for mkdirp do the same. init still reads the promise value before running copyfile, so a synchronous failure still settles and frees before init returns. The per-hop ref_keep_alive / unref_keep_alive pairs (copyfile, mkdirp, chmod, the read/write loop) become one reference taken in init and released by Drop, which is what ReadFileUV already did: whenever the task exists across a return to the loop one of those hops is in flight, so the loop sees the same count at every point where it matters, and a synchronous failure now takes and releases the reference inside init where it previously touched neither.
ReadFileUV: on_file_open, queue_read, on_finish and the bodies of the stat / read callbacks return Step::Pending or Step::Done; finish delivers the result to the completion and drops the task (the old finalize, minus the manual request cleanup and unref, which Drop does). Opening moves into the task: get_fd(&mut self) -> Step uses the store's fd directly or queues uv_fs_open with the same mode and error mapping the trait used (the flags become libuv::O::RDONLY, which is what uv::O::from_bun_o made of the trait's RDONLY | NONBLOCK | CLOEXEC on Windows anyway, spelled the way WriteFileWindows::open spells its flags), and its completion continues in on_file_open. The trait's get_fd was the top of the freeing chain on Windows and ReadFileUV was its only Windows caller (WriteFileWindows already opens through libuv itself), so FileOpener loses its Windows-only hooks (loop_, req, set_open_callback, open_callback, the libuv half of get_fd_by_opening) together with the unreachable!() stubs for them on ReadFile and WriteFile; get_fd / get_fd_by_opening become #[cfg(not(windows))] and their POSIX body is unchanged (the diff of Blob.rs with whitespace ignored adds two cfg attributes and a doc comment). ReadFileUV also drops the open_callback field that only existed to carry the trait's callback across the hop.
The back pointers the steps store (io_request.data, req.data, the mkdirp completion_ctx) are still taken from the &mut self of the step that schedules the request. That stays sound: the reference's protector ends when the step returns, nothing touches the allocation through an older pointer afterwards, and the completion that later reclaims the box through that pointer does so with no reference argument live. What moved is the free: it now happens in a frame whose only handle on the task is the raw pointer, and it is a Box drop.
One more site in the same module, found in review: the pool half of the mkdirp hop (on_mkdirp_complete_concurrent, in both CopyFileWindows and its WriteFileWindows twin in write_file.rs) posted the JS-thread hop through the LoopHandle stored in the task, so post_task's &self pointed into the task while the JS thread was already free to pop the hop and free it. Both now write the error through the raw pointer and post through a clone of the handle, which is what post_job does for the same reason, so nothing pointing into the task is live across the post.
No behaviour is intended to change. Every settle/complete happens in the same order relative to the free as before (copy: queue the closes and free, then settle, with script running when the event-loop scope exits; read: deliver the result, then release the store, the request and the loop reference).
Tests
-
test/internal/source-lints/self-receiver-teardown.test.ts bans handing the receiver's address (
ptr::from_mut(self),self as *mut _,&raw mut *self,addr_of_mut!(*self),NonNull::from(self)) to adestroy/deinit/finalize, directly or through ascopeguard, checks the patterns against positive and negative examples, and ratchets an allowlist. Onmainit reports exactlysrc/runtime/webcore/blob/copy_file.rs:1652: Self::destroy(core::ptr::from_mut(self)) src/runtime/webcore/blob/copy_file.rs:1739: Self::destroy(core::ptr::from_mut(self)) src/runtime/webcore/blob/read_file.rs:1127: Self::finalize(core::ptr::from_mut(self))The file is the one #37685 and #37693 add at the same path, deliberately: whichever of the three lands last resolves the conflict by keeping one copy and deleting the entries for conversions that have landed, instead of landing a stale ratchet on
main. This copy is theirs plusfinalizein the name set (theReadFileUVline is that shape; an in-placefinalize(&mut self)is never called with the receiver's address, so it does not match), with the allowlist holding the sites owned elsewhere at their current counts: src/install/lifecycle_script_runner.rs (5, #37551), src/bundler/ThreadPool.rs (1, #37685), and src/runtime/node/node_fs.rs (3, #37693). The entry this copy carried for src/runtime/webcore/ArrayBufferSink.rs was dropped after #37716 landed (the rebase onto it is what the ratchet is for: the count went to 0 and the test failed until the entry was removed). -
test/js/bun/io/bun-write.test.js gets a file-to-file copy with
{ mode }into an existing and into a missing directory, asserting the mode and the contents. This is theon_complete-> chmod request ->on_chmod_complete->finishchain (and, for the second copy, the mkdirp hop before it), which no test ran on Windows before; this file already re-runs itself on Windows withBUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1, so both theuv_fs_copyfileand the read/write-loop variants of the chain are covered.0o444is the one mode every platform reports back exactly. -
test/js/bun/util/bun-file-fd-read.test.ts now runs on Windows too; on Windows those reads are
ReadFileUV::get_fd's fd branch, which had no coverage there.
Verification
cargo check --workspacefor the host and forx86_64-pc-windows-msvc/aarch64-pc-windows-msvc;cargo clippy -p bun_runtimeon the host andrustfmt --checkon the touched files are clean. (The Windows-only code in these files has pre-existing clippy findings that CI does not lint; this change does not add any.)- Windows (x64, debug build): test/js/bun/io/bun-write.test.js (41 pass, including the nested run without
uv_fs_copyfile), bun-write-leak, test/js/bun/util/bun-file{,-read,-windows,-exists}.test.*, bun-stdin-slice, bun-file-fd-read (3 pass), and test/js/web/fetch/blob{,-cow,-write,-file-name-ownership}.test.ts plus utf8-bom (114 pass); no failures. In addition a script exercising each converted path (read of a missing path, a directory, an empty file, a regular file, a slice, an fd, an empty fd,NUL, a closed fd; copies to a path, withmode, withmodeplus a missing directory, from a missing source with and without a missing destination directory, into a missing directory withcreatePath: false, onto a directory, from a directory, onto and from a directory fd, onto a file fd) prints identical results (resolved values, error codes, syscalls and paths) on the base build and on this build, in both copy modes. The two pre-existing oddities it shows (the copy resolving with 0 bytes inuv_fs_copyfilemode, #33715, andUV_-prefixed codes in the read/write loop, #37465) are unchanged. The same suites and the same script were re-run after the switch toDrop(175 pass across the 13 files, script output again identical to the base build in both modes), plus a script with no top-levelawaitthat starts a copy into a missing directory with{ mode }, a read of a missing file and a copy from a missing source: in both copy modes the process stays alive across the mkdirp pool hop (the only hop during which nothing but the task's own loop reference keeps the loop alive), all three settle,beforeExitfires and it exits 0, so the single reference is neither dropped early nor leaked. After the review fixes (cloned handle on the mkdirp hop,ReadWriteLoop::dropcollapsed to theClosercall) the suites were run once more (175 pass), the script is still identical to the base build in both modes, and the no-top-level-await script still shows both mkdirp hops completing and the process exiting 0 in both modes. The final head (72707d3) was checked the same way once more against a fresh build of the current base commit: the per-path script is identical in both modes and the 13 suites pass (175 pass). - Linux (debug, ASAN): bun-write.test.js, bun-file-fd-read.test.ts and
bun test test/internal/source-lints/(18 files) pass; the new lint fails with the two source files restored tomainas shown above.
Textual overlap only: #37072 touches the throw / resolve_promise lines that became finish, and #31844 changes the lifetimes on these same structs.