blob(windows): free CopyFileWindows and ReadFileUV through the task pointer, not under &mut self - #37705
blob(windows): free CopyFileWindows and ReadFileUV through the task pointer, not under &mut self#37705robobun wants to merge 8 commits into
Conversation
WalkthroughSummaryWindows Blob reads and copies now use ChangesWindows Blob I/O
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 10:35 AM PT - Aug 12th, 2026
❌ @robobun, your commit 72707d3 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 37705That installs a local version of the PR into your bun-37705 --bun |
|
Status: fixed; teardown reworked as Reproduced how: the new lint in test/internal/source-lints/self-receiver-teardown.test.ts reports exactly the three sites on main (copy_file.rs:1652 and :1739 CI for the final head (build 93131): 180 of 181 jobs passed, every Windows lane included. The one failed job is the x64-asan test lane, whose only red test is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts, a worker-termination exception-check assertion that this diff does not touch (the Linux build of this branch differs from main only by whitespace and cfg attributes in Blob.rs); it is intermittent on that lane across builds of this PR (green on build 92948 with the same source files) and has been reported for main. Everything else in the build is retry-passed flakes. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it's a ~600-line refactor of unsafe raw-pointer / libuv lifecycle code in the Windows blob tasks, and it deliberately conflicts with the sibling conversions (#37685, #37693) on the shared source-lint file, a human look would still be worthwhile.
What was reviewed:
- Traced every
Step-returning path in both state machines to confirm each terminal branch reachesfinishexactly once and eachPendinghas a completion that runs the next step. - Checked
ReadFileUV::get_fdpreserves the old trait's flags (RDONLY | NONBLOCK | CLOEXEC,DEFAULT_PERMISSION), fd-branch handling, and error mapping;do_closeforReadFileUValways returnsfalseon Windows soon_finishnever returns a danglingPending. - Confirmed
CopyFileWindows::finishkeeps the oldthrow/resolve_promiseorder (swap promise → build value → enter scope →destroy→ settle) and thatpromise.swap()yields a GC-cell reference that survivesdestroy(this).
Extended reasoning...
Overview
This PR restructures the two Windows-only blob async state machines — CopyFileWindows (in copy_file.rs) and ReadFileUV (in read_file.rs) — so that no &mut self method frees its own receiver. Each step now returns a #[must_use] Step enum, and the entry points that hold the raw heap pointer (libuv completions, init/start_with_ctx, the mkdirp ManagedTask hop) run one step and hand the result to finish(*mut Self), the sole free site. The FileOpener trait loses its Windows-only hooks (loop_, req, set_open_callback, open_callback) since ReadFileUV was their only user and now opens through libuv itself; get_fd/get_fd_by_opening become POSIX-only. write_file.rs just drops the unreachable!() stubs. It also adds a source-lint test that ratchets the remaining sites owned by sibling PRs, a new { mode } copy test, and un-skips the fd-read test on Windows.
Security risks
None. This is an internal memory-model soundness refactor of file-copy/read state machines. No user input parsing, auth, crypto, or trust boundaries are touched.
Level of scrutiny
High. This is exactly the category REVIEW.md flags as most-blocked: raw-pointer lifetime, heap ownership, libuv FFI, and &mut self aliasing on Windows-only paths that most reviewers can't run locally. The change is careful and the PR description's differential-output verification against a base build is convincing, but the diff is large enough (four libuv callback bodies moved into &mut self steps, two throw/resolve_promise collapsed into one finish, the open path moved out of the trait) that a maintainer familiar with the sibling conversions should confirm the shape matches what landed in #37672/#37693.
Other factors
The new source-lint file is intentionally the same path as #37685/#37693 add, with an allowlist covering the other PRs' sites at their current counts — whichever lands last must reconcile. The PR also notes textual overlap with #37072 and #31844 on the same structs. These coordination points argue for a human in the loop. The bug-hunting system found nothing; my own trace of every Step return, the open-flag equivalence, the do_close return on Windows, and the finish ordering vs. the old throw/resolve_promise all check out.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #37685, #37693 or #37672: each of those converts a different site (the bundler |
|
Heads-up on the allowlist: #37716 converts the JsSinkType::finalize hop (and the generated thunk above it) to *mut, removing the src/runtime/webcore/ArrayBufferSink.rs line this lint carries at 1. Whichever side lands second should drop that entry; the same applies to the copies of this lint in #37685 and #37693. |
…37705 Same lint body as the other two PRs converting sites of this shape (adds the scopeguard deferred form and finalize as a callee), so each PR's copy differs only in which allowlist entry it deletes. This copy drops the ThreadPool.rs entry and keeps node_fs.rs, copy_file.rs and read_file.rs at their current counts.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can we use a RAII type?
|
Done in ebd6f3c (comments shortened in 6d47e67): teardown is now
What stays raw is the identity pointer libuv holds between hops ( Re-verified on Windows after the change: the same suites (175 pass across 13 files, bun-write in both copy modes), the per-path script is still byte-identical to the base build in both modes, and a no-top-level-await script shows the process staying alive across the mkdirp pool hop and exiting once the promises settle (so the single reference is neither dropped early nor leaked). Description updated. |
### Problem
The JSSink finalize chain frees the sink while reference arguments to it
are still live. At `3fc747a7da`:
* generated thunk `extern "C" fn ${name}__finalize(this: &mut ${name})`
(src/codegen/generate-jssink.ts), called from `~JS${name}`,
`~JSReadable${name}Controller` and `${name}__doClose`
* `JSSink::js_finalize(this: &mut T)` (src/runtime/webcore/Sink.rs)
* `JsSinkType::finalize(&mut self)` (src/runtime/webcore/Sink.rs), whose
impls do the actual release:
* `ArrayBufferSink` (src/runtime/webcore/ArrayBufferSink.rs):
`Self::finalize(ptr::from_mut(self))` -> `destroy` -> `heap::take`,
unconditionally. The comment on the impl said the C export owned the
free; this call is the free.
* `FileSink` (src/runtime/webcore/FileSink.rs): the inherent
`finalize(&mut self)` ends in `FileSink::deref(ptr::from_mut(self))`,
which runs `deinit` -> `heap::take` whenever the wrapper's +1 was the
last ref, i.e. on an ordinary GC sweep of a sink nothing else holds. The
header comment argued this was fine because the `&mut` carries write
provenance, which is true but is not the problem.
* `FetchRequestBodySink`
(src/runtime/webcore/fetch/FetchRequestBodySink.rs): drops the tasklet
ref taken in `start_request_stream`. The tasklet owns the sink
allocation, so if that ref is the last one, `FetchTasklet::deinit` ->
`clear_data` -> `clear_sink` -> `heap::take(sink)` frees `*self` inside
the call. That is the fallback path for a pump that never settled; it is
reachable at least on worker teardown: phase B of `VirtualMachine`
teardown releases the aborted fetch's other refs on the tasklet, and
phase C then destroys the heap, sweeping the controller with `m_sinkPtr`
still set because `JSSinkController__onClose` does not run the detaching
JS callback once termination is pending.
* `HTTPServerWritable`, `NetworkSink` and `RewriterPipe` do not free
anything here (their allocations are owned by the `RequestContext`, the
S3 wrapper and the pipe's own refcount respectively).
A reference passed as an argument has to stay dereferenceable until the
call returns. Freeing it from inside the call is undefined behaviour
under both aliasing models whether or not the reference is used again
(Stacked Borrows: `deallocating while item is strongly protected`; Tree
Borrows, which `bun run rust:miri` uses, rejects it the same way), and
that protector is the model behind the `dereferenceable` attribute rustc
puts on every `&`/`&mut` argument, so the optimizer may legitimately
move a load through any of the three frames past the free. No crash is
known from this; ASAN only has something to catch if the optimizer
actually takes that liberty, which the unoptimized debug build never
does, so it is not observable as a runtime test. Same family as #37672,
#37681, #37685, #37693, #37705 and #37551; #37705's description leaves
this chain out explicitly because it needs a change to the generated
thunk.
### Fix
The whole chain takes the raw pointer, which is what the C++ side has
anyway (`void* m_sinkPtr`):
* generate-jssink.ts emits `pub unsafe extern "C" fn
${name}__finalize(this: *mut ${name})` forwarding to `js_finalize`; the
ABI is unchanged, so JSSink.cpp is untouched.
* `JSSink::js_finalize(this: *mut T)` forwards to the trait.
* `JsSinkType::finalize` becomes `unsafe fn finalize(this: *mut Self)`,
documented as "the cell is giving up its claim; this may free the sink",
the same shape as `HTTPServerWritable::abort(this: *mut Self)` and the
FileSink PipeWriter callbacks.
* The three freeing impls release through the pointer without forming a
reference to the allocation: `ArrayBufferSink` calls `destroy` directly
(the inherent `finalize` wrapper, whose only caller was the trait impl,
is deleted); `FileSink::finalize(this: *mut FileSink)` keeps the same
body with per-statement `(*this).field` access, like `on_close` in the
same file (the file header no longer claims the `&mut` version was
sound; the rationale lives once, on the trait method);
`FetchRequestBodySink::finalize(this: *mut Self)` takes `task` out
through the pointer and does not touch it after the deref.
* `HTTPServerWritable` and `NetworkSink` reborrow inside their own impl
to call the unchanged inherent `finalize(&mut self)`; that borrow ends
before the impl returns and nothing under it frees, which the SAFETY
comments state. `RewriterPipe`'s impl stays empty.
Every impl performs the same operations in the same order as before; the
only thing that moves is the type the pointer travels as.
`js_controller_detached`, `js_close` and `js_end_with_sink` still take
`&mut`: nothing frees under them (the `controller_detached` contract on
the trait already requires deferring a last-owner free for that reason).
`FileSink::assign_to_stream`'s `FileSinkRef` guard also derefs from a
`&mut self` frame, but its ref is balanced against one it took itself
and every caller (subprocess stdin setup) holds its own ref across the
call, so it can never be the one that frees; left alone. Sites with the
same shape outside this chain
(`S3UploadStreamWrapper::handle_{resolve,reject}_stream`,
`FetchTasklet::write_end_request`) are not sink frames and are reported
separately.
### Tests
test/internal/source-lints/jssink-finalize-raw-ptr.test.ts scans every
`impl ... JsSinkType for ...` block for a `finalize` item and requires
`unsafe fn finalize(<ident>: *mut Self)`, checks the other frames by
signature (trait declaration, `js_finalize`, the codegen template, and
the three inherent methods that perform the free, which `pub` tells
apart from the trait impls in the same files), and checks its own
patterns against positive and negative spellings. With src/ restored to
`main` it reports:
```
src/runtime/api/html_rewriter.rs:1650: impl JsSinkType for RewriterPipe: fn finalize(&mut self) (line 1661)
src/runtime/webcore/ArrayBufferSink.rs:213: impl JsSinkType for ArrayBufferSink: fn finalize(&mut self) (line 221)
src/runtime/webcore/fetch/FetchRequestBodySink.rs:274: impl JsSinkType for FetchRequestBodySink: fn finalize(&mut self) (line 281)
src/runtime/webcore/FileSink.rs:1283: impl JsSinkType for FileSink: fn finalize(&mut self) (line 1294)
src/runtime/webcore/streams.rs:2104: impl JsSinkType for HTTPServerWritable: fn finalize(&mut self) (line 2119)
src/runtime/webcore/streams.rs:2523: impl JsSinkType for NetworkSink: fn finalize(&mut self) (line 2530)
src/runtime/webcore/Sink.rs: JsSinkType::finalize declaration does not take the sink as `*mut`
src/runtime/webcore/Sink.rs: JSSink::js_finalize does not take the sink as `*mut`
src/codegen/generate-jssink.ts: generated `${name}__finalize` thunk does not take the sink as `*mut`
src/runtime/webcore/FileSink.rs: FileSink::finalize does not take the sink as `*mut`
src/runtime/webcore/fetch/FetchRequestBodySink.rs: FetchRequestBodySink::finalize does not take the sink as `*mut`
```
(`ArrayBufferSink::destroy` already took `*mut` on `main`; its entry is
a ratchet.)
The behaviour itself is the existing coverage of each finalize path; see
below.
### Verification
Debug (ASAN) build on Linux: `cargo clippy -p bun_runtime` and `rustfmt
--check` on the touched files are clean; the generated thunks have the
new signature. Passing: test/internal/source-lints/ (all 18 files),
test/js/bun/util/arraybuffersink.test.ts and filesink.test.ts (wrapper
sweep and prototype `.close()` for the two Box/refcount sinks),
test/js/bun/spawn/spawn.test.ts (stdin `FileSink` via
`assign_to_stream`), test/js/web/fetch/body-stream.test.ts,
fetch-abort-stream-body.test.ts and fetch-stream-cancel-leak.test.ts
(`FetchRequestBodySink`),
test/js/bun/http/serve-response-stream-sink-leak,
serve-direct-readable-stream, serve-stream-reject-flush-leak and
serve-async-stream-client-abort (`HTTPServerWritable` controller
teardown), test/js/web/fetch/server-response-stream-leak.test.ts,
test/js/web/streams/streams.test.js,
test/js/workerd/html-rewriter.test.js and html-rewriter-leak.test.ts
(`RewriterPipe`), test/js/bun/s3/s3-stream-error-gc.test.ts and
s3-argument-validation.test.ts. The S3 upload tests that would drive
`NetworkSink` (s3.test.ts, s3-storage-class.test.ts) cannot connect from
this environment and fail identically on the released binary, so that
impl (a one-line forward to the unchanged inherent method) is left to
CI.
Overlap with the sibling lints, each of which documents these sites as
tracked separately: #37685 / #37693 / #37705 add
`self-receiver-teardown.test.ts` with
`src/runtime/webcore/ArrayBufferSink.rs: 1` allowlisted for the
`Self::finalize(ptr::from_mut(self))` line this PR removes, and #37703
adds `self-receiver-release.test.ts` with
`src/runtime/webcore/FileSink.rs: 2` allowlisted for the two derefs
inside the old `FileSink::finalize(&mut self)` (running that lint
against this branch reports FileSink.rs at 0). Whichever side lands
second deletes the entry; nothing else conflicts (#37703's
FetchRequestBodySink.rs hunk is `end_from_stream`, a different
function). #34999 and #35528 edit the body of `FileSink::finalize`
textually but keep the receiver.
---------
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it restructures two Windows-only libuv state machines end to end (new Step return contract, Drop impls, moving ref_keep_alive/unref_keep_alive to init/Drop, and inlining the open path into ReadFileUV), a human look would still be worthwhile.
What was reviewed:
ref_keep_aliveaccounting: one ref atinit/start_with_ctx, one unref inDrop; everyStep::Pendingleaves the task allocated and everyStep::Donereachesfinish→heap::take→Drop, so the pair is balanced on all paths including synchronous failure.ReadFileUV::on_finishreturningStep::Pendingondo_close:close_after_io()is hard-codedfalseandio_request()isNone, sodo_closealways returnsfalseon Windows — no unpairedPending.CopyFileWindows::finishordering (take promise, build error, enter scope, drop task, settle) matches the oldthrow/resolve_promisedestroy-then-settle order.- The removed
FileOpenerWindows hooks hadReadFileUVas their only real caller;ReadFile/WriteFilestubs wereunreachable!().
Extended reasoning...
Overview
This PR converts CopyFileWindows (src/runtime/webcore/blob/copy_file.rs) and ReadFileUV (src/runtime/webcore/blob/read_file.rs) so that no &mut self frame frees its own allocation. Each step method now returns a #[must_use] Step (Pending / Done), and a single unsafe fn finish(this: *mut Self, step) — reached only from raw-pointer entry points (init, on_uv_complete, the mkdirp ManagedTask hop, start_with_ctx) — performs heap::take. Teardown moves into Drop impls (fd close for ReadWriteLoop, io_request.deinit() + unref_keep_alive() for both tasks). The Windows arm of FileOpener::get_fd[_by_opening] and its trait hooks are removed and the libuv open is inlined into ReadFileUV::get_fd. write_file.rs drops the now-dead unreachable!() stubs. A source-lint test bans the destroy/deinit/finalize(ptr::from_mut(self)) shape with a ratcheted allowlist, a bun-write test covers the copy→chmod chain (both copy modes), and the fd-read test is un-skipped on Windows.
Security risks
None identified. This is an aliasing-model soundness refactor of file-copy/read paths; no auth, crypto, or untrusted-input parsing surface changes. Error message construction and path handling are moved but not altered.
Level of scrutiny
High. This is ~500 lines of restructuring in memory-safety-critical Windows I/O with subtle Stacked/Tree Borrows reasoning about provenance and reference protectors. It changes when ref_keep_alive/unref_keep_alive fire (from per-hop to once-per-task via Drop), introduces two new Drop impls that close descriptors, and rewrites every libuv callback body. The PR description documents thorough Windows verification (test suites plus a per-path differential script against the base build), but the scope and the number of behavior-preservation obligations put it well outside auto-approval.
Other factors
- The comment-cop bot left a batch of "paragraph-long comment" flags after the last commit (
6d47e67d, which already shortened comments). The remaining doc/SAFETY comments look proportionate to what they document (Step,finish# Safety,on_uv_completeprovenance note), but a maintainer should confirm whether the bot's threshold is satisfied. - The lint file is intentionally shared with #37685/#37693; whichever lands last resolves the allowlist, so merge order matters.
- Spot-checked the
read_write_loop.close()→Dropconversion: the droppedmust_close_* = false/*_fd = INVALIDresets andread_buf = Vec::new()are dead inDrop(fields are about to be freed), so their removal is fine. on_mkdirp_complete_concurrent'sPosted::Refusedpath still leaks theCopyFileWindows(it only frees theConcurrentTask), but that is pre-existing behaviour and unchanged by this PR.
…nter, not &mut self CopyFileWindows::throw / resolve_promise and ReadFileUV::on_finish freed the task they were called on (Self::destroy / Self::finalize on from_mut(self)) while that receiver, and the &mut self receivers of everything that called them, were still live arguments. The state machine steps now return a Step and never free; the entry points that hold the heap pointer (init / start_with_ctx, the libuv completions, the mkdirp hop) apply it through finish(*mut Self), which is the only place either task is freed. ReadFileUV opened its file through FileOpener::get_fd(&mut self, callback), whose callback chain ended in the same free, so it now opens through libuv itself; the Windows-only hooks of FileOpener and their unreachable!() stubs on ReadFile / WriteFile go away with it. The new source lint bans passing the receiver's address to an associated destroy / deinit / finalize and ratchets the remaining sites in other subsystems.
…ons, add finalize
CopyFileWindows and ReadFileUV get Drop impls (request cleanup, the loop reference, and for the copy a Drop on ReadWriteLoop that closes the descriptors it opened), so finish() turns the pointer back into the Box and drops it instead of calling a destroy()/finalize() routine. The copy task now holds a single loop reference for its lifetime, like the read task already did, instead of one per hop.
6d47e67 to
b18b9b8
Compare
|
Rebased onto main (b18b9b8). #37716 landed in the meantime and removed the ArrayBufferSink line, so the lint's ratchet failed on the rebased tree until that allowlist entry was deleted, which is the intended flow; the three remaining entries (#37551, #37685, #37693) are still open. The Windows suites and the per-path script were re-run on the rebased head with the same results (175 pass across 13 files, script output identical to the base build in both copy modes). One note on the review's last point, since the new |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it's a substantial restructuring of the Windows CopyFileWindows / ReadFileUV state machines — teardown moved to Drop, per-hop ref_keep_alive collapsed into one, finish ordering relative to promise settlement, and the FileOpener Windows hooks removed — a human look from someone familiar with the blob module would still be worthwhile.
Checked that every step returning Step::Pending has a completion that reaches finish (including on_finish's do_close branch, which is unreachable on ReadFileUV since close_after_io() is false and io_request() is None).
Checked the inlined ReadFileUV::get_fd preserves the trait's open flags (RDONLY | NONBLOCK | CLOEXEC), mode, and error mapping; set_open_error only runs on the path branch so .path() is safe.
Checked CopyFileWindows::finish keeps the old order (take promise, build error, enter scope, drop task → close descriptors, then settle) and that init reads the promise value before finish can free the task.
Extended reasoning...
Overview
This PR converts two Windows-only blob I/O tasks (CopyFileWindows in src/runtime/webcore/blob/copy_file.rs and ReadFileUV in src/runtime/webcore/blob/read_file.rs) from a shape where &mut self methods free the task via Self::destroy(ptr::from_mut(self)) / Self::finalize(ptr::from_mut(self)) — UB under Stacked/Tree Borrows because the reference argument is protected — to a Step-returning state machine where only a raw-pointer finish(this: *mut Self) reclaims the Box. Teardown moves to Drop impls (ReadWriteLoop closes its descriptors; the two task types deinit their libuv request and release the loop reference). The four libuv callbacks become one-line thunks through a shared on_uv_complete. The FileOpener trait loses its Windows-only hooks (loop_, req, set_open_callback, open_callback) since ReadFileUV was the only Windows caller and now opens through libuv itself; get_fd/get_fd_by_opening become #[cfg(not(windows))]. A new source lint (test/internal/source-lints/self-receiver-teardown.test.ts) ratchets the pattern, and two behavioural tests are added/enabled (copy-with-mode+mkdirp in bun-write.test.js, fd-backed reads on Windows in bun-file-fd-read.test.ts).
Security risks
None identified. This is an internal soundness refactor of file-copy/read state machines; no new user-facing surface, no parsing of untrusted input, no auth/crypto/permissions.
Level of scrutiny
High. This is the memory-safety category REVIEW.md flags as most-blocked: raw-pointer lifetime across libuv callbacks, Drop ordering, reference-count balancing (the per-hop ref_keep_alive/unref_keep_alive pairs are consolidated into one taken in init and released by Drop), and provenance-sensitive back-pointer stashing in req.data. Nearly all of it is #[cfg(windows)], so it isn't exercised by the default Linux build. The control-flow reshaping is faithful to the original as far as I traced (each old throw/resolve_promise/finalize site maps to a Step::Done returned to a finish caller that holds the heap pointer), and the PR description documents byte-identical output on a Windows debug build across both copy modes plus a keep-alive script — but the ref-count consolidation and the Drop-driven descriptor close ordering are exactly the sort of non-local invariants a maintainer should confirm.
Other factors
The bug hunting system found nothing. The comment-cop bot flags are all resolved (comments shortened or pre-existing). The lint file is intentionally shared with #37685/#37693/#37716 and the allowlist will need reconciling on merge, which the description calls out. Given the size (~650 lines of state-machine restructuring in unsafe Rust) and the design decisions involved (Step pattern, single loop reference, FileOpener trait surface reduction), this is beyond what should land without a human sign-off.
…37705 Same lint body as the other two PRs converting sites of this shape (adds the scopeguard deferred form and finalize as a callee), so each PR's copy differs only in which allowlist entry it deletes. This copy drops the ThreadPool.rs entry and keeps node_fs.rs, copy_file.rs and read_file.rs at their current counts.
The pool-side mkdirp completion posted through the LoopHandle stored in the task, so a reference into the task was live while the JS thread was already free to pop the hop and free it. Clone the handle out first, as post_job does, in both the copy and the write task. ReadWriteLoop's Drop only ever sees libuv-owned descriptors, so it queues the closes directly, and the comment in finish now says that the drop only queues them.
|
Self-review pass done; two findings survived, both fixed in 421d0f4:
Re-verified on Windows after the change: same suites (175 pass across 13 files, bun-write in both copy modes), per-path script 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. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/webcore/blob/read_file.rs`:
- Around line 1059-1068: Update the uv_fs_open invocation in Self::on_open’s
surrounding open flow to translate the Bun flags through
libuv::O::from_bun_o(...) before passing them as the open flags. Preserve the
existing read-only and permission behavior while ensuring Windows receives
libuv-compatible flags instead of raw NONBLOCK and CLOEXEC bits.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: c9b22f1d-685a-450e-96d1-0909958a6de3
📒 Files selected for processing (7)
src/runtime/webcore/Blob.rssrc/runtime/webcore/blob/copy_file.rssrc/runtime/webcore/blob/read_file.rssrc/runtime/webcore/blob/write_file.rstest/internal/source-lints/self-receiver-teardown.test.tstest/js/bun/io/bun-write.test.jstest/js/bun/util/bun-file-fd-read.test.ts
There was a problem hiding this comment.
I reviewed this PR and the bug-hunting pass found no issues. Because it restructures the teardown/lifetime of two Windows libuv state machines (heap ownership, Drop impls, event-loop keep-alive accounting, cross-thread mkdirp hop) across ~700 lines of unsafe Rust, a human look is still worthwhile — the author is also flagged as waiting on re-review after the Drop rework.
What was reviewed:
- The
Step/finish(*mut Self)shape in both tasks — every&mut selfstep returnsStepand only the raw-pointer entry points (on_uv_complete,init/start_with_ctx,call_erased) reclaim the box; no path frees under a live reference. - Keep-alive balance: the single
ref_keep_aliveininit/start_with_ctxis released byDrop; checked that every hop that used to bracket its own ref/unref is covered while the task is live, and that a synchronous failure ininitstill drops it. ReadWriteLoop::droplosingmake_libuv_owned()—prepare_pathlikealready produces libuv-owned fds, so passing them straight toCloser::closeis equivalent.on_mkdirp_complete_concurrentcloningloop_handlebeforepost_task(both here and the matching change inWriteFileWindows) so nothing borrows the task across the post.
Extended reasoning...
Overview
The PR converts CopyFileWindows and ReadFileUV (Windows-only libuv-backed blob tasks) so they no longer free their own heap allocation from inside &mut self methods, which is UB under Stacked/Tree Borrows. Each state machine's steps now return a #[must_use] Step; the C-callback thunks and init/start_with_ctx hold the raw *mut Self, run one step, and hand the result to finish(this: *mut Self), which reclaims the Box on Step::Done. Teardown moves into Drop impls (ReadWriteLoop closes fds it opened; the tasks deinit their libuv request and release the loop keep-alive). The Windows half of FileOpener::get_fd/get_fd_by_opening and its unreachable!() stubs on ReadFile/WriteFile are deleted, with ReadFileUV opening through libuv itself. on_mkdirp_complete_concurrent in both copy_file.rs and write_file.rs now clones the LoopHandle out before posting so the pool thread holds nothing pointing into the task across post_task. A new source lint (self-receiver-teardown.test.ts) ratchets the pattern out of the tree, and two behavioural tests are added/unskipped.
Security risks
None identified. This is internal lifetime/ownership plumbing for file I/O tasks; no user-input parsing, auth, or trust-boundary changes. The risk class is memory safety (use-after-free, double-free, refcount leak/imbalance), not security in the injection/bypass sense.
Level of scrutiny
High. This is exactly the category the repo's review guidance treats as most-blocked: pairing acquisition with release, exactly-one-owner, refcounts balanced on every terminal path, and cross-thread lifetime. The change touches ~700 lines of #[cfg(windows)] unsafe Rust with libuv FFI, replaces per-hop ref_keep_alive/unref_keep_alive bracketing with a single ref taken at construction and released in Drop, changes what ReadWriteLoop's close path does (dropping the make_libuv_owned() fallback), and reorders promise-settle vs. task-drop in finish. Each of these is individually reviewable, and the PR description walks through them carefully with Windows verification, but the surface area and the nature of the invariants put this well outside what should be auto-approved.
Other factors
- The author's status comment says the
Droprework was done "per review" and they are "waiting on re-review" — a human already has context on this change and asked for the current shape. - Two comment-cop inline comments (copy_file.rs:1182, :1800) from the latest push are still unresolved; earlier rounds were addressed with either shortening or a "this is the item's contract, not a workaround" justification, so these likely fall in the same bucket, but they are outstanding.
- This PR intentionally shares
test/internal/source-lints/self-receiver-teardown.test.tswith #37685 and #37693 so whichever lands last resolves the allowlist conflict; that coordination is a merge-order concern a human should be aware of. - CI on the rebased head is reported green on every Windows and ASAN lane; the behavioural tests added (
{ mode }copy + mkdirp on Windows, and unskippingbun-file-fd-readon Windows) exercise the converted paths.
from_bun_o(RDONLY | NONBLOCK | CLOEXEC) is RDONLY on Windows (libuv defines NONBLOCK as 0 there and has no CLOEXEC), so pass libuv's own constant like WriteFileWindows does instead of bun's bits, which libuv happened to ignore.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it's a substantial refactor of two unsafe Windows async I/O state machines (Drop ordering, event-loop refcount consolidation, cross-thread post via cloned LoopHandle, raw-pointer finish reclaiming the Box), a human look would still be worthwhile.
Checked: the Step-returning paths in both tasks all reach finish exactly once and none free self; the consolidated ref_keep_alive/Drop pair is balanced on every terminal path including synchronous failure in init; on_mkdirp_complete_concurrent now posts through a cloned handle in both CopyFileWindows and WriteFileWindows so nothing borrows the task across the post; ReadWriteLoop::drop only closes libuv-owned fds prepare_pathlike opened. The CodeRabbit note about uv_fs_open flags was addressed in 72707d3 (now libuv::O::RDONLY only).
Extended reasoning...
Overview
Restructures CopyFileWindows and ReadFileUV (Windows-only libuv blob tasks) so their &mut self state-machine steps return a #[must_use] Step instead of freeing the allocation they run on; a single unsafe fn finish(this: *mut Self, step) reclaims the leaked Box on Step::Done. Teardown moves to Drop impls (io_request cleanup, event-loop unref, ReadWriteLoop fd closes). The per-hop ref_keep_alive/unref_keep_alive pairs collapse to one pair taken in init/start_with_ctx and released in Drop. FileOpener loses its Windows-only hooks (loop_, req, open-callback stash) and get_fd/get_fd_by_opening become #[cfg(not(windows))]; ReadFileUV opens through uv_fs_open itself. Also fixes on_mkdirp_complete_concurrent in both CopyFileWindows and WriteFileWindows to clone the LoopHandle out before post_task, so no reference into the task is live across the post. Adds a source-lint test (shared with #37685/#37693), a { mode } copy test in bun-write.test.js, and enables bun-file-fd-read.test.ts on Windows.
Security risks
None. This is internal memory-lifetime plumbing for file I/O; no auth, crypto, permissions, or untrusted-input parsing is touched.
Level of scrutiny
High. The change is ~700 lines across four Rust files, all of it in the memory-safety-critical category the repo's review guide singles out: raw-pointer ownership, Drop ordering vs. promise settlement, event-loop refcount balance across every terminal path, cross-thread task posting where the JS thread may free the task before the pool thread's post_task returns, and libuv FFI back-pointers derived from &mut self. The reasoning about why each of these is sound is subtle (e.g., why re-leaking the Box at every callback would invalidate stored back-pointers under Stacked Borrows, why the Posted::Refused branch still intentionally leaks the task). The author has verified extensively on a Windows debug build with a per-path comparison script and the full test suite in both copy modes, but the surface area and the unsafe-Rust density warrant a maintainer's eyes.
Other factors
The PR has already been through several rounds: comment-cop trimming, a self-review pass that found and fixed a real reference-outlives-free in the mkdirp pool hop (421d0f4), and CodeRabbit's open-flags note addressed by 72707d3. It follows the same pattern as sibling conversions (#37672, #37685, #37693, #37551) and the source-lint ratchet is coordinated across them. CI on the previous head had all Windows and ASAN lanes green. No bugs surfaced in the multi-agent review. The do_close path in on_finish returning Step::Pending was traced — FileCloser::do_close on ReadFileUV always returns false (no io_request field), so that branch is effectively dead and Step::Done is always reached there; worth a maintainer confirming that reading of the trait's default.
Problem
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.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.Fix
&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.&mut selfhas returned by then, and the back pointers stored in libuv requests are only used after that.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
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.CopyFileWindows,ReadFileUV) instead of the blocking POSIX ones; only the Windows paths change here.&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.test/internal/source-lints/holds tests that grep the source for banned patterns behind a ratcheting allowlist. bundler: make Worker::deinit_soon take the worker pointer instead of &mut self #37685 and node:fs: let the fs completions own their task box instead of freeing it through &mut self #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 selfmethods: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, whichbun run rust:miriuses:the strongly protected tag disallows deallocations), and the protector is the model of thedereferenceableattribute rustc puts on reference arguments, so the optimizer is entitled to move a load ofselfpast the free. No crash is known from these sites. Same family as #37672 (Blob::deinit), #37685 (bundlerWorker), #37693 (node:fstasks) and #37551 (lifecycle scripts); these are the two sites in the blob module.Fix
Both state machines get the same shape: the
&mut selfsteps 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 tofinish(this: *mut Self). OnStep::Done,finishturns the pointer back into theBoxthe constructor leaked and drops it; teardown is the types'Dropimpls (ReadWriteLoopqueuesaio::Closercloses for the two descriptors it opened, which are always libuv-owned,CopyFileWindows/ReadFileUVclean 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). Thedestroy/finalizeroutines are gone;heap::takein the twofinishfunctions is the only place either task's allocation is touched as a whole.CopyFileWindows:copyfile,prepare_read_write_loop,mkdirp,on_mkdirp_complete,on_completeandon_read_write_loop_completereturnStep::Pending(a libuv request or the mkdirp pool task is in flight) orStep::Done(Result<usize, bun_sys::Error>).throwandresolve_promise, which differed only in which way they settled the promise, becomefinish, 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 selfsteps (on_copyfile_complete,on_chmod_complete,on_read_complete,on_write_complete) and theextern "C"thunks all go through oneon_uv_completethat recovers the task pointer fromreq.data, runs the step and callsfinish;initand theManagedTaskhop for mkdirp do the same.initstill reads the promise value before runningcopyfile, so a synchronous failure still settles and frees beforeinitreturns. The per-hopref_keep_alive/unref_keep_alivepairs (copyfile, mkdirp, chmod, the read/write loop) become one reference taken ininitand released byDrop, which is whatReadFileUValready 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 insideinitwhere it previously touched neither.ReadFileUV:on_file_open,queue_read,on_finishand the bodies of the stat / read callbacks returnStep::PendingorStep::Done;finishdelivers the result to the completion and drops the task (the oldfinalize, minus the manual request cleanup and unref, whichDropdoes). Opening moves into the task:get_fd(&mut self) -> Stepuses the store's fd directly or queuesuv_fs_openwith the same mode and error mapping the trait used (the flags becomelibuv::O::RDONLY, which is whatuv::O::from_bun_omade of the trait'sRDONLY | NONBLOCK | CLOEXECon Windows anyway, spelled the wayWriteFileWindows::openspells its flags), and its completion continues inon_file_open. The trait'sget_fdwas the top of the freeing chain on Windows andReadFileUVwas its only Windows caller (WriteFileWindowsalready opens through libuv itself), soFileOpenerloses its Windows-only hooks (loop_,req,set_open_callback,open_callback, the libuv half ofget_fd_by_opening) together with theunreachable!()stubs for them onReadFileandWriteFile;get_fd/get_fd_by_openingbecome#[cfg(not(windows))]and their POSIX body is unchanged (the diff of Blob.rs with whitespace ignored adds twocfgattributes and a doc comment).ReadFileUValso drops theopen_callbackfield that only existed to carry the trait's callback across the hop.The back pointers the steps store (
io_request.data,req.data, the mkdirpcompletion_ctx) are still taken from the&mut selfof 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 aBoxdrop.One more site in the same module, found in review: the pool half of the mkdirp hop (
on_mkdirp_complete_concurrent, in bothCopyFileWindowsand itsWriteFileWindowstwin in write_file.rs) posted the JS-thread hop through theLoopHandlestored in the task, sopost_task's&selfpointed 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 whatpost_jobdoes 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 exactlyThe file is the one bundler: make Worker::deinit_soon take the worker pointer instead of &mut self #37685 and node:fs: let the fs completions own their task box instead of freeing it through &mut self #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, install: lifecycle exit returns Disposition; never free self via &mut #37551), src/bundler/ThreadPool.rs (1, bundler: make Worker::deinit_soon take the worker pointer instead of &mut self #37685), and src/runtime/node/node_fs.rs (3, node:fs: let the fs completions own their task box instead of freeing it through &mut self #37693). The entry this copy carried for src/runtime/webcore/ArrayBufferSink.rs was dropped after JSSink: pass the sink to finalize as *mut instead of &mut #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.)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, Bun.write: fix file-to-file copy resolving 0 bytes on Windows and macOS overwrite #33715, andUV_-prefixed codes in the read/write loop, Report EBADF instead of UV_EBADF for async libuv fs errors on Windows #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).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_promiselines that becamefinish, and #31844 changes the lifetimes on these same structs.