napi: drive a threadsafe function's JS-thread side off its pointer, not &mut self - #37762
napi: drive a threadsafe function's JS-thread side off its pointer, not &mut self#37762robobun wants to merge 4 commits into
Conversation
…ot &mut self dispatch_one, call, maybe_queue_finalizer and env_teardown took the ThreadSafeFunction as &mut self while addon threads were, by design, taking its lock and writing its counters (and, after env_teardown's last phase, possibly freeing it), and while the callback run by call could re-enter the same object through napi_unref_threadsafe_function. maybe_queue_finalizer also posted a pointer made from that receiver, which on_dispatch's later state transitions invalidate before destroy frees through it. They now take this: *mut ThreadSafeFunction and borrow one field per statement, the finalize task carries the dispatched pointer itself, and napi_ref/unref_threadsafe_function (and create/destroy) touch poll_ref directly instead of going through &mut self wrappers. loop_mut, ref_ and unref are gone. Behaviour is unchanged. A source lint holds the shape: no method of impl ThreadSafeFunction takes a reference receiver and neither the methods nor the *threadsafe_function* entry points reborrow the whole object from the pointer, with the addon-thread methods being converted in #37741 ratcheted by name.
|
Warning Review limit reached
Next review available in: 24 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 (2)
Comment |
|
Status: ready for review, at 4ddfdae. Reproduced as the Miri reduction in the PR body (all four Review so far: the automated review found no bugs and asked for human eyes on
CI: two builds so far, 92891 on the first revision and 93276 on the current head (4ddfdae). Both are 192 of 194 jobs passed, every build lane, the asan lane and the source lints included; on both, the only non-passing jobs are the two The addon-thread half of the same object is #37741; the two branches' napi_body.rs merge without conflicts, and whichever lands second empties the other's ratchet entries as described in the body. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it restructures the JS-thread dispatch/teardown paths of ThreadSafeFunction — unsafe raw-pointer code with cross-thread access and lock/guard-drop ordering that determines who frees the allocation — a human look would still be worthwhile, particularly on the reshaped call() and env_teardown() phase 3.
What was reviewed:
maybe_queue_finalizer: theprev == Closedearly-return is equivalent to the oldClosing || NotClosingmatch (ClosingStatehas exactly three variants);has_queued_finalizerordering and theevent_loop == Noneearly return are preserved.call: traced bothTsfnCallback::Jsand::Cpaths — same operations in the same order;_dispatchand_hsscope-guard lifetimes match the old arms;BackRefandAsyncTaskTrackerareCopyso the field reads move nothing.dispatch_one/env_teardown: lock-guard scopes (labeled block →unsafeblock) drop at the same points;thread_countis still read under the phase-3 lock and nothing touches*thisafter the guard drops.- Confirmed
ref_/unref/loop_mutinlining is behavior-identical at all four call sites.
Extended reasoning...
Overview
This PR converts the JS-thread-side methods of ThreadSafeFunction in src/runtime/napi/napi_body.rs from &mut self receivers to this: *mut Self associated functions: dispatch_one, call, maybe_queue_finalizer, env_teardown. It deletes the loop_mut/ref_/unref wrappers and inlines their single-field access at the four call sites (destroy, napi_create/ref/unref_threadsafe_function). It adds a source-lint test that ratchets the remaining &self receivers (the addon-thread half, converted separately in #37741) and forbids whole-object reborrows of the handle. ~200 lines of native diff plus a 268-line lint test.
Security risks
None user-facing. This is an aliasing-model soundness fix (Tree Borrows / Stacked Borrows protector violations, noalias on &mut self that addon threads write through concurrently). No new attack surface, no input parsing, no privilege changes. The risk is a subtle behavior regression in a memory-safety-critical path — wrong lock-guard scope, dropped scope guard, or a field read that used to happen under the lock now happening outside it — which would show up as UAF or hang in native addons.
Level of scrutiny
High. This is unsafe Rust in the NAPI threadsafe-function implementation — raw pointers, a mutex whose guard-drop point determines whether an addon thread may concurrently free the object, condvar signaling, and a user callback that can synchronously re-enter the object. The call() body was restructured from a two-arm match on a borrowed field into destructure + let Some(call_js) else, which is the largest shape change and the place a subtle ordering difference would hide. env_teardown phase 3 wraps the critical section in an unsafe {} block whose tail expression is the return value; the _g guard still drops after thread_count is read and before anything else touches *this, which is the invariant that lets an addon thread free the orphan.
Other factors
The change follows an established pattern in this codebase (six sibling PRs cited, plus #37741 for the other half of this same struct), and the PR description is unusually thorough with a Miri reduction. Behavior coverage relies on the existing test/napi/napi.test.ts and node-api addon suites, which the description reports passing on debug+ASAN. I traced each converted function against its predecessor and found the lock/condvar/state-transition/guard-drop ordering preserved; I verified ClosingState has exactly three variants so the inverted condition in maybe_queue_finalizer is equivalent, and that BackRef<_, Mut> and AsyncTaskTracker are Copy so the by-value field reads are sound. That said, the PR itself flags textual conflicts with three other open PRs (#37741, #36831, #36801) touching the same functions, and the correctness of concurrent raw-pointer code in a finalize/free path is exactly where a maintainer's second pair of eyes adds the most value. Deferring rather than auto-approving.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #36831, though the two overlap textually (the PR body already lists it under the conflicts). #36831 fixes a deadlock when a callback blocks in a nested event loop (#36828): it adds in-flight dispatch accounting and a backup dispatch, and changing the signatures of |
|
Updated 1:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit 4ddfdae has some failures in 🧪 To try this PR locally: bunx bun-pr 37762That installs a local version of the PR into your bun-37762 --bun |
…ot just ban two spellings of the old one The lint only rejected self receivers and `&mut *this`-style reborrows, so a function could take the object as `this: &mut ThreadSafeFunction` (the same whole-object claim under another name) and pass. It now parses each function's parameter list: the converted functions must take `this: *mut`, any other parameter whose type names the object must be a raw pointer, a move or a Box, and the entry-point pattern accepts qualified `extern "C"` items and lifetime-annotated receivers. The header says what stays out of scope and when the `&self` half of the ban should be lifted.
|
Three follow-up commits since the first round of review:
The description also gained a section on why this is raw pointers rather than |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it restructures the concurrent JS-thread paths of ThreadSafeFunction (lock-guard scope in env_teardown phase 3, copy-out ordering in call before user code runs), a human look at those two invariants would still be worthwhile.
Checked: maybe_queue_finalizer's inverted branch is equivalent (ClosingState has exactly three variants); BackRef<EventLoop, Mut> is Copy, so (*this).event_loop reads a copy, not a borrow; env_teardown phase 3's guard now drops at the unsafe {} block end, which is still after the thread_count read and the last field write; the deleted ref_/unref/loop_mut wrappers have all four call sites updated in this diff.
Extended reasoning...
Overview
This PR converts the JS-thread-side methods of ThreadSafeFunction in src/runtime/napi/napi_body.rs from &mut self receivers to this: *mut ThreadSafeFunction associated functions: dispatch_one, call, maybe_queue_finalizer, env_teardown. It deletes the thin wrappers loop_mut, ref_, unref and inlines their bodies at the four call sites (destroy, napi_create_threadsafe_function, napi_ref/unref_threadsafe_function). It also adds a 400-line source-lint test that parses the impl block and *threadsafe_function* entry points to enforce the pointer-receiver shape and forbid whole-object reborrows, with a ratchet list for the addon-thread methods that #37741 converts.
Security risks
None. This is an internal refactor of receiver shapes to satisfy Rust's aliasing model (Stacked/Tree Borrows); no user-facing API, input validation, or trust boundary changes.
Level of scrutiny
High. This is the "Native code: memory safety" category from REVIEW.md — the most-blocked class. ThreadSafeFunction is reached concurrently from addon threads and re-entrantly from user callbacks; the refactor is not purely mechanical (call's two match arms became a destructure + early-return; env_teardown phase 3's lock guard moved from function-tail scope into a block; maybe_queue_finalizer's branch was inverted). Each of those preserves behavior on inspection, but the ordering invariants (what is read out of the object before user code can re-enter, and where the phase-3 guard drops relative to env_teardown_done being published) are exactly the kind of thing a human maintainer of this file should confirm.
Other factors
- The bug hunter found nothing; CI (192/194) and the ASAN napi suites passed per the author's status comment.
- I verified
ClosingStatehas exactly {NotClosing, Closing, Closed}, soif prev == Closed { return }is equivalent to the oldmatch prev { Closing | NotClosing => ..., _ => {} }. - I verified
BackRefisCopy(src/ptr/lib.rs:93-96), so the newunsafe { (*this).event_loop }copies theOption<BackRef<..>>out rather than borrowing into the object acrossdrain_microtasks/enqueue_task. - The comment-cop bot fired three times on paragraph-length comments; follow-up commits a790170 and 4ddfdae shortened/removed them.
- This PR overlaps textually with open PRs #37741 / #36831 / #36801 and encodes a merge-order-dependent ratchet in the new lint — a maintainer should be aware of the coordination when landing.
Given the concurrency-critical nature and the non-mechanical restructuring, deferring rather than auto-approving.
Problem
ThreadSafeFunction(dispatching queued calls, ref/unref, env teardown) ran as&mut selfmethods on the shared object.&mut selfargument claims the whole object for the length of the call, but addon threads take its lock and bump its counters the whole time the user's callback runs, the callback can unref the same function re-entrantly (test_issue_11949), and after teardown's last phase an addon thread may free it while the method is still returning.destroyfrees through it.protected tags must never be Disabled) and Stacked Borrows, and passes in this PR's shape. No crash is known and the compiler does not exploit it today. Same class as fetch: release the FetchTasklet through its raw pointer, not a &mut receiver #37703, bundler: make Worker::deinit_soon take the worker pointer instead of &mut self #37685, node:fs: let the fs completions own their task box instead of freeing it through &mut self #37693, blob(windows): free CopyFileWindows and ReadFileUV through the task pointer, not under &mut self #37705, bundler: hand a finished Bun.build back through its pointer, not a &mut receiver #37723, bundler: allocate, link and post a plugin request through one pointer instead of a &mut receiver #37732; the mirror image of napi: drive a threadsafe function's addon-thread entry points off its pointer, not &mut self #37741, which converts the addon-thread half.Fix
this: *mut ThreadSafeFunctionand borrow one field per statement, aspush/releasealready do; theloop_mut,ref_andunrefwrappers go away and their callers touchpoll_refor a copiedBackRefdirectly.callcopies its inputs out first; the finalize task carries the pointer the object was dispatched with, the onedestroyfrees). The lock is taken and dropped at the same points as before and the finalize / free decisions are unchanged.napi_acquire_threadsafe_function; whichever PR lands second drops the other's allowlist entry.main(listing the seven old receivers) and passes here, but it checks signatures only; there is no runtime repro since nothing crashes. The existing napi addon tests, including unref from inside the callback and worker teardown, pass on the ASAN build; clippy and fmt are clean.Background
napi_call_threadsafe_function) that the JS thread later runs; addon threads acquire and release it, and the last release, the JS-thread finalizer, or env teardown frees it. One allocation is shared between the JS thread and any number of addon threads for its whole life.&mut self(or&self) argument is a promise, passed to LLVM asnoalias, that nothing else reads or writes the object while the call is on the stack; holding a lock around each field access does not change that. Going through(*this).fieldclaims only that field for that statement.bun run rust:miriuses) and Stacked Borrows are its two models. A "protected tag" in its errors is the reference argument of a call that has not returned yet.poll_refis the object's keep-the-event-loop-alive handle and is touched only by the JS thread (ref/unref are JS-thread-only in Node as well), so borrowing that one field is fine while addon threads are in the rest of the object.[review] gate passed · iteration 0 · 2 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
Original description
Problem
The JS-thread side of
ThreadSafeFunction(src/runtime/napi/napi_body.rs) still went through whole-object receivers:on_dispatch(this)called(*this).dispatch_one(&mut self), which calledcall(&mut self)andmaybe_queue_finalizer(&mut self), andnapi_internal_threadsafe_function_env_teardowncalled(*this).env_teardown(&mut self).napi_ref_threadsafe_function/napi_unref_threadsafe_functionwent throughref_(&mut self)/unref(&mut self).A reference argument claims the object for the duration of the call (it is what rustc's
noaliason the argument promises LLVM), and this object is shared for its whole life:dispatch_onetakeslockand then runs the user's callback incall, for as long as that takes. Anynapi_call_threadsafe_function/napi_acquire_threadsafe_function/napi_release_threadsafe_functionarriving from an addon thread in that time does a read-modify-write on the lock word and then onthread_count/queue.count, i.e. writes inside the range the protected&mut selfcovers. This is the steady state of every threadsafe function with a producer thread, not a race window.call(&mut self)can re-enter the object from the same thread:napi_unref_threadsafe_functionon the TSFN being dispatched (what thetest_issue_11949addon in test/napi does) formed a second&mut Selfunder the protected one.maybe_queue_finalizerpostedlet self_ptr: *mut Self = selfas the finalize task.on_dispatchgoes on using the object through its own pointer afterdispatch_onereturns (theRunning -> IdleCAS), which invalidates that receiver-derived pointer, and the nexton_dispatchthen frees through it indestroy.env_teardowncoordinates with live addon threads throughlockacross its three phases, and its last phase publishesenv_teardown_done: from the moment its guard drops, a thread dropping the last reference frees the allocation, while the&mut selfframe is still returning.A reduction of these four shapes (below) fails under both aliasing models and passes with the shape this PR uses. Tree Borrows, the model
bun run rust:miriuses, reports the first, second and fourth asforeign read access would cause the protected tag <N> (currently Unique) to become Disabled; protected tags must never be Disabled, with<N>pointing at the&mut selfofdispatch_one/env_teardown, and the third as the posted pointer havingstate Disabled which forbids this reborrowwhen it is freed; Stacked Borrows reportswould remove [Unique for <N>] which is strongly protectedandtag does not exist in the borrow stack. No crash is known from any of this; the compiler does not currently exploit it. It is the contract that is wrong, the same class as #37703, #37685, #37693, #37705, #37723 and #37732, and the mirror image of #37741, which converts the addon-thread side of this same object and lists this side as reported separately.Reduction run under Miri
HANDLEis the addon's copy of thenapi_threadsafe_functionpointer;POSTEDis whatmaybe_queue_finalizerput in the finalize task.<scenario>-beforeis the shape onmain,<scenario>-afterthe shape in this PR.-Zmiri-tree-borrows)js-beforeforeign read access would cause the protected tag ... to become Disabled, tag created atfn dispatch_one_before(&mut self, ..)would remove [Unique for <dispatch_one_before>] which is strongly protectedreenter-beforeunref_beforeunref_beforeanddispatch_one_beforefinalize-beforehas state Disabled which forbids this reborrow, created atfn maybe_queue_finalizer_before(&mut self)trying to retag from <posted> for Unique permission ..., but that tag does not exist in the borrow stackteardown-beforeforeign read access would cause the protected tag ... to become Disabled, tag created atfn env_teardown_before(&mut self)would remove [Unique for <env_teardown_before>] which is strongly protected-afterFix
dispatch_one,call,maybe_queue_finalizerandenv_teardowntakethis: *mut ThreadSafeFunctionand borrow one field per statement, the waypush/releasealready do on the other side of the object:(*this).lock.lock_guard()(the guard holds the lock by pointer, so nothing points into the object across the callback except that),(*this).queue.data.read_item()under it, and so on. Concretely:on_dispatchpasses its pointer down:Self::dispatch_one(this, is_first).callcopies what the callback needs out of the object first (the env pointer, theCopytracker, the JS value orcall_jsfunction pointer,ctx), so no borrow of the threadsafe function exists while the microtask drain or the callback runs. The JS and C paths do the same things in the same order as before; they are just no longer two arms of onematchon a borrowed field.maybe_queue_finalizerpostsTask::init(this), the pointeron_dispatchwas dispatched with, which is the onedestroylater frees.env_teardownkeeps its three phases, with the phase 1 and phase 3 critical sections as single blocks;env_teardown_doneis still published, andthread_countstill read, under the lock, and nothing touches the object after the guard drops. Its caller already had the pointer.loop_mut,ref_andunrefare deleted: the two loop users copy theCopyBackRefout of the field and callget_mut()on the copy, andnapi_ref_threadsafe_function,napi_unref_threadsafe_function,napi_create_threadsafe_functionanddestroycallpoll_ref.ref_()/unref()on the field directly, which is all the wrappers did.poll_refis the JS thread's (asnapi_ref/unref_threadsafe_functionare JS-thread-only in Node too), so a borrow of that one field is sound while addon threads are in the rest of the object.Behaviour is unchanged: the lock is taken and dropped at the same points, the condvar signals, state transitions and the finalize / free decisions are the same, and
callruns the same code in the same order.Why raw pointers rather than
&selfplus interior mutabilityThe other way to make this object sound is to change its layout: put
queue.databehind aGuarded, the JS thread's plain fields (poll_ref,callback,env,finalizer_fun,has_queued_finalizer) intoJsCell, and then give everything that does not post or free the object a&selfreceiver, keeping*mutonly foron_dispatch, the two posting functions and the freeing ones. That is the better end state and nothing here is in its way: every function this PR converts would keep working unchanged under that layout (a raw pointer is sound wherever a shared reference is), so it is a later simplification on top of this, not a reversal of it. It is not done here because it rewrites both halves of the object at once (the addon-thread half is #37741, in flight, and #36831 / #36801 are open against the same functions), while this PR is a behaviour-preserving change to one half. With the current layout,&selfis not a sound receiver either (a shared reference coveringqueue.dataand the plain fields is invalidated by the writes the other party makes to them), which is why the lint bans both kinds of reference for now; its header says that the&selfhalf of the ban is what to lift when the layout changes.Relationship to other open PRs
enqueue->push_locked,release_locked,schedule_dispatch,is_closing,acquire); this PR converts the JS-thread methods. The two are independent (each removes its own side's receivers); a three-way merge of the two branches' napi_body.rs against their common base applies cleanly, and the merged file passes the lint below once its two ratchets are emptied. The lint below ratchets napi: drive a threadsafe function's addon-thread entry points off its pointer, not &mut self #37741's five methods and the one&mut *funcinnapi_acquire_threadsafe_functionby name, so when it lands its entries are deleted and both lists are empty; conversely napi: drive a threadsafe function's addon-thread entry points off its pointer, not &mut self #37741'sself-receiver-publishlint allowlistsmaybe_queue_finalizer's post, which this PR removes, so whichever lands second drops that allowlist entry (this PR, if it is the second one).on_dispatch/dispatch_one/callfor other reasons and will conflict with this; napi: keep dispatching threadsafe function calls when a callback blocks in a nested event loop #36831 already movesdispatch_oneandcallto*mut Selfas part of its change, so the shapes are compatible.Tests
test/internal/source-lints/napi-tsfn-receivers.test.tsparses the signature and body of every method of the inherentimpl ThreadSafeFunctionand of every*threadsafe_function*extern "C"entry point under src/runtime/napi/ (brace- and paren-matched, so rustfmt-wrapped parameter lists, generics, lifetimes and qualifiedpub(crate) unsafe extern "C"items are all seen) and asserts four things:on_dispatch,dispatch_one,call,maybe_queue_finalizer,env_teardown, pluspush,release,destroy,free_orphaned, which already had the shape) take the object asthis: *mut ..;Box(so renaming the receiver to athis: &mut ThreadSafeFunctionparameter, orOption<&mut Self>/NonNull<Self>, fails; zero today, so there is no ratchet);&mut *this,&*func,func.as_mut()), withnapi_acquire_threadsafe_function's one site ratcheted.It checks its own parser and patterns against an inline fixture (including the lines it reports), anchors itself on the pinned names and the seven entry points so it cannot pass vacuously if the code moves, and its header lists what it knowingly does not see (a reborrow through a local alias of the handle, and callers outside src/runtime/napi/ such as the dispatch.rs arm, which passes
cast_ptr!). Againstmain's napi_body.rs assertions 1 and 3 fail, the latter listingbeyond the allowed list; with this branch all four pass. I also applied seven single-site regressions to this branch's napi_body.rs (
dispatch_one(this: &mut ThreadSafeFunction)with the caller coercing,env_teardown(this: &mut Self)reached through a local alias,on_dispatch(this: &mut ThreadSafeFunction), an entry point with a&mut ThreadSafeFunctionparameter, a new&selfhelper, a lifetime-annotated&'a selfhelper, and a qualified entry point reborrowing&mut *func); each one fails the lint.The behaviour the converted functions implement is covered by the existing addon tests, all run on the debug (ASAN) build of this branch:
test/napi/napi.test.ts(166 pass; the threadsafe-function ones exercise the empty-queue and after-call finalize paths, abort with and without queued items, blocked producers on a bounded queue, microtask draining between callbacks,napi_unref_threadsafe_functionfrom inside the callback being dispatched (test_issue_11949),napi_ref/unref_threadsafe_functionwith a NULL env, and the worker-teardown paths through all three phases ofenv_teardown, including the ones where an addon thread's later call or release frees the orphan and where the caller frees it), andtest/napi/node-napi-tests/test/node-api/{test_threadsafe_function,test_worker_terminate,test_worker_terminate_finalization,test_env_teardown_gc}/do.test.ts(10 pass;test_threadsafe_function/test.jsis a pre-existing todo, it crashes inuv_thread_create).bun test test/internal/source-lints/(19 files, 88 tests),cargo clippy -p bun_runtime --no-depsandcargo fmt --checkare clean.