Skip to content

node:fs: let the fs completions own their task box instead of freeing it through &mut self - #37693

Open
robobun wants to merge 7 commits into
mainfrom
farm/20391523/node-fs-destroy-via-raw-ptr
Open

robobun wants to merge 7 commits into
mainfrom
farm/20391523/node-fs-destroy-via-raw-ptr

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Two of the JS-thread completions for async node:fs work take &mut self and free the heap box that holds self before returning: the Windows libuv-backed promise ops (open/close/read/write/readv/writev/statfs), and fs.cp / fs.promises.cp on every platform, which the shell's cp builtin also goes through.
  • Under both of Rust's aliasing models a reference argument is protected for the whole call it was passed to, so deallocating it is undefined behaviour whether or not it is touched again. Miri reports deallocating while item is strongly protected (Stacked Borrows) or the strongly protected tag disallows deallocations (Tree Borrows, which bun run rust:miri uses), pointing at the &mut self receiver.
  • The cp completion also leaked its task on its two error returns.
  • No crash is known from this.

Fix

  • Both completions now take self: Box<Self>. The caller holding the raw pointer (the dispatch arm, or the shell's mini-loop thunk) reclaims the box and hands it over, the same shape as the neighbouring task arms; the box drops on every return path, so the error returns no longer leak.
  • The keep-alive unref that destroy() did moves into Drop on the two task types and destroy() is deleted. This is correct because a callee may deallocate a box it owns but never a reference it was lent, and with the unref in Drop there is no way to free the task without it.
  • Only one ordering changes: the cp task now drops after settling its promise instead of before, as the Windows requests already did. Both still happen inside the same task turn, before microtasks drain.
  • Verification: the new source lint reports exactly the three sites on main and passes here; the same shape in four other files is allowlisted by exact spelling and count, each with its own conversion PR. There is no runtime repro since no crash is known; the fs cp and promises tests pass on Linux debug (ASAN) and Windows debug builds, and the shell cp builtin was run by hand on both loops.

Background

  • An async fs task is a heap box that is leaked so a raw pointer can be handed to libuv or the thread pool; whichever completion runs last on the JS thread must free it exactly once. heap::take turns that pointer back into a Box, heap::destroy takes and drops it.
  • Stacked Borrows and Tree Borrows are the aliasing models Miri checks unsafe Rust against. A reference argument carries a protector for the duration of the call, the model's counterpart of the dereferenceable attribute rustc puts on the compiled function, so this is not only a Miri concern.
  • Each pending fs task holds a keep-alive ref on the event loop so the process does not exit before the promise settles; releasing it is the one piece of teardown these tasks do beyond dropping their fields.
  • The event loop's task queue holds tag plus pointer pairs; run_task in src/runtime/dispatch.rs matches on the tag and casts the pointer. The shell cp builtin can instead complete on a separate mini event loop, which is why the cp task has a second entry point.
  • test/internal/source-lints/ holds bun tests that regex-scan the tree's own sources for banned shapes, with a ratcheting allowlist so existing sites can be converted one PR at a time.

[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/self-receiver-teardown.test.ts
bun test v1.4.0 (5900596d7)

test/internal/source-lints/self-receiver-teardown.test.ts:
(pass) scans a non-empty set of tracked Rust sources [3.14ms]
(pass) the patterns recognize the spellings they claim to [38.10ms]
(pass) a file is scanned with comments stripped and hits attributed to their lines [9.45ms]
(pass) an allowlist entry exempts only its documented spelling, and only that many of it [6.99ms]
307 |     offenders: [`x.rs:10: ${destroy}`, `x.rs:20: ${destroy}`, `x.rs:30: ${deinit}`],
308 |   });
309 | });
310 | 
311 | test("no method hands its own receiver to destroy/deinit/finalize", () => {
312 |   expect(offenders).toEqual([]);
                          ^
error: expect(received).toEqual(expected)

- []
+ [
+   "src/runtime/node/node_fs.rs:948: scopeguard::guard(core::ptr::from_mut(self), |p| unsafe { Self::destroy(p)",
+   "src/runtime/node/node_fs.rs:1709: Self::destroy(std::ptr::from_mut::<Self>(self))",
+   "src/runtime/node/node_fs.rs:1750: Self::destroy(std::pt
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (9008ae7ab)

test/internal/source-lints/self-receiver-teardown.test.ts:
(pass) scans a non-empty set of tracked Rust sources [0.16ms]
(pass) the patterns recognize the spellings they claim to [0.61ms]
(pass) a file is scanned with comments stripped and hits attributed to their lines [0.19ms]
(pass) an allowlist entry exempts only its documented spelling, and only that many of it [0.09ms]
307 |     offenders: [`x.rs:10: ${destroy}`, `x.rs:20: ${destroy}`, `x.rs:30: ${deinit}`],
308 |   });
309 | });
310 | 
311 | test("no method hands its own receiver to destroy/deinit/finalize", () => {
312 |   expect(offenders).toEqual([]);
                          ^
error: expect(received).toEqual(expected)

- []
+ [
+   "src/runtime/node/node_fs.rs:948: scopeguard::guard(core::ptr::from_mut(self), |p| unsafe { Self::destroy(p)",
+   "src/runtime/node/node_fs.rs:1709: Self::destroy(std::ptr::from_mut::<Self>(self))",
+   "src/runtime/node/node_fs.rs:1750: Self::destroy(std::ptr::from_mut::<Self>(self))",
+ ]

- Expected  - 1
+ Received  + 5

      at <anonymous> (/workspace/bun/test/internal/source-lints/self-receiver-teardown.test.ts:312:21)
(fail) no met
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/self-receiver-teardown.test.ts
bun test v1.4.0 (5900596d7)

test/internal/source-lints/self-receiver-teardown.test.ts:
(pass) scans a non-empty set of tracked Rust sources [3.10ms]
(pass) the patterns recognize the spellings they claim to [37.98ms]
(pass) a file is scanned with comments stripped and hits attributed to their lines [7.49ms]
(pass) an allowlist entry exempts only its documented spelling, and only that many of it [6.59ms]
(pass) no method hands its own receiver to destroy/deinit/finalize [2.38ms]
(pass) allowlisted files still carry exactly their documented sites [14.30ms]

 6 pass
 0 fail
 8 expect() calls
Ran 6 tests across 1 file. [39.36s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1026ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m   Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[92m   Compiling^[[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
^[[1m^[[92m   Compiling^[[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
^[[1m^[[92m   Compiling^[[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
^[[1m^[[92m   Compiling^[[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
^[[1m^[[92m   Compiling^[[0m bun_brotli 
... (truncated)
diff hotspot
src/runtime/dispatch.rs                            |  16 +-
 src/runtime/node/node_fs.rs                        | 147 ++++------
 .../source-lints/self-receiver-teardown.test.ts    | 321 +++++++++++++++++++++
 3 files changed, 387 insertions(+), 97 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/runtime/dispatch.rs                                       8      8      0
src/runtime/node/node_fs.rs                                  23     40      0
…st/internal/source-lints/self-receiver-teardown.test.ts      6     15      0
Original description

Problem

Two JS-thread completion methods in src/runtime/node/node_fs.rs take &mut self and free the Box that contains self before returning:

  • UVFSRequest::run_from_js_thread(&mut self) (Windows: the libuv-backed open/close/read/write/readv/writev/statfs promises) arms scopeguard::guard(ptr::from_mut(self), |p| Self::destroy(p)), so the free runs in the epilogue while the receiver is still live.
  • NewAsyncCpTask::run_from_js_thread(&mut self) (fs.cp / fs.promises.cp on every platform, and the shell's cp builtin through NewAsyncCpTask<true>) calls Self::destroy(ptr::from_mut(self)) inline on both of its completion paths. The shell's mini-loop path added one more &mut frame (run_from_js_thread_mini), and the two dispatch arms in src/runtime/dispatch.rs formed the &mut with (*task.ptr.cast::<AsyncCpTask>()).run_from_js_thread().

destroy is an unconditional heap::take. A reference argument is protected for the duration of the call it was passed to, and deallocating protected memory is undefined behaviour under both aliasing models regardless of whether the reference is touched again afterwards: Stacked Borrows reports it as deallocating while item is strongly protected, Tree Borrows (what bun run rust:miri uses) as the strongly protected tag disallows deallocations, pointing at the &mut self receiver. Spelling the receiver as a raw pointer at the call site does not change that; the protected argument is the method's own receiver. No crash is known from this.

Fix

The completions own their allocation instead of freeing it from behind a reference (RAII, as suggested in review):

  • Both run_from_js_threads take self: Box<Self>. The dispatch arms (and the Windows __fs_run table) reclaim the box with heap::take(task.ptr) and call it, the same shape as the StatWatcherTimerUpdate / AsyncModule / ValkeyDeferredClose arms next to them; the shell's mini-loop thunk does the same, so run_from_js_thread_mini goes away (it was only reachable for the shell specialization, whose result is always Ok, which the thunk now asserts). The box drops on every return path, including the two Err returns from the conversion helpers that previously leaked the task.
  • The keep-alive unref that destroy() did moves into Drop impls on the two task types, so destroy() is deleted and Taskable::release_unrun becomes heap::destroy. Field drops (promise handle, protected arguments) run in the same order as before, after the unref.
  • A Box<Self> argument is what the aliasing models (and clippy's boxed_local, suppressed here the same way as at the other reclaim points in the tree) are designed around: a box may be deallocated by the callee that owns it, a reference may not.

The only order-of-operations change is in the cp task, which used to destroy itself before settling the promise (hence its raw *mut JSPromise copy and the comments explaining why the promise outlived destroy); it now drops after settling, like UVFSRequest already did and like the by-value AsyncFSTask::then. Everything still happens inside the same task turn, before microtasks drain. c_void drops out of the file's imports because the removed parameter was its last non-Windows use.

Tests

test/internal/source-lints/self-receiver-teardown.test.ts bans destroy(..) / deinit(..) / finalize(..) applied to a pointer spelled from self (ptr::from_mut(self), from_mut(&mut *self), self as *mut _, &raw mut *self, addr_of_mut!(*self), NonNull::from(self), optionally parenthesized or .cast()ed, turbofish allowed), and the deferred forms scopeguard::guard(<that pointer>, |p| .. destroy(p)) and scopeguard::guard(<that pointer>, Self::destroy); the first of those is the shape UVFSRequest had and a direct-call pattern cannot see it, the second is what clippy's redundant_closure turns the first into when the callee is a safe fn. The per-file step is one scanText() (strip full-line comments, match, attribute lines); the ~50 positive and negative snippets go through it, and a small fixture (the three shapes main had, behind a prose mention and with a SAFETY comment inside the guard's closure) pins its exact {line, text} output, so the pipeline stays exercised after the allowlist below has emptied. Against main it reports exactly

src/runtime/node/node_fs.rs:948:  scopeguard::guard(core::ptr::from_mut(self), |p| unsafe { Self::destroy(p)
src/runtime/node/node_fs.rs:1709: Self::destroy(std::ptr::from_mut::<Self>(self))
src/runtime/node/node_fs.rs:1750: Self::destroy(std::ptr::from_mut::<Self>(self))

and nothing else beyond the files carrying the same shape, which are allowlisted by exact spelling and count (any other spelling in those files is still reported) so each conversion deletes its entry: src/install/lifecycle_script_runner.rs (5, #37551), src/bundler/ThreadPool.rs (1, #37685), src/runtime/webcore/blob/copy_file.rs (2) and read_file.rs (1, both #37705). Refcount releases and the heap::take / Box::from_raw primitives are documented as out of scope (#37672 covers the latter).

#37685 and #37705 carry a copy of this lint at the same path with their own file's sites allowlisted and this file's three listed against this PR. The identical path is deliberate: whichever PR lands second gets a conflict on the file instead of landing a stale ratchet entry on main, and resolves it by keeping one copy and deleting the entries for the conversions that have landed. The copies have been kept in step (this one currently has the fn-value guard form, the fixture and the spelling-bound allowlist on top of the shared regexes).

Verification

Debug (ASAN) build on Linux: test/js/node/fs/cp.test.ts and cp-symlink-target.test.ts (49 pass), the 36 upstream test-fs-cp-async-* / test-fs-cp-promises-* scripts under test/js/node/test/parallel (all pass), fs.promises.cp keeping the process alive until it settles and the process exiting afterwards (the Drop unref), and, since the shell cp builtin is off on POSIX without BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1, an ad hoc run with it on: 20 recursive $\cp -R`copies plus the error and-vpaths from JS (the JS event loop arm), and recursive, error,-vand&&-chained copies through bun exec(the mini event loop thunk).bun test test/internal/source-lints/(18 files, 82 tests) passes, and the new lint fails againstmain's node_fs.rs as shown above. On a Windows debug build of the same head: test/js/node/fs/promises.test.js and cp.test.ts (74 pass), the readv/writev/statfs/promises subset of fs.test.ts (98 pass; the one failure, fs/promises > writeFile, fails identically on mainwhen the file is run with that filter because it relies on a directory an earlier test creates), and test/js/bun/shell/commands/cp.test.ts (30 pass, which on Windows runs thecpbuiltin throughNewAsyncCpTaskon both the JS loop and, in the(exec)variants, the mini loop).cargo check -p bun_runtime --target x86_64-pc-windows-msvcalso passes;cargo clippy -p bun_runtimeandcargo fmt --check` are clean.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change transfers async filesystem and copy task ownership through boxed allocations. Completion paths now use Drop and direct heap reclamation. A Rust source lint detects receiver-pointer teardown patterns and enforces documented exceptions.

Async task ownership

Layer / File(s) Summary
Dispatch ownership
src/runtime/dispatch.rs
Task dispatch uses heap::take before running async copy and Windows filesystem tasks.
UV filesystem ownership
src/runtime/node/node_fs.rs
UVFSRequest uses boxed execution, Windows cleanup, and direct heap destruction for unrun requests.
Async copy ownership
src/runtime/node/node_fs.rs
Copy completion takes boxed tasks, manages keep-alive cleanup through Drop, and settles promises without explicit destruction.
Receiver teardown lint
test/internal/source-lints/self-receiver-teardown.test.ts
The new lint scans tracked Rust sources, applies an exception allowlist, and tests teardown pattern detection and line attribution.

Suggested reviewers: jarred-sumner, cirospaciari, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the primary change: completion methods now own and reclaim their task boxes instead of freeing them through an &mut self reference.
Description check ✅ Passed The description explains the problem, fix, background, and verification in detail, although it uses equivalent headings instead of the template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced as a source-level contract bug (no crash; the three sites are the ones the lint in test/internal/source-lints/self-receiver-teardown.test.ts reports against main, see the description). Fix is in this PR, reworked after review so the completions own their Box<Self>; head 5900596 (the Rust side is unchanged since d5b0628). Runtime paths verified on the Linux debug (ASAN) build and on a native Windows debug build.

CI: Buildkite build 92741 (same Rust code) passed 190/190. The latest build, 93199, has every test lane green (179 of 181 jobs passed, the rest were test-level flakes that passed on retry); it shows as failed only because the two macOS 26 aarch64 test jobs expired waiting for an agent, so it is infrastructure, not this change. Build 93176 before it had one Windows failure in test/bake/deinitialization.test.ts (an exit-time crash in dev-server code this PR does not touch, absent on the re-run); that has been reported for main triage separately. Ready for re-review.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use an RAII type instead?

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Yes, that is cleaner. Reworking it so the completion reclaims the Box at the entry point (heap::take(task.ptr) in the dispatch arm, like the StatWatcherTimerUpdate / AsyncModule arms already do) and run_from_js_thread takes self: Box<Self>; the keep-alive unref moves into a Drop impl on each task type, so destroy() and the scope guard go away and every return path frees through the Box. Will push shortly.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it reworks unsafe teardown ordering in NewAsyncCpTask::run_from_js_thread (destroy now runs after resolve/reject via scopeguard, where it previously ran before) and touches the hot task-dispatch path for both platforms, a human look would still be worthwhile.

What was reviewed:

  • Drop order in both rewritten bodies — _dispatch, promise, and task all last-use before _deinit frees *this; tracker.dispatch takes self by value so DispatchScope holds no borrow into the task.
  • JSPromiseStrong::get(&self) returns a JS-heap &mut JSPromise (not a pointer into *this), so holding it across the guard is fine now that the strong root outlives the settle.
  • The mini-loop thunk's let _ = run_from_js_thread(p): the IS_SHELL branch returns Ok(()) unconditionally after cp_on_finish, so nothing is swallowed.
  • c_void import removal — only a comment reference remains; the Windows .cast() infers from uv_fs_t.data.
Extended reasoning...

Overview

Converts UVFSRequest::run_from_js_thread (Windows libuv fs ops) and NewAsyncCpTask::run_from_js_thread (fs.cp on all platforms + shell cp) from &mut self to unsafe fn(this: *mut Self), so the trailing Self::destroy no longer frees an allocation while a protected reference argument is live (Stacked/Tree Borrows UB). The three call sites in dispatch.rs and the Windows __fs_run macro table now pass cast_ptr! through instead of forming &mut. run_from_js_thread_mini is deleted and its body inlined into the AnyTaskWithExtraContext callback. A new source-lint (self-receiver-teardown.test.ts) bans the destroy(ptr::from_mut(self)) shape and its scopeguard::guard deferred form, with a ratcheted allowlist for three files being converted separately.

Security risks

None. This is an aliasing-model correctness fix in already-unsafe teardown code; it does not introduce new unsafe operations, only re-spells the receiver so the free happens through a raw pointer instead of a protected reference. No user-controlled input handling changes.

Level of scrutiny

High — this is memory-safety-critical unsafe Rust on a hot dispatch path, and the cp-task change reorders destroy relative to resolve/reject. I traced the drop order (locals in reverse declaration: _dispatch → … → _deinit), confirmed AsyncTaskTracker::dispatch takes self by value (so DispatchScope<'_> borrows only global_object, not the task), and confirmed JSPromiseStrong::get returns a JS-heap cell reference so the strong root staying alive through settle is the correct direction. The reordering matches UVFSRequest's existing order and the by-value AsyncFSTask::then, and everything happens within one task turn before microtasks drain, so the KeepAlive unref moving after settle is not observable. The pattern matches CompressionStream::run_from_js_thread, DuplexUpgradeContext::run_event, and PostgresSQLConnection::deinit already in the tree.

Other factors

The PR description documents extensive verification (ASAN debug on Linux, 49 cp tests + 36 upstream Node parallel scripts, shell cp under both event-loop arms with the experimental flag on, Windows cargo check, clippy, fmt, and the new lint failing on main at exactly the three fixed sites). The lint test includes positive/negative pattern fixtures and a non-vacuous scan-count guard. No prior reviewer comments to address. Deferring rather than approving because the destroy-after-settle reordering is a real behavioral change in unsafe teardown code that a maintainer should sign off on, even though I found nothing wrong with it.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Aug 12th, 2026

❌ @robobun, your commit 5900596 has some failures in Build #93199 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37693

That installs a local version of the PR into your bun-37693 executable, so you can run:

bun-37693 --bun

Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed in f24763c (plus two comment-trim commits, head d5b0628): run_from_js_thread takes self: Box<Self> on both task types, the dispatch arms and the shell's mini-loop thunk reclaim the box with heap::take and call it, and the keep-alive unref lives in Drop impls, so destroy() and the scope guard are gone. Description updated to match.

@robobun robobun changed the title node:fs: free UVFSRequest and AsyncCpTask through the task pointer, not &mut self node:fs: let the fs completions own their task box instead of freeing it through &mut self Aug 12, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The self: Box<Self> + Drop rework looks correct and matches the neighboring heap::take dispatch arms, but since it changes ownership/drop semantics on the fs.cp completion path (all platforms) and the Windows libuv fs request path, and reorders destroy relative to promise settlement, a human look would still be worthwhile.

What was reviewed:

  • KeepAlive::unref is a no-op when status != Active, so the new Drop impls are safe on an un-ref_'d default and cannot double-unref.
  • release_unrun now goes through heap::destroy, which runs Drop, so the keep-alive and ThreadSafe<args::Cp> protects are still released on VM teardown.
  • The two Err(e) => return promise.reject(...) early returns in the old cp task never called destroy; the Box now frees on those paths too.
  • The mini-loop thunk's let _ = task.run_from_js_thread() is only reachable for IS_SHELL, where the body returns Ok(()) before touching the promise.
Extended reasoning...

Overview

Converts UVFSRequest::run_from_js_thread (Windows libuv-backed open/close/read/write/readv/writev/statfs) and NewAsyncCpTask::run_from_js_thread (fs.cp on all platforms, plus the shell cp builtin) from &mut self methods that free their own receiver via Self::destroy(ptr::from_mut(self)) to self: Box<Self> methods where the caller reclaims the box (heap::take) and Drop handles the keep-alive unref. Touches src/runtime/dispatch.rs (three dispatch arms + the Windows __fs_run table), src/runtime/node/node_fs.rs (~100 lines of native diff: two Drop impls added, two destroy() fns and run_from_js_thread_mini deleted, cp completion body simplified), and adds a 224-line source-lint test with a ratcheted allowlist for three files with the same shape still in flight.

Security risks

None identified. This is an internal ownership/aliasing-model refactor; no user-controlled input handling, parsing, or trust-boundary code changes.

Level of scrutiny

High. This is memory-safety-critical native code on a hot fs completion path. The change is well-motivated (freeing through a protected &mut self receiver is UB under Stacked/Tree Borrows) and the Box<Self> + Drop shape matches the established pattern in neighboring dispatch arms (AsyncModule, StatWatcherTimerUpdate, ValkeyDeferredClose). But it adds Drop impls to two types that previously had none, reorders the cp task's destroy relative to promise.resolve/reject (now after, matching UVFSRequest), and removes the raw-*mut JSPromise + opaque_mut dance in favor of holding the JSPromiseStrong borrow across settlement. Each of these is individually justified in the PR description and I traced them through, but the composition is subtle enough that a maintainer familiar with the JSC promise/keep-alive lifecycle should confirm.

Other factors

  • Verified KeepAlive::unref early-returns on Inactive/Done (src/io/keep_alive.rs:43), so the new Drop cannot underflow the loop refcount if a task is somehow dropped without ref_() having run, and Drop on the shell specialization is gated on !IS_SHELL matching the old destroy.
  • release_unrun for both types now calls bun_core::heap::destroy (= drop(Box::from_raw)) instead of the deleted Self::destroy; Drop runs, so the keep-alive unref and field drops (including ThreadSafe<args::Cp>'s unprotect()) still happen on VM-teardown release.
  • The old cp run_from_js_thread leaked the task on the two to_js_with_async_stack/fs_to_js Err early-return paths (they returned before Self::destroy); the Box now drops on every return.
  • The PR description's "Fix" section still describes the earlier this: *mut Self + scopeguard revision; the actual code is the self: Box<Self> rework from the follow-up comment. Not blocking, but worth syncing before merge.
  • The comment-cop bot flagged long comments on earlier commits; the two most recent commits (trim the completion comments, one-line completion comments) appear to have addressed those.
  • The new source-lint test's regex + allowlist approach mirrors sibling lints in test/internal/source-lints/; it self-tests its patterns against positive/negative fixtures and guards against a vacuous scan.

robobun added a commit that referenced this pull request Aug 12, 2026
…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 added a commit that referenced this pull request Aug 12, 2026
### 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>
robobun added a commit that referenced this pull request Aug 12, 2026
…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.
…ot &mut self

UVFSRequest::run_from_js_thread and NewAsyncCpTask::run_from_js_thread took
&mut self and freed the Box containing self before returning (a scope guard
over ptr::from_mut(self) in the first, Self::destroy(ptr::from_mut(self)) in
the second). A reference argument is protected for the duration of the call,
so deallocating through it is undefined behaviour under both aliasing models.

Both now take this: *mut Self. The body moves the result out, works through
a shared borrow for the rest, and a guard over the raw pointer frees the task
after the promise is settled (which also lets the cp task drop its raw
JSPromise pointer and covers its early returns). The shell's mini-loop thunk
calls the pointer-taking entry point directly, and the dispatch arms pass
task.ptr through instead of forming &mut.

Adds a source lint banning destroy/deinit (direct or via scopeguard) applied
to a pointer spelled from self, with the remaining in-flight conversions
allowlisted at their current counts.
run_from_js_thread on UVFSRequest and NewAsyncCpTask takes self: Box<Self>;
the dispatch arms and the shell's mini-loop thunk reclaim the leaked box with
heap::take and call it, so every return path frees the task when the box
drops. The keep-alive unref moves into Drop impls, which replaces destroy()
(release_unrun is heap::destroy) and removes the scope guards.
Route the example snippets through the same scanText() the file scan uses
and add a fixture pinning comment stripping and line attribution, so the
pipeline stays exercised once the allowlist empties. Match the fn-value guard
form (scopeguard::guard(ptr, Self::destroy)), finalize as a callee, nested
turbofish, reborrowed and parenthesized receivers. Allowlist read_file.rs,
which the finalize spelling now reaches.
@robobun
robobun force-pushed the farm/20391523/node-fs-destroy-via-raw-ptr branch from d5b0628 to 7e23a06 Compare August 12, 2026 12:46
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review of the lint turned up three things, fixed in 7e23a06 (branch also rebased onto current main): the example snippets bypassed the comment-stripping step that the real scan depends on (now everything goes through one scanText(), plus a fixture pinning its exact output so the pipeline stays covered once the allowlist empties); the fn-value guard spelling scopeguard::guard(ptr::from_mut(self), Self::destroy), which is what clippy's redundant_closure produces for a safe callee, was not matched; and the turbofish patterns stopped at the first >. While at it the regexes were brought in step with the copies in #37685 / #37705 (finalize as a callee, reborrowed and parenthesized receivers), which adds read_file.rs to the allowlist until #37705 lands. No change to the Rust side since the last push.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 `@test/internal/source-lints/self-receiver-teardown.test.ts`:
- Around line 103-107: Update the DEFERRED regex to match only complete
scopeguard callbacks, requiring the closure and scopeguard::guard() delimiters
after the teardown call so callbacks with additional statements are excluded.
Add the multi-call callback described in the comment to the allowed regression
cases.
- Around line 154-156: Update the allowlist handling around ALLOW and the hits
loop to associate each exception with its documented teardown-site text or
pattern instead of skipping the first N matches. Reject any hit that does not
match an allowlisted site, while retaining the existing count assertion as a
ratchet.
🪄 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: 36b80819-05f5-4a78-ad24-7d533a86a2f0

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and 7e23a06.

📒 Files selected for processing (3)
  • src/runtime/dispatch.rs
  • src/runtime/node/node_fs.rs
  • test/internal/source-lints/self-receiver-teardown.test.ts

Comment thread test/internal/source-lints/self-receiver-teardown.test.ts
Comment thread test/internal/source-lints/self-receiver-teardown.test.ts Outdated
…spelling

Each entry now names the exact match text it exempts as well as the count,
so a different spelling appearing in an allowlisted file is reported even
while the entry exists; the triage step is a function with its own test.
Also document that a guard closure is matched on its first expression.
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the two review points about the lint (both in 7b62972): allowlist entries now carry the exact spelling they exempt as well as the count, so a different spelling in an allowlisted file is reported while the entry exists, and the triage step has its own test. For the guard pattern I went the other way from the suggestion: a guard closure that frees the receiver and then does more is still the bug, so it stays matched (added as a positive example) and the header now says precisely what is matched, the closure's first expression.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it reworks task-box ownership and Drop ordering across dispatch.rs, both event-loop paths, and the Windows-only UVFSRequest completions — with a stated order-of-operations change for the cp task — a maintainer look is still worthwhile.

What was reviewed:

  • The new Drop impls vs. the removed destroy(): keep-alive unref now runs after promise settlement for cp (same tick, before microtasks drain) and on every return path including the two Err early returns that previously leaked.
  • All call sites of run_from_js_thread updated in step: the two dispatch.rs arms, the Windows __fs_run macro, the mini-loop thunk, and release_unrun → heap::destroy.
  • The new source lint's regexes and allowlist against the documented spellings; the fixture and triage tests keep it exercised once the allowlist empties.
Extended reasoning...

Overview

The PR converts two &mut self completion methods in src/runtime/node/node_fs.rs (UVFSRequest::run_from_js_thread on Windows, NewAsyncCpTask::run_from_js_thread on all platforms) to self: Box<Self>, moves the keep-alive unref into Drop impls, deletes the destroy() methods and the scopeguard, and updates every entry point (dispatch.rs arms, the Windows for_each_fs_uv_op! macro, the shell mini-loop thunk, Taskable::release_unrun) to heap::take the box first. It also adds a ~320-line source-lint test that bans destroy/deinit/finalize(ptr::from_mut(self)) and its scopeguard forms tree-wide, with a spelling-bound allowlist for the four files still carrying the pattern.

Security risks

None. This is an internal ownership refactor of async fs task teardown; no user-facing input handling, no auth/crypto/permissions, no new external inputs. The lint test spawns git ls-tree on the repo root, which is benign.

Level of scrutiny

High. Memory safety is the repo's most-scrutinized review category, and this change touches exactly the concerns REVIEW.md calls out: pairing acquisition with release at every exit (now via Drop), release ordering (cp now drops after settling the promise instead of before — the PR description explains why this is safe within one task turn), and reference-count balance across success/error/release_unrun paths. The Windows UVFSRequest path and the __fs_run dispatch macro cannot be exercised on Linux CI alone; the author reports native Windows debug-build verification, which a maintainer should confirm.

Other factors

  • The two CodeRabbit review points on the lint (guard-closure completeness, allowlist bound to spelling) were addressed in the head commit and are marked resolved.
  • The comment-cop bot's flags were addressed (comments trimmed).
  • The bug-hunting system found nothing; I checked that KeepAlive::unref in Drop cannot run before ref_() (the box is leaked before ref_() and only reclaimed at dispatch/release_unrun), that the mini-loop thunk's debug_assert!(IS_SHELL) matches the only poster of that path, and that run_from_js_thread_mini's removal leaves no dangling caller.
  • Deferring rather than approving because this is a lifecycle/ordering change in the event-loop dispatcher and fs task teardown across two platforms — the class of change the repo review guide reserves for maintainer sign-off.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes ownership and drop ordering for async fs task completions (unsafe reclaim points, new Drop impls that unref the event loop, Windows-only UVFSRequest paths), a human look would still be worthwhile.

What was reviewed:

  • The Box<Self> reclaim shape matches the neighboring StatWatcherTimerUpdate/AsyncModule/ValkeyDeferredClose arms in dispatch.rs; release_unrun → heap::destroy still runs the same unref via the new Drop.
  • The cp task's drop-after-settle ordering now matches UVFSRequest (same self.promise.get() pattern at node_fs.rs:957); the removed early destroy no longer forces the raw *mut JSPromise copy, and the two Err(e) returns that used to leak the task now free it.
  • The mini-loop thunk's discarded result is safe: the IS_SHELL branch of run_from_js_thread unconditionally returns Ok(()) after cp_on_finish.
  • The lint's spelling-bound allowlist and closed-callback matching address the two CodeRabbit points; positive/negative cases and the fixture cover comment-stripping and line attribution.
Extended reasoning...

Overview

Converts two async fs completion methods from &mut self + explicit Self::destroy(ptr::from_mut(self)) to self: Box<Self> with the keep-alive unref in Drop, fixing a Stacked/Tree Borrows protector violation (deallocating while the receiver reference is protected). Touches src/runtime/dispatch.rs (four arms + the Windows __fs_run table now heap::take the box), src/runtime/node/node_fs.rs (UVFSRequest on Windows and NewAsyncCpTask<IS_SHELL> on all platforms; destroy() and run_from_js_thread_mini deleted), and adds test/internal/source-lints/self-receiver-teardown.test.ts (regex ratchet with a spelling-bound allowlist for four in-flight sibling conversions).

Security risks

None. No user-facing input handling, parsing, or auth surface changes; this is an internal ownership refactor of task-completion lifetime.

Level of scrutiny

High. Per REVIEW.md this is the most-blocked category — native memory safety: unsafe box reclaim, new Drop impls that touch the event loop keep-alive, a change to when the cp task frees relative to promise settlement, and Windows-only libuv request paths that only Windows CI exercises. The change is well-argued and matches established shapes in the same file, but it is not mechanical.

Other factors

  • The PR is part of a coordinated set (#37551, #37685, #37705) that intentionally share the lint file so whichever lands second conflicts rather than landing a stale allowlist entry; a maintainer should be aware of the merge-order plan.
  • All prior review threads (comment-cop paragraph-comment nags, two CodeRabbit points on the lint's allowlist and guard-callback matching) are marked resolved with follow-up commits; the Rust side is unchanged since d5b0628 which the author reports built green (Buildkite #92741).
  • I checked that the new cp run_from_js_thread mirrors the existing UVFSRequest version (same promise.get()/resolve/reject shape), that the Drop unref is the exact body destroy() had, and that the shell mini-loop path's discarded Result is provably Ok for IS_SHELL. No issues found, but a maintainer sign-off on the drop-ordering change and the Windows path is appropriate.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants