Skip to content

napi: mark threadsafe functions closing on env teardown - #33968

Closed
robobun wants to merge 3 commits into
mainfrom
farm/f603132f/napi-tsfn-worker-cleanup
Closed

napi: mark threadsafe functions closing on env teardown#33968
robobun wants to merge 3 commits into
mainfrom
farm/f603132f/napi-tsfn-worker-cleanup

Conversation

@robobun

@robobun robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes test/integration/next-pages/test/next-build.test.ts which has been red on the musl lanes since #31216 landed (builds 71706, 71726, 71800).

Reproduction

next build under bun --bun segfaults in a tokio worker thread:

panic: Segmentation fault at address 0x0
#7  us_wakeup_loop at packages/bun-usockets/src/loop.c:164
#8  wakeup() at src/uws_sys/Loop.rs:239
#9  wakeup() at src/jsc/event_loop.rs:975
#10 enqueue_task_concurrent() at src/jsc/event_loop.rs:990
#11 schedule_dispatch() at src/runtime/napi/napi_body.rs:2653
#12 release() at src/runtime/napi/napi_body.rs:2735
#13 napi_release_threadsafe_function() at src/runtime/napi/napi_body.rs:2858
#14 ... next-swc.linux-x64-musl.node (tokio-runtime-w thread)

Minimal repro (added as a test): a worker_threads Worker creates an unref'd TSF and exits; the main thread then calls napi_release_threadsafe_function on it. Under ASAN this is a deterministic heap-use-after-free at EventLoop::vm_ref, with the freed region being the worker's VirtualMachine box (dealloc'd in web_worker.rs::shutdown).

Cause

A ThreadSafeFunction holds a raw BackRef<EventLoop> into its owning VM. Bun has no mechanism to abort TSFs when their env tears down (Node registers a per-TSF env-cleanup hook; Bun did not), so after the worker's VM box is dealloc'd any napi_release_threadsafe_function / napi_call_threadsafe_function from a native thread calls schedule_dispatch() on freed memory.

This was latent until #31216: that PR made MessagePort.unref() release the event-loop ref taken by a 'message' listener (correct, matches Node). Before, next's worker-side port with .on('message', fn); .unref() left one refEventLoop() outstanding and the worker never drained. Now it drains, its VM is freed, and next-swc's tokio threads (which hold unref'd TSFs) hit the UAF when they release.

Fix

Register an env-cleanup hook per TSF in napi_create_threadsafe_function and remove it in ThreadSafeFunction::destroy. The hook runs on the owning JS thread during vm.on_exit(), i.e. before the VM box is freed, and:

  • under the TSF's lock, sets closing = Closing and broadcasts to wake blocked producers;
  • drops the Strong JS callback handle and disables the poll ref (JSC and the loop are still live here);
  • runs the user finalizer synchronously;
  • drains queued items via call_js_cb(null, null, ctx, item).

This mirrors Node's ThreadSafeFunction::Cleanup / CloseHandlesAndMaybeDelete(true) / EmptyQueueAndDelete in node_api.cc. Once closing is set, release(), enqueue() and acquire() take their existing is_closing() early-return and never touch event_loop. The TSF struct itself is left allocated so a late release() from a native thread can still take the lock and read the atomic.

Verification

# fail-before (src/ stashed), 3/3 runs:
==20003==ERROR: AddressSanitizer: heap-use-after-free on address 0x73f068150628
    #0 <bun_jsc::event_loop::EventLoop>::vm_ref src/jsc/event_loop.rs:1029
    #1 <bun_jsc::event_loop::EventLoop>::enqueue_task_concurrent src/jsc/event_loop.rs:985
    #2 <ThreadSafeFunction>::schedule_dispatch src/runtime/napi/napi_body.rs:2653
    #3 <ThreadSafeFunction>::release src/runtime/napi/napi_body.rs:2735
    #4 napi_release_threadsafe_function src/runtime/napi/napi_body.rs:2858
freed by thread T10 (Worker) in <WebWorker>::shutdown src/jsc/web_worker.rs:1346

# pass-after, 3/3 runs:
(pass) napi > napi_threadsafe_function > is marked closing when its worker_threads owner exits ... [3134.01ms]

The existing napi_threadsafe_function and cleanup hooks test groups (31 tests) still pass, and rust:check-all is green across all 10 targets.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi.test.ts

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 415fa7a9-0bfc-48e7-a854-0819fed73a43

📥 Commits

Reviewing files that changed from the base of the PR and between 7d44148 and 93b6b42.

📒 Files selected for processing (4)
  • src/jsc/bindings/napi.cpp
  • src/runtime/napi/napi_body.rs
  • test/napi/napi-app/async_tests.cpp
  • test/napi/napi.test.ts

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

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:02 PM PT - Jul 13th, 2026

@robobun, your commit 93b6b42 has 1 failures in Build #72502 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33968

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

bun-33968 --bun

@github-actions

Copy link
Copy Markdown
Contributor

No clearly related issues were found for this PR. The 28 candidate issues were reviewed, but none specifically describe a crash caused by NAPI ThreadSafeFunction use-after-free when a worker_threads Worker exits (the exact bug this PR fixes). The closest candidates were either about different NAPI mechanisms (async_work, callback conversion errors), bun:ffi threadsafe callbacks (not NAPI TSF), general worker lifecycle races without NAPI involvement, or broader architectural tracking issues (#32073).

🤖 Generated with Claude Code

@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 didn't find any bugs, but this is a cross-thread lifetime/UAF fix in the NAPI threadsafe-function teardown path with an intentional leak of the TSF allocation — worth a human look.

Checked: release()/enqueue()/acquire() all early-return on is_closing() before touching event_loop, so the hook's atomic store is sufficient.
Checked: finalizer-before-queue-drain ordering matches Node's EmptyQueueAndDelete; ctx is passed as-is (not freed by Bun) so the drain loop's use of self_.ctx is safe.
Checked: hook is removed in destroy() before the box is freed, so normal-path teardown won't call env_cleanup on freed storage.

Extended reasoning...

Overview

The PR adds an env-cleanup hook per ThreadSafeFunction so that when a worker's VM tears down, outstanding TSFs are marked closing (under the TSF lock), their Strong JS handle is dropped, the poll ref is disabled, the user finalizer is run synchronously, and queued items are drained via call_js_cb(null, null, ctx, item). The TSF heap allocation is then intentionally left alive so a late napi_release_threadsafe_function from a native thread can still take the lock and hit the is_closing() early-return instead of dereferencing the freed event_loop. A regression test spawns a worker_threads Worker that creates an unref'd TSF, waits for the worker to exit, then releases the TSF from the main thread and asserts the finalizer ran once.

Security risks

None in the traditional sense — no user-controlled input parsing, auth, or crypto. The risk profile here is memory safety: the fix trades a heap-use-after-free for an intentional per-TSF leak on env teardown, and reorders finalizer/drain relative to the normal dispatch path.

Level of scrutiny

High. This is exactly the category the repo's review guidance calls out as most-blocked: cross-thread lifetime, refcounted env, Strong JS handles, atomics under a lock, and an intentional allocation leak. The change mirrors Node's ThreadSafeFunction::Cleanup closely and the PR description traces the mechanism precisely, but the interaction between env_cleanup and any concurrently-running dispatch() / in-flight schedule_dispatch() task, the choice to set aborted alongside closing, and the decision to leak rather than refcount all deserve a maintainer's eyes.

Other factors

The bug-hunting pass raised and ruled out the finalizer-before-drain ordering (Node does the same in EmptyQueueAndDelete; ctx ownership stays with the addon so drain-after-finalize is only unsafe if the addon frees ctx in its finalizer, which is the addon's contract to manage). The new test asserts stderr: "", which the repo guidance discourages, but it matches the established pattern in the surrounding tests in this file. CI build #71868 is still running; existing TSF and cleanup-hook test groups reportedly pass locally.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Don't enqueue to a terminated worker's freed event loop from other threads #32071 - Both fix cross-thread use-after-free when native threads (including NAPI ThreadSafeFunction callers) enqueue into a freed worker VM. Don't enqueue to a terminated worker's freed event loop from other threads #32071 takes a broader approach with a process-global live_vm_registry and VmHandle/LoopHandle that covers all cross-thread producers (fetch, node:fs, zlib, NAPI TSFs, etc.), while this PR adds per-TSF env-cleanup hooks specifically for the NAPI ThreadSafeFunction case. Both touch src/runtime/napi/napi_body.rs and both are currently OPEN and CONFLICTING.

🤖 Generated with Claude Code

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate-PR bot: #32071 covers the same underlying bug class (cross-thread enqueue into a freed worker VM) with a process-global live_vm_registry across all producers. This PR is the napi-TSF-specific slice, using the same env-cleanup-hook mechanism Node uses for napi_threadsafe_function in node_api.cc. The two are complementary: even with a live-VM registry, env teardown still needs to mark a TSF closing so a late napi_release_threadsafe_function returns napi_ok / napi_closing (Node's contract) and the TSF finalizer runs on the owning thread. Landing this unblocks next-build.test.ts now; #32071 can rebase over it without conflict in the TSF path.

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial self-review (pushback workflow, 71 subagents across 4 rounds) plus a separate pass against Node's node_api.cc on main. Four concerns survived 2-vote refutation; all addressed in 249f215:

concern finding fix
blocked-producer deadlock enqueue() wait loop was while is_blocked() with no !is_closing() term; a producer parked on a full bounded queue would wake from broadcast(), re-evaluate the unchanged predicate, and re-park forever. Node's Push() (node_api.cc:242) has && state == kOpen. added && !self.is_closing() to the wait predicate and the nonblocking guard; env_cleanup zeroes queue.count under the lock before broadcasting
drain/finalizer order Node v24+ Finalize() is EmptyQueue() then CallFinalizer (nodejs/node#61956, fixes nodejs/node#60026 where a finalizer that frees context leaves call_js_cb with a dangling pointer). I had the v22 order. swapped: drain first, then finalizer
hook registration layer the public napi_add/remove_env_cleanup_hook begin with NAPI_PREAMBLE, which early-returns napi_pending_exception if the VM has a pending exception. A skipped add leaves the UAF reachable; a skipped remove leaves a dangling hook. Node registers via the internal AddCleanupHook directly. added napi_internal_{add,remove}_env_cleanup_hook no-preamble shims in napi.cpp and switched the Rust side to them
queue_full vs closing nonblocking enqueue() checked is_blocked() before is_closing(), so a full bounded TSF after teardown returned napi_queue_full instead of napi_closing. same guard change as row 1

Invariants confirmed with line cites:

  • schedule_dispatch (the UAF site) is only reached from enqueue() and release(); both hold self.lock and are gated on is_closing() at napi_body.rs:2634 / :2806. env_cleanup sets closing under the same lock, so the gate is race-free.
  • Worker shutdown order: vm.on_exit() (runs napi env cleanup, including this hook) at web_worker.rs:1253; std::alloc::dealloc(vm_ptr) at :1346. Strict ordering.
  • on_dispatch cannot run after the hook: no event-loop tick between :1253 and :1346; release_queued_tasks_for_shutdown / EventLoop::deinit re-queue task_tag::ThreadSafeFunction entries (dispatch.rs:1219 _ => false), never dispatch them.
  • Node main keeps the TSF allocation alive while thread_count > 0 (MaybeDelete, node_api.cc:322-336), so not freeing the box here when native threads may still hold the pointer matches Node.

The test now also asserts napi_call_threadsafe_function after worker exit returns napi_closing.

@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 didn't find any bugs, but this is a cross-thread memory-safety fix in the NAPI TSF lifecycle (new env-cleanup hook, lock/condvar/atomic interplay, intentionally-leaked TSF box, user-finalizer invocation during teardown) and it overlaps architecturally with #32071 — worth a human look before landing.

What was reviewed:

  • schedule_dispatch reachability: confirmed enqueue()/release() are the only callers and both gate on is_closing() under self.lock, same lock env_cleanup holds when setting closing.
  • Wait-loop predicate change: && !is_closing() plus queue.count = 0 before broadcast prevents parked producers re-sleeping.
  • Drain-then-finalizer order matches Node v24+ Finalize(); hook add/remove uses no-preamble internals so a pending VM exception can't skip them.
  • Test asserts napi_closing on post-teardown call and finalized=1; ruled out the comment-length nits as non-issues.
Extended reasoning...

Overview

This PR fixes a heap-use-after-free where a native thread calls napi_release_threadsafe_function / napi_call_threadsafe_function on a TSF whose owning worker VM has been dealloc'd. The fix registers a per-TSF env-cleanup hook (mirroring Node's ThreadSafeFunction::Cleanup) that runs during vm.on_exit(), sets closing under the TSF lock, broadcasts to wake parked producers, drops the Strong JS callback, drains queued items via call_js_cb(null, null, ctx, item), and runs the user finalizer — all before the VM box is freed. It touches src/jsc/bindings/napi.cpp (two new no-preamble internal shims), src/runtime/napi/napi_body.rs (~100 lines: extern decls, env_cleanup hook, wait-predicate changes, hook registration/removal), and adds a C++/TS test fixture.

Security risks

No direct security surface (no untrusted-input parsing, auth, or crypto). The risk profile is memory safety: the change manipulates cross-thread locks, atomics, and condvars, invokes user-supplied C callbacks (call_js_cb, finalizer) during env teardown, and intentionally leaks the TSF heap allocation so late release() from native threads can still take the lock. A mistake here would be a UAF/deadlock rather than an injection or auth bypass.

Level of scrutiny

High. This is exactly the category CLAUDE.md flags as most-blocked in review: cross-thread lifetime, refcount balance, lock/condvar ordering, calling user callbacks while state is mid-teardown, and an intentional allocation leak. The self-review comment already caught and fixed four non-trivial issues (blocked-producer deadlock, drain/finalizer order, preamble skip, queue_full vs closing), which is evidence the change is subtle enough that independent human eyes are warranted. The PR also intentionally diverges from the normal destroy() free path and leaves the box allocated — a design choice that matches Node's MaybeDelete but should be signed off by a maintainer.

Other factors

  • Architectural overlap with open PR #32071 (process-global live_vm_registry). The author argues they're complementary, but a maintainer should decide sequencing.
  • The bug-hunting system found no defects; two style nits (comment length, test comment content) were raised and refuted.
  • Test coverage is good: a deterministic ASAN repro that asserts napi_closing on post-teardown call, napi_ok on release, and finalizer-ran-once. The 31 existing TSF/cleanup-hook tests still pass per the description.
  • The test asserts stderr: "" exactly, which CLAUDE.md warns against for ASAN/debug builds — minor, but a human reviewer may want that relaxed.

Given the complexity and the memory-safety-critical nature, deferring to human review is the right call even with zero findings.

robobun added 3 commits July 13, 2026 20:26
A napi_threadsafe_function created in a worker_threads Worker holds a
raw BackRef to the worker's EventLoop. When the worker exits the VM box
is dealloc'd, and a later napi_release_threadsafe_function /
napi_call_threadsafe_function from a native thread (next-swc's tokio
runtime) would call schedule_dispatch() on the freed event loop,
segfaulting in us_wakeup_loop.

This became visible after #31216 made MessagePort.unref() release the
listener loop-ref, so next build's workers now drain and exit instead of
being accidentally pinned; next-build.test.ts has been red on musl
lanes since.

Register a per-TSF env-cleanup hook (mirroring Node's
ThreadSafeFunction::Cleanup in node_api.cc) that, on the owning JS
thread and before the VM is freed, marks the TSF closing, wakes blocked
producers, drops the JS Strong handle, disables the poll ref, runs the
user finalizer, and drains queued items with a null env. Once closing is
set, release()/enqueue()/acquire() take their is_closing() early-return
and never touch the event loop. The hook is removed in destroy() so the
normal release path stays unchanged.
- enqueue(): fold !is_closing() into the blocking wait predicate and the
  nonblocking queue_full guard, matching Node's Push() (state == kOpen).
  Without this a producer parked on a full bounded queue would re-sleep
  after env_cleanup's broadcast, and a nonblocking caller would see
  napi_queue_full instead of napi_closing.
- env_cleanup(): zero queue.count under the lock before broadcasting so
  is_blocked() goes false; drain queued items before calling the user
  finalizer (Node v24+ Finalize(): EmptyQueue then CallFinalizer,
  nodejs/node#61956) so ctx is still valid for each drained item.
- Register/remove the per-TSF cleanup hook via internal no-preamble
  shims (napi_internal_{add,remove}_env_cleanup_hook). The public
  napi_{add,remove}_env_cleanup_hook start with NAPI_PREAMBLE which
  early-returns on a pending VM exception; a skipped add would leave the
  UAF reachable and a skipped remove would leave a dangling hook.
- Test: assert napi_call_threadsafe_function after worker exit returns
  napi_closing (Node's Push contract).
…ort path

#34026 added `else if prev_remaining == 1 { schedule_dispatch() }` to
release() so the last release after an abort reaches the finalize path.
That unguarded schedule_dispatch() would touch the freed event loop when
the closing state came from env_cleanup (worker teardown).

env_cleanup now advances the state to `Closed` (the finalizer has run
synchronously, nothing left to dispatch) and the last-release-after-abort
branch targets `Closing` only. #34026's three new tests and the
worker-teardown repro both pass.
@robobun
robobun force-pushed the farm/f603132f/napi-tsfn-worker-cleanup branch from 249f215 to 93b6b42 Compare July 13, 2026 20:40

@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 didn't find bugs, but this is a cross-thread memory-safety change in the NAPI TSF lifecycle (locks, atomics, env-teardown ordering, intentionally-leaked allocations) and it overlaps with the open architectural PR #32071 — worth a human look before landing.

What was reviewed:

  • env_cleanup sets Closed under the lock before release()/enqueue() can reach schedule_dispatch, and the Closing-only guard on the last-release-after-abort path prevents re-touching the freed loop.
  • The internal no-preamble add/remove hook shims — remove-before-free in destroy() looks correctly ordered so the hook can't fire on freed storage.
  • The drain-then-finalize order and the && !is_closing() wait-predicate change match Node's Push()/Finalize() contract as described.
Extended reasoning...

Overview

Adds a per-TSF env-cleanup hook so worker/env teardown marks NAPI threadsafe functions closing before the VM's event loop is freed, fixing a heap-use-after-free when a native thread later calls napi_release_threadsafe_function/napi_call_threadsafe_function. Touches src/runtime/napi/napi_body.rs (new env_cleanup hook, enqueue() wait-predicate change, release() Closing-only gate, hook registration in napi_create_threadsafe_function, hook removal in destroy()), src/jsc/bindings/napi.cpp (two no-preamble internal cleanup-hook entry points), plus a C++ test fixture and a worker_threads regression test.

Security risks

None specific to this change — it's a lifecycle/UAF fix in the NAPI runtime, no auth/crypto/permissions surface. The security-adjacent concern is memory safety itself, which is exactly what the PR is changing.

Level of scrutiny

High. This is squarely in the memory-safety / cross-thread-lifetime category CLAUDE.md flags as most-blocked: it introduces a new Closed state distinct from Closing, intentionally leaves the TSF box allocated so late foreign-thread calls can still take the lock, reorders finalizer/drain, and interacts with #34026's last-release-after-abort dispatch path. The reasoning in the PR description and the adversarial self-review comment is thorough and cites Node's node_api.cc line-by-line, but the correctness depends on subtle ordering invariants (no event-loop tick between vm.on_exit() and VM dealloc, schedule_dispatch reachable only under the lock and behind is_closing(), cleanup-hook removal strictly preceding the box free) that a maintainer familiar with the worker shutdown path should confirm.

Other factors

  • Open PR #32071 addresses the same underlying bug class with a broader live_vm_registry design and is currently marked conflicting with this one; a human should decide sequencing.
  • The change deliberately leaks the TSF allocation when native threads still hold references (matching Node's MaybeDelete), which is a lifetime-policy decision worth explicit sign-off.
  • The new test asserts stderr: "" exactly, which the repo guidance cautions against for ASAN/debug noise — several sibling tests in the file do the same, so it follows local convention, but it may be worth a glance if CI is flaky on the ASAN lane.
  • No prior human review on the PR; the only substantive review is the author's own adversarial pass.

@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 72502 is done. The diff is green:

  • All three musl lanes (alpine 3.23 x64, x64-baseline, aarch64) passed, i.e. next-build.test.ts is fixed on the lanes where it was red.
  • napi.test.ts: 126/127 pass on every lane; the one flake is the pre-existing napi_wrap > has the right lifetime GC-timing flake on Windows (passed on retry, same flake as builds 71868 and 71800 on other branches).

The only red annotation is test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on debian x64-asan, which is a separate main break from #31216 (it was already red in build 71800 before this PR was opened) and is being addressed in #33966 / #33418. It is unrelated to napi threadsafe functions.

Ready for review.

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #34067 which landed on main with a more complete fix: a lock-guarded per-env TSFN registry, event_loop/env as Option (type-enforced), and freeing the TSFN on the releasing thread once orphaned instead of leaking. next-build.test.ts is green on the last 5 main builds.

@robobun robobun closed this Jul 14, 2026
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.

1 participant