node:fs: share the recursive readdir scan between pool threads by pointer, not &mut self - #37861
node:fs: share the recursive readdir scan between pool threads by pointer, not &mut self#37861robobun wants to merge 1 commit into
Conversation
…t &mut
AsyncReaddirRecursiveTask is one heap object shared by every pool thread
working on a readdir({ recursive: true }) scan, kept alive by
subtask_count, and handed to the JS thread (which frees it) by the thread
that drops the last count. Its pool-side methods took &mut self: the root
walk, every subtask (ParentRef::assume_mut) and the finishing thread each
held an exclusive reference to the same object at once, and the finishing
thread posted the job, letting the JS thread free it, from under its own
&mut self frames. Both aliasing models reject that (see the lint's header).
The shared phase now goes through a ReaddirScanRef (ParentRef) and &self,
with the state written during the fan-out interior mutable: pending_err is
a Guarded, root_fd a Cell; has_result is gone, as the count reaching zero
already happens exactly once. The frames that drop a count, perform_work
and on_subtask_done, take the scan by pointer; the last one out does the
exclusive work in finish_scan(&mut self), which returns the completion so
that the hand-over happens with no borrow live. The decrement is AcqRel,
as in NewAsyncCpTask::on_subtask_done, so the finishing thread's join and
its read of pending_err are ordered after the other threads' pushes. The
walker takes the handle instead of &mut, which also removes the
detach_lifetime round-trips.
test/internal/source-lints/self-receiver-fan-out.test.ts bans assume_mut()
inside the impl blocks of owned_task! types and a self receiver or &Self
parameter on the count-dropping entry points of the two fan-outs; with the
previous node_fs.rs it reports the subtask's assume_mut() and
perform_work(&mut self, ..). fs.test.ts gains the buffer-encoding variant
of the recursive promise walk (a join across subdirectories, and a discard
after a subtask fails), which the Node comparison tests did not reach.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 2:05 PM PT - Aug 12th, 2026
✅ @robobun, your commit e293675b4114c1d286f157bfb6aa6cb9f68f8cd1 passed in 🧪 To try this PR locally: bunx bun-pr 37861That installs a local version of the PR into your bun-37861 --bun |
|
Status: ready for review. Reproduced as a model violation rather than a crash: the standalone Miri reduction in the description (Tree Borrows and Stacked Borrows) rejects the two shapes the scan had, a directory with two subdirectories and a scan finishing from under its own |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it restructures the concurrent unsafe ownership model of AsyncReaddirRecursiveTask (raw-pointer receivers, refcount ordering, interior-mutable field conversions), a human look would still be worthwhile.
What was reviewed:
ParentRefisCopyandDeref-only, so passingscanby value intoenqueue/the walker whilebasenameborrowsscan.root_pathholds only shared borrows — no aliasing conflict.root_fd: Cell<FD>write ordering: set by the root before anyenqueue, read shared by subtasks, replaced infinish_scanafter the last count — no overlap with a shared read.has_resultremoval is safe:fetch_sub == 1fires exactly once anddone.take().expect(..)still guards double-finish.FdExtis already in scope at file top (line 21), so dropping the localusebeforeroot_fd.close()is fine;AtomicBoolis still used byNewAsyncCpTask.
Extended reasoning...
Overview
This PR converts AsyncReaddirRecursiveTask (the pool-side state for fs.promises.readdir({recursive: true})) from &mut self receivers to a *mut Self / &self split, mirroring the existing NewAsyncCpTask shape a few hundred lines up. The shared fan-out phase now goes through ReaddirScanRef (a Copy ParentRef<_, Mut>) with interior-mutable fields (Guarded<Option<Error>>, Cell<FD>, atomics, the lock-free queue); the count-dropping frames (perform_work, on_subtask_done) hold only *mut Self; and finish_scan(&mut self) returns the Completion rather than finishing it under a live borrow. The impl_tag! macro is replaced by a generic scan_directory::<T>; write_results is split into push_results/record_error; the join loop moves into join_result_lists. The decrement ordering is upgraded from Relaxed to AcqRel. It also adds a 279-line source lint enforcing this shape across all owned_task! types, and two encoding: 'buffer' recursive-readdir tests.
Security risks
None. This is an internal aliasing-model refactor of an existing concurrent task; no new inputs, no parsing, no auth/crypto surface.
Level of scrutiny
High. Per REVIEW.md, native memory safety is the most-blocked category, and this touches thread affinity, reference-count balancing on every terminal path, raw-pointer receivers with hand-written SAFETY contracts, and memory-ordering changes. fs.readdir({recursive: true}) is a production hot path. The reasoning in the PR description is very thorough (Miri reductions under both aliasing models, explicit mapping to the sibling NewAsyncCpTask pattern), and I checked that ParentRef is Copy+Deref-only, Guarded::get_mut exists for the &mut self sites in then/finish_scan, the file-level FdExt import covers the removed local one, and AtomicBool is still used by NewAsyncCpTask so no dead import. But a change of this shape — deleting an atomic guard (has_result), changing decrement ordering, and rewriting every pool-side entry point — warrants a human maintainer's read, not auto-approval.
Other factors
The PR acknowledges that JobContext::run(this: &mut Self, ...) still holds a protected &mut across perform_work and defers that to #37820; the two are stated to be independent. The source lint has anti-vacuity checks (verifies the named entry points still exist, verifies a non-empty scan set). The new fs tests are stated to pass before and after (they cover the previously-untested buffer variant, not the aliasing fix itself). No prior human or bot reviews with substantive feedback; CI is still building.
Problem
readdir(.., { recursive: true })on the promise and callback paths returns the right entries. What is wrong is the shape of the shared state under Rust's aliasing rules; Miri rejects it (reduction in the collapsed original).&mut. A&mutclaims the whole object, so a directory with two subdirectories is enough: the second subtask's count bump invalidates the reference the first subtask was handed. There is no data race, since the bytes written are atomics, a lock-free queue and a locked error.&mut self(for the root, plus an argument pointing into the object), so the JS thread freed memory those references still protected. This happened on every scan.Fix
Cell). Property to check: no thread holds&mutto the scan while another thread can touch it. Same shape as thecpfan-out earlier in the same file.AcqRel, so the finishing thread reads the queue and the recorded error after every other thread's writes; withRelaxedthat was formally unordered. The separate "already finished" flag is deleted because the count reaches zero exactly once.encoding: "buffer"tests (which also pass before the fix) on a debug ASAN build;cargo checkfor host and Windows, clippy and rustfmt are clean.Background
&mut Tclaims exclusive access to all ofTwhile the reference is live. A write through any other path, even to an atomic field with no data race, makes further use of that reference undefined behaviour. Miri's Stacked Borrows and Tree Borrows are the checkers;bun run rust:miriuses Tree Borrows.bun_ptr::ParentRefis bun's back-pointer from a subtask to the object that owns it, kept alive by that object's count. It derefs to&T;assume_mut()forms a&mutfrom it andas_mut_ptr()yields the raw pointer.bun_threading::Guarded<T>is a mutex that owns the value it guards.test/internal/source-lints/holds bun tests that regex-scan the Rust tree for banned shapes; the new one bansassume_mut()inside pool-task types and requires the count-dropping entry points of both fan-outs to take a*mut.Original description
What
readdir(.., { recursive: true })on the promise/callback path is oneAsyncReaddirRecursiveTask(src/runtime/node/node_fs.rs,mod _async_tasks) shared by every pool thread working on the scan: the job's own pool callback walks the root,enqueueschedules aReaddirSubtaskper subdirectory,subtask_countcounts them, and whichever thread's decrement reaches zero joins the result lists and finishes theCompletion, after which the JS thread reads and frees the wholeJob(Job::complete, src/jsc/job.rs) through the job's own pointer.Every pool-side method on it took
&mut self:perform_work,enqueue,write_results,finish_concurrently, and the walkerreaddir_with_entries_recursive_async(.., &mut AsyncReaddirRecursiveTask, ..).ReaddirSubtask::run_ownedgot its&mutwithreaddir_task.assume_mut(). So:&mutclaims the whole object: the secondenqueuein a directory is a fresh&mutreborrow whosefetch_addis a foreign write to the pointer the first subtask was handed, which the model then refuses to let the subtask reborrow. A directory with two subdirectories is enough.finish_concurrently(&mut self), called fromwrite_results(&mut self)/perform_work(&mut self)(and, for the root, withbasename: &ZStrpointing intoroot_path), so the JS thread frees memory those arguments still protect. That happens on every scan, on whichever thread finishes it; the other threads' frames are exposed to the same free racily, as soon as their own decrement has landed.No crash is known: this is the contract shape the aliasing models reject, the same class as #37681 / #37705 / #37768 / #37787 / #37820. #37820 converts the carrier frame above this (
JobContext::run) and still calls intoperform_workby reference; this PR is everything below it, and the two are independent apart from the two lines inrun. Found by the work on #37820.Standalone reduction under Miri (both shapes rejected under Tree Borrows and Stacked Borrows, the pointer shape accepted under both)
Scanstands for the task,Tokenfor theCompletion(finishing it frees the job on another thread, asJob::completedoes), the spawned threads for the subtasks; channels pin down one interleaving the real code produces routinely.Under Stacked Borrows (Miri's default) the same two cases fail with "trying to retag from <786> for Unique permission ... but that tag does not exist in the borrow stack" and "not granting access to tag <418> because that would remove [Unique for <446>] which is strongly protected".
Fix
Same shape as
NewAsyncCpTaska few hundred lines up (cp_async(.., this: *mut Self)/CpSingleTask::run_owned/on_subtask_done(this: *mut Self)):ReaddirScanRef(ParentRef<AsyncReaddirRecursiveTask, Mut>, the type the subtask already stored) and&self.enqueue(scan, ..)and the walker take the handle, which also removes the twodetach_lifetimeround-trips the&mutsignatures had forced. What is written during the fan-out is now interior mutable:pending_erris abun_threading::Guarded<Option<Error>>(as in the shell'srm),root_fdaCell(written by the root walk before anything is enqueued and by the finishing thread, read shared in between; same as cp'sresult).has_resultis deleted: the count reaching zero already happens exactly once, anddone.take().expect(..)keeps the check.perform_work(this: *mut Self, subdir: Option<&ZStr>)(the root's basename is now borrowed insidescan_directory, which has returned by the time the count is dropped; the subtask's is its own allocation) andon_subtask_done(this: *mut Self). The last one out does the exclusive work infinish_scan(&mut self), which returns theCompletioninstead of finishing it, so the hand-over happens from the pointer frame with no borrow live.scan_directory::<T>replaces theimpl_tag!macro (whose$variantargument was unused);push_results/record_errorare the&selfhalves of the oldwrite_resultsand error branch.AcqRel, as inon_subtask_donefor cp: the finishing thread'spop_batchand its read ofpending_err(outside the lock) are then ordered after every other thread's pushes and error record; withRelaxedthey were formally unordered.Behaviour is meant to be unchanged: same fd lifetime, same first-error-wins with the same path fallback, same join, same
then.Tests
test/internal/source-lints/self-receiver-fan-out.test.ts: inside the impl blocks of everyowned_task!type (five in the tree),assume_mut()is banned; and the count-dropping entry points of the two fan-outs (NewAsyncCpTask::{cp_async, on_subtask_done},AsyncReaddirRecursiveTask::{perform_work, on_subtask_done}) must take the object as*mutwith no receiver or&Selfparameter, with a check that the listed functions still exist so a rename cannot make it vacuous. Withsrc/at main it reportsnode_fs.rs:2332(the subtask'sassume_mut()) andnode_fs.rs:2410: fn perform_work(&mut self, ..); with this branch the wholetest/internal/source-lints/directory passes (88 tests).test/js/node/fs/fs.test.ts: theencoding: "buffer"variant of the recursive promise walk was not covered (the Node comparison tests cover strings and Dirents), and it is now the third instantiation of the same body, so two tests are added next to the existing buffer/ELOOP ones: a join across nested subdirectories including an empty one, and a rejection after subtasks have already queued entries (exercising the discard). They pass before and after; the behavioural coverage of the conversion is the existingfs.test.ts -t readdirset (the Node comparisons, the x100 concurrent scans, the 64-failing-subtasks test, the MAX_PATH_BYTES test).Verification
Debug (ASAN) build:
fs.test.ts -t readdir(30 pass),test/js/node/test/parallel/test-fs-readdir-{recursive,types,types-symlinks}.js,test/internal/source-lints/(88 pass), and the stash round-trip above.cargo check -p bun_runtimefor the host andx86_64-pc-windows-msvc,cargo clippy -p bun_runtimeandrustfmt --checkare clean.