blob(windows): free CopyFileWindows and ReadFileUV through the task pointer, not under &mut self

RO
3mo ago
+798-659

Problem

  • On Windows, the task behind a file-to-file Bun.write copy and the task behind reading a Bun.file() each free themselves from inside a &mut self method, while callers up the stack still hold a &mut to 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 as the strongly protected tag disallows deallocations, and the dereferenceable attribute on the argument lets the optimizer move a load of self past 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 self steps of both tasks no longer free anything. Each returns Pending or Done(result); the entry points that hold the raw task pointer run one step and hand the result to finish(*mut Self), which settles the promise or completion and drops the Box. Teardown (opened descriptors, the libuv request, the event-loop reference) moves into Drop.
  • This is correct because the only frame that frees the task holds it as a raw pointer, never as a reference: every &mut self has 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 main at exactly the three converted sites and passes here; a new copy-with-mode test covers the chmod chain, which nothing ran on Windows before; 13 blob and Bun.write suites 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 Box is leaked at creation, its raw pointer is stashed in the libuv request's data field, 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 self argument 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 what dereferenceable tells 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) and resolve_promise(&mut self) (src/runtime/webcore/blob/copy_file.rs) end in Self::destroy(ptr::from_mut(self)), an unconditional heap::take. They are reached from further &mut self frames: copyfile, prepare_read_write_loop, mkdirp, on_mkdirp_complete, on_complete, on_read_write_loop_complete, and the &mut the four libuv callbacks and init formed to call them.
  • ReadFileUV::on_finish(&mut self) (src/runtime/webcore/blob/read_file.rs) ends in Self::finalize(ptr::from_mut(self)), also a heap::take. Above it sit on_file_open(&mut self), queue_read(&mut self), the libuv callbacks, and on the open path FileOpener::get_fd(&mut self, callback) / get_fd_by_opening(&mut self, ..) in src/runtime/webcore/Blob.rs, whose own comment notes that callback may free self.

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 a destroy / deinit / finalize, directly or through a scopeguard, checks the patterns against positive and negative examples, and ratchets an allowlist. On main it reports exactly

    src/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 plus finalize in the name set (the ReadFileUV line is that shape; an in-place finalize(&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 the on_complete -> chmod request -> on_chmod_complete -> finish chain (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 with BUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1, so both the uv_fs_copyfile and the read/write-loop variants of the chain are covered. 0o444 is 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 --workspace for the host and for x86_64-pc-windows-msvc / aarch64-pc-windows-msvc; cargo clippy -p bun_runtime on the host and rustfmt --check on 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, with mode, with mode plus a missing directory, from a missing source with and without a missing destination directory, into a missing directory with createPath: 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 in uv_fs_copyfile mode, #33715, and UV_-prefixed codes in the read/write loop, #37465) are unchanged. The same suites and the same script were re-run after the switch to Drop (175 pass across the 13 files, script output again identical to the base build in both modes), plus a script with no top-level await that 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, beforeExit fires 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::drop collapsed to the Closer call) 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 to main as shown above.

Textual overlap only: #37072 touches the throw / resolve_promise lines that became finish, and #31844 changes the lifetimes on these same structs.