Skip to content

napi: keep threadsafe functions alive after their env is torn down - #34067

Merged
Jarred-Sumner merged 7 commits into
mainfrom
claude/napi-tsfn-env-teardown
Jul 13, 2026
Merged

napi: keep threadsafe functions alive after their env is torn down#34067
Jarred-Sumner merged 7 commits into
mainfrom
claude/napi-tsfn-env-teardown

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a use-after-free: a napi_threadsafe_function created in a worker keeps a raw pointer to that worker's event loop, and nothing neutralizes it when the worker's VM is destroyed. A native addon thread that outlives the worker then calls or releases the TSFN and walks freed memory.

This is why bun --bun next build segfaults: next.js runs dozens of workers, and next-swc is a dlopen'd addon whose tokio threads are process-global.

napi_release_threadsafe_function+281:  cmpl $0x1,0x7eb0(%rax)     # rax = 0

0x7EB0 is the offset of VirtualMachine.event_loop_handle. The chain:

napi_release_threadsafe_functionThreadSafeFunction::release()schedule_dispatch()EventLoop::enqueue_task_concurrent()EventLoop::wakeup() → deref of a freed VirtualMachine.

The stale SAFETY claim it violates:

// src/runtime/napi/napi_body.rs
// SAFETY: `event_loop()` is the live JS-thread loop (non-null, stable
// address) and outlives every threadsafe function.

True for the main VM. False for workers.

Node had the same bug and fixed it upstream in v24.14 / v25.4 (EmptyQueueAndMaybeDelete); Node ≤ v25.3 aborts on this scenario. This mirrors that fix.

The fix

NapiEnv gains a lock-guarded registry of live TSFNs
NapiEnv::cleanup() aborts every registered TSFN (after the cleanup-hook drain)
ThreadSafeFunction.event_loop / .env become Option — type-enforced: no path can reach a dead loop
release() / enqueue() short-circuit when the env is dead, and free the TSFN on the releasing thread
release_locked returns napi_invalid_arg at thread_count <= 0 (was < 0), matching Node — a negative count could permanently defeat dispatch_one's == 0 check

Teardown is three phases, mirroring Node's Cleanup → Finalize → ReleaseResources/MaybeDelete. Every foreign-thread path that could reach the loop takes the same lock the teardown holds while publishing env_dead, so the check-then-enqueue window is closed by the lock, not by a racy flag.

Who frees, in every interleaving (env_teardown_done is the handoff token):

last thread_count ref dropped freed by
before teardown the event loop's dispatch task (unchanged)
during teardown phases 1–2 teardown's phase 3 (it sees thread_count == 0)
after teardown the releasing thread (nothing it touches is VM-owned by then)
never (refs outstanding) whichever thread drops the last one, per row 3

The token store and the thread_count read are in one critical section, so exactly one of the two frees.

How did you verify your code works?

check before after
ASAN, test/napi/napi.test.ts orphan-TSFN test heap-use-after-free 5/5 pass
next-build.test.ts, Linux x64, release 3/3 SIGSEGV @ 0x7EB0 3/3 pass
same test fixture on Node v25.6 pass (valid N-API usage) pass
bun bd test test/napi/ 6 pre-existing failures same 6, no new

The ASAN report names it exactly: freed by WebWorker::shutdown, used by enqueue_task_concurrent.

The regression test has an addon own two unref'd TSFNs created in a worker, and makes the last call and last release from a process-global addon thread after the worker has exited — mirroring what next-swc actually does.

A ThreadSafeFunction stores a raw pointer to the event loop of the VM that
created it. Nothing neutralized that pointer when the VM went away, so an addon
thread that called or released a threadsafe function created in a worker, after
that worker exited, walked a freed EventLoop. next.js hits this: next-swc is a
dlopen'd addon whose native threads are process-global and outlive the worker
VMs, and next build segfaults at VirtualMachine.event_loop_handle with a null
base pointer.

Register every threadsafe function with its NapiEnv. Env cleanup now aborts them
on the JS thread while JSC is still alive: it marks them closing, drains the
queue back to the addon, runs the finalizer, then drops the JS callback, the
event-loop keepalive and the env reference and clears the event-loop pointer.
Release and call take the threadsafe function's own lock, the same one teardown
takes, so once the env is dead they can never schedule onto the loop; whichever
thread drops the last thread_count reference frees the object itself.

Mirrors Node's ThreadSafeFunction::Cleanup -> Finalize -> MaybeDelete.
@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator
Updated 5:05 PM PT - Jul 13th, 2026

@Jarred-Sumner, your commit 69f5304 is building: #72562

@github-actions

Copy link
Copy Markdown
Contributor

Found 7 issues this PR may fix:

  1. v.1.3.11 crash on Next.js (16.1.0) build. #28274 - Segfault during next build with next-swc; automated analysis identified TSFN scheduleDispatch() accessing a freed event loop during worker teardown as the root cause
  2. Segfault (SIGILL) after successful next build completion in Docker (linux/amd64) #28203 - Segfault (SIGILL) after successful next build in Docker; same next-swc tokio thread pool outliving the worker VM pattern
  3. Worker lifetime: carry a generation token with cross-thread VM handles (follow-up to #32071) #32073 - Tracking issue for cross-thread VM handle lifetime; explicitly mentions TSFN acceptance on dead worker VMs
  4. Worker & worker_threads stability tracking issue #15964 - Worker & worker_threads stability tracking; TODO list includes NAPI finalizer handling and weak EventLoop pointers
  5. SIGTRAP: C++ exception in __cxa_finalize_ranges at exit after in-process onnxruntime (via @huggingface/transformers) inference — tests pass, teardown panics #34065 - SIGTRAP at exit after onnxruntime inference; process-global NAPI threads race with env teardown
  6. bun test 1.3.13 crashes on shutdown (macOS, 'A C++ exception occurred') and during execution (Linux x86-64, 'Segmentation fault at address 0x1A' + SIGILL) in onnxruntime-node test suite; 1.2.23 is fine #30431 - Crash on shutdown in onnxruntime-node test suite; finalizer teardown ordering issue addressed by the 3-phase env_teardown()
  7. Segmentation fault #21310 - Segfault in NAPI addon with worker_threads; corruption through NAPI/JSC path consistent with TSFN accessing torn-down env

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #28274
Fixes #28203
Fixes #32073
Fixes #15964
Fixes #34065
Fixes #30431
Fixes #21310

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. napi: mark threadsafe functions closing on env teardown #33968 - Both fix the same TSFN use-after-free when a worker's VM is destroyed and a native thread later calls napi_release_threadsafe_function; napi: keep threadsafe functions alive after their env is torn down #34067 is a more comprehensive version with a NapiEnv TSFN registry, Optional fields, and atomic teardown coordination

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Thread-safe function teardown

Layer / File(s) Summary
Environment TSFN registry
src/jsc/bindings/napi.h, src/jsc/bindings/napi.cpp
NapiEnv tracks registered TSFNs, aborts them during cleanup, and exposes registration wrappers to the runtime.
TSFN teardown lifecycle
src/runtime/napi/napi_body.rs
TSFN dispatch, queueing, finalization, environment teardown, and orphan freeing now coordinate through optional resources and teardown state.
TSFN creation and release wiring
src/runtime/napi/napi_body.rs, src/runtime/napi/mod.rs, src/codegen/generate-js2native.ts, src/js/internal-for-testing.ts
TSFN creation registers with the environment, handles teardown races, updates release and reference operations, and exposes live-count testing support.
Orphaned TSFN integration tests
test/napi/napi-app/*, test/napi/napi.test.ts
Tests cover orphaned worker functions, closing and release behavior, post-teardown creation, leak accounting, and microtask ordering.

Possibly related PRs

  • oven-sh/bun#34026: Changes TSFN release and dispatch behavior when teardown or closing has occurred.

Suggested reviewers: robobun, dylan-conway, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preserving threadsafe functions across environment teardown.
Description check ✅ Passed The description includes both required sections and gives a detailed fix summary plus concrete verification results.
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.

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

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/napi/napi.test.ts`:
- Around line 445-456: Update the subprocess assertions in the affected test to
inspect the captured stderr before asserting exitCode. Immediately before
expect(exitCode).toBe(0), add the house-style conditional that expects stderr to
be empty only when exitCode is nonzero, preserving the existing stdout and
exit-code checks.
🪄 Autofix (Beta)

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: c3d13c17-696f-4b13-82c2-d5df0486b7f3

📥 Commits

Reviewing files that changed from the base of the PR and between 8f1a954 and 6f08b3d.

📒 Files selected for processing (7)
  • src/jsc/bindings/napi.cpp
  • src/jsc/bindings/napi.h
  • src/runtime/napi/napi_body.rs
  • test/napi/napi-app/async_tests.cpp
  • test/napi/napi-app/tsfn-orphan-worker.js
  • test/napi/napi-app/tsfn-orphan.js
  • test/napi/napi.test.ts

Comment thread test/napi/napi.test.ts Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
@Jarred-Sumner
Jarred-Sumner marked this pull request as draft July 13, 2026 17:50
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Moving to draft — a max-effort review found 5 confirmed correctness bugs in this change, including one that would introduce a new use-after-free:

napi_call_threadsafe_function frees the TSFN on the calling foreign thread when the env is dead and it holds the last reference. Node never frees inside Push. So the idiomatic sequence — napi_call_threadsafe_function(...) then later napi_release_threadsafe_function(...) from the thread's cleanup, which is what node-addon-api's wrapper does — returns napi_closing, deallocates, and then the release dereferences freed memory.

The test in this PR does not catch it because it calls one handle and releases a different one.

Also confirmed: loop_mut() manufactures a &mut EventLoop on addon threads (aliasing UB; enqueue_task_concurrent only needs &); free_orphaned(self) deallocates through a live &mut self (forbidden by src/CLAUDE.md — must dispatch off *mut Self); dispatch_one passes !is_first into a parameter named is_first, inverting microtask-drain ordering; and the "env already torn down" guard in napi_create_threadsafe_function is unreachable.

Fixing.

Follow-ups to the env-teardown change:

- napi_call_threadsafe_function no longer frees the threadsafe function when a
  call on an orphaned one consumes the last thread reference. Addons (including
  node-addon-api's ThreadSafeFunction wrapper) release the same handle right
  after a call reports napi_closing, and freeing inside the call turned that
  into a use-after-free. Only napi_release_threadsafe_function frees now; the
  release reports napi_invalid_arg instead.
- schedule_dispatch reaches the event loop through a shared reference. It runs
  on addon threads, where manufacturing &mut EventLoop aliases the JS thread's
  own borrow inside tick().
- The release entry point dispatches off *mut Self: it can free the object, and
  deallocating through a pointer derived from a live &mut self is UB.
- Threadsafe-function callbacks drain microtasks between callbacks of one tick
  again, instead of once before the first (the flag was inverted).
- Creating a threadsafe function after its env tore its threadsafe functions
  down now fails instead of returning a handle whose finalizer already ran; the
  old check tested a thread count that had just been initialized.
- Env cleanup drains the cleanup-hook queue again after aborting threadsafe
  functions, so a hook registered by a teardown finalizer still runs.

Tests: a call followed by a release of the same orphaned handle (ASAN
heap-use-after-free before this change), callback-vs-microtask ordering across a
multi-item tick, and creating a threadsafe function after teardown. The orphan
test now compares against node instead of hardcoding its output.
@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review July 13, 2026 18:14
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

All 5 review findings fixed in 9eea9a605a.

fix
enqueue() freed the TSFN on a foreign thread it no longer frees — consumes the thread ref, returns napi_closing. Only release frees.
loop_mut() made &mut EventLoop on addon threads schedule_dispatch uses the shared Deref; enqueue_task_concurrent only needs &
freed through &mut self release is now unsafe fn release(this: *mut Self, ..); the borrow ends before the free
dispatch_one passed !is_first into is_first fixed; new ordering test agrees with Node via checkSameOutput
unreachable "env torn down" guard gates on the registry's real flag; registration failure runs the missed teardown and returns napi_generic_failure

Plus: cleanup re-drains the hook queue after abortThreadSafeFunctions() so a hook registered by a TSFN finalizer still runs; the fixture holds its mutex across the call; the test asserts {stdout, stderr, exitCode, signalCode}.

Correction to my earlier claim: Node does not survive call-then-release on the same handle — v26.3 aborts in napi_release_threadsafe_function (verified under lldb). Node frees inside the call too, matching the N-API doc ("make no further use after napi_closing"). So that case can't be a Node-parity test. Bun is now deliberately safer than Node there: napi_invalid_arg, no crash, at the cost of leaking one orphaned TSFN allocation — a leak beats a UAF, and it's stated in the code.

Verification: the new same-handle test is ASAN heap-use-after-free on the unfixed build and 5/5 clean on the fixed one. 12/12 threadsafe tests. test/napi/ 306 pass / 6 pre-existing failures. next-build.test.ts on Linux x64: 3/3 pass (was 3/3 SIGSEGV).

Not fixed here, deliberately: napi_async_work has the same raw-back-pointer shape, but the fix does not transfer — NapiEnv is non-atomic RefCounted and the global WorkPool has no drain, so a correct fix has to change worker-termination semantics (wait for or atomically detach in-flight pool work). Pre-existing, not the next build crash, and it needs its own ASan repro and PR.

@Jarred-Sumner
Jarred-Sumner marked this pull request as draft July 13, 2026 18:35

@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.

The registration-fails path I flagged earlier is fixed in 9eea9a605a — it now unconditionally frees and returns napi_generic_failure without publishing the handle, so the fallthrough to *result = function; env.ok() is gone. Re-review of the fix commit found no new issues, but this is intricate cross-thread lifetime management (lock-guarded teardown handoff, foreign-thread frees, *mut Self dispatch to avoid freeing through a live borrow), so it warrants a human pass.

Checked on this revision: release drops the lock guard before free_orphaned; enqueue no longer frees (only consumes the ref via release_locked); schedule_dispatch takes &EventLoop via Deref on addon threads while loop_mut is JS-thread-only; env_teardown publishes env_dead and env_teardown_done under the same lock the foreign-thread readers hold; the is_first inversion in dispatch_one is corrected and covered by the new microtask-order test.

Extended reasoning...

Overview

This PR fixes a use-after-free where a napi_threadsafe_function created in a worker holds a raw pointer to that worker's event loop, which is freed at worker shutdown while process-global addon threads (e.g. next-swc's tokio pool) still hold references. The fix adds a per-NapiEnv TSFN registry, a three-phase env_teardown() that neutralizes each TSFN before its VM dies, and an ownership-handoff protocol (env_dead / env_teardown_done) so exactly one party frees the allocation. ~250 lines of Rust in ThreadSafeFunction, ~60 lines of C++ registry plumbing in NapiEnv, and ~350 lines of new native test fixtures.

Prior review and what changed

I left one inline comment on the previous revision about the "registration fails after teardown" path returning napi_ok with a handle whose finalizer had already run. The author's fix commit 9eea9a605a addresses it (unconditional free_orphaned + generic_failure, handle never written to *result) along with four other correctness bugs found in a separate max-effort review — including a foreign-thread free inside enqueue() that would have introduced a new UAF, &mut EventLoop aliasing UB from addon threads, freeing through a live &mut self, and an inverted is_first argument. All five are fixed in the current diff and the bug hunter found nothing on re-review.

Security risks

None in the injection/auth sense. The risk surface is memory safety: cross-thread raw-pointer lifetime, lock ordering between the per-TSFN Mutex and the env's m_threadSafeFunctionsLock, and ensuring every JS-heap-touching field (StrongOptional callback, poll_ref, NapiEnvRef) is released on the JS thread before a foreign thread can free the allocation. Getting any interleaving wrong is a UAF or double-free reachable from untrusted native addons.

Level of scrutiny

High. This is exactly the class of change CLAUDE.md flags as most-blocked: cross-thread ownership with a hand-rolled refcount, atomics under a lock, and unsafe raw-pointer deallocation. The author's own max-effort pass found five real bugs in the first revision — evidence that the invariants here are subtle. The fix commit is well-reasoned (the release(this: *mut Self) refactor and the env_teardown_done handoff token are the right shapes), and test coverage is thorough (ASAN-verified UAF repro, Node parity via checkSameOutput, the call-then-release-same-handle case, late creation during teardown, and the microtask-ordering regression). But a human maintainer should confirm the full ownership matrix in the PR description holds — particularly that Debugger::AsyncTaskTracker and any other un-cleared fields are safe to drop from a foreign thread in free_orphaned, and that the deliberate leak in the enqueue-consumes-last-ref-after-teardown case is the right tradeoff.

Other factors

The PR is out of draft after the fix commit, CI build #72457 is running, and there's a noted overlap with #33968 (a less complete fix for the same bug). The napi_async_work sibling with the same shape is explicitly deferred to a follow-up with a stated reason.

…se does

The last round got the ownership model backwards. A threadsafe function is owned
by the JS thread while its env lives (it frees it in destroy, always with
thread_count == 0), and from env_teardown_done on it is owned by the remaining
thread_count references, whichever thread drops the last one freeing it. A call
that reports napi_closing consumes the caller's thread reference -- node's
ThreadSafeFunction::Push does the same, and a thread that stops calling after
napi_closing would otherwise pin the loop forever -- so a call is a
reference-drop point exactly like a release, and can be what frees.

- napi_call_threadsafe_function honors that. release_locked already reported
  "you must free"; enqueue dropped the flag on the floor, so a call that dropped
  the last reference of a torn-down threadsafe function freed nothing: one
  ThreadSafeFunction plus its queue leaked per handle, for every worker that
  left one behind. It frees through the raw pointer once the lock is dropped,
  the way the release entry point does.

  This means an addon that uses a handle after a call reported napi_closing --
  releasing it, say -- touches freed memory. It does in node too (Push deletes
  and the release aborts), which is why the docs say to make no further use of
  the function after napi_closing. The previous round leaked the allocation to
  tolerate that, and its "a call never frees" comments and test went with it.

- A creation that fails no longer runs the addon's finalizer. The
  registration-failure path ran the teardown the threadsafe function had missed,
  which handed thread_finalize_data back to the addon while the addon's own
  error handling still owned it: a double free under any wrapper that frees on a
  failed create. Node's Init failure path just deletes the ThreadSafeFunction,
  whose destructor releases only its own resources; ours now frees only what it
  allocated (the Strong callback, the queue, the box).

- A call on an aborted threadsafe function whose bounded queue is full reports
  napi_closing, not napi_queue_full. Node's Push only checks the queue when the
  function is open. Reporting queue_full left the caller's reference in place,
  and with nothing left to consume it the finalizer never ran and the
  event-loop keepalive pinned the process.

Tests: the leak is bounded by a new bun:internal-for-testing live count -- five
worker-orphaned threadsafe functions per iteration, called and never released,
must all be gone before the next iteration (it reports orphaned=5 closing=5
leaked=0 five times; without the fix the second iteration already reports
orphaned=10). The failed late creation now passes a finalizer that must not run.
The aborted-full-queue call is compared against node.
@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review July 13, 2026 19:04
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Round 3 (db69ca72c8). This one started by writing the ownership model and checking it against Node's src/node_api.cc, rather than against our assumptions — and that overturned the premise of round 2.

The fact both earlier rounds had backwards: a thread reference is dropped by napi_release_threadsafe_function and by napi_call_threadsafe_function when it reports napi_closing (Node's Push: thread_count--; if (state == kClosed && thread_count == 0) delete this;). The call must consume the ref, or a thread that stops calling after napi_closing pins the event loop forever. So both are ref-drop points and both can free — "only release may free" was never a coherent model; it either leaks or hangs.

state owner who may free
open JS thread nobody
closing/closed, env alive JS thread JS thread, in destroy(), only at thread_count == 0
tearing down JS thread JS thread
orphaned (env_dead && env_teardown_done) the remaining refs whichever thread drops the last one

Fixed:

  • Leak: enqueue discarded release_locked's must-free flag, so an orphaned TSFN drained by a call was never freed — one box + queue per handle, unbounded across workers. It now frees through the raw pointer after the guard drops, like release.
  • Double-free: the registration-failure path ran the addon's thread_finalize_cb before returning napi_generic_failure. It no longer does; it frees only what we allocated. (Node's Init() failure path does exactly this.)
  • A hang, found in passing: the non-blocking arm returned napi_queue_full before checking is_closing, so aborting a bounded-queue TSFN whose queue was full left the caller's reference unconsumed — nothing could finalize it and the event-loop keepalive pinned the process forever. Node's Push only checks the queue while kOpen. Now reports napi_closing; Node-parity tested.

Removed: the call_then_release test from round 2. Its premise — that node-addon-api's wrapper does call-then-release — is false: napi-inl.h's BlockingCall/NonBlockingCall only call, never auto-release. Keeping that test required the unbounded leak above, to tolerate a contract violation Node punishes with an abort. The real contract ("make no further use of the handle after napi_closing") is now documented instead of contradicted.

Verification — every new test has a negative control that fails when the fix is reverted:

check result
ASAN orphan test ×5 5/5, no ASAN reports
leak-bounding test (live count flat) pass · control: leaked grows 5→50
failed-create must not run addon finalizer pass · control: "finalizer of a failed creation ran"
aborted-full-queue (Node parity) pass · control fails
bun bd test test/napi/ 307 pass / 6 fail (the known pre-existing set)
next-build.test.ts, Linux x64 3/3 pass (was 3/3 SIGSEGV)

Comment thread test/napi/napi.test.ts
Jarred-Sumner and others added 2 commits July 13, 2026 13:24
The two new threadsafe-function tests compared stdout against a \n-joined string,
so they failed on Windows, where the child writes \r\n. The diff renders identical
because \r is invisible.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/napi/napi_body.rs (1)

2547-2559: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Avoid reborrowing the loop mutably from TSFN dispatch

src/runtime/dispatch.rs:336-337 calls ThreadSafeFunction::on_dispatch() from run_task(task, el: &mut EventLoop, ...), so call()/maybe_queue_finalizer() can overlap with the dispatcher’s live &mut EventLoop. If self.event_loop points at that same loop, unsafe { back_ref.get_mut() } creates aliased mutable refs. Thread the live loop through this path or otherwise avoid get_mut() here.

🤖 Prompt for 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.

In `@src/runtime/napi/napi_body.rs` around lines 2547 - 2559, Avoid using
ThreadSafeFunction::loop_mut to call BackRef::get_mut during TSFN dispatch,
since run_task already holds a live &mut EventLoop. Thread that existing loop
reference through on_dispatch/call/maybe_queue_finalizer or otherwise reuse a
shared reference, ensuring no aliased mutable EventLoop is created.
🤖 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.

Outside diff comments:
In `@src/runtime/napi/napi_body.rs`:
- Around line 2547-2559: Avoid using ThreadSafeFunction::loop_mut to call
BackRef::get_mut during TSFN dispatch, since run_task already holds a live &mut
EventLoop. Thread that existing loop reference through
on_dispatch/call/maybe_queue_finalizer or otherwise reuse a shared reference,
ensuring no aliased mutable EventLoop is created.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fdafa0e8-aae3-4c78-83ad-33fd53035acf

📥 Commits

Reviewing files that changed from the base of the PR and between 9eea9a6 and ff10606.

📒 Files selected for processing (9)
  • src/codegen/generate-js2native.ts
  • src/js/internal-for-testing.ts
  • src/runtime/napi/mod.rs
  • src/runtime/napi/napi_body.rs
  • test/napi/napi-app/async_tests.cpp
  • test/napi/napi-app/module.js
  • test/napi/napi-app/standalone_tests.cpp
  • test/napi/napi-app/tsfn-orphan-worker.js
  • test/napi/napi.test.ts

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/napi/napi.test.ts`:
- Around line 483-486: Update both stdout normalization blocks in the NAPI tests
to have the debug-line regex consume the trailing newline when removing lines,
preventing blank lines between expected records. Preserve the existing CRLF
normalization and trimming behavior.
🪄 Autofix (Beta)

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: 06d8fc0f-a578-444e-a2f1-58fce145e157

📥 Commits

Reviewing files that changed from the base of the PR and between ff10606 and 69f5304.

📒 Files selected for processing (1)
  • test/napi/napi.test.ts

Comment thread test/napi/napi.test.ts
Comment on lines +483 to +486
stdout: stdout
.replaceAll("\r\n", "\n")
.replaceAll(/^\[\w+\].+$/gm, "")
.trim(),

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

bun -e 'const s = "orphaned=5\n[debug] native\nclosing=5\n"; console.log(JSON.stringify(s.replaceAll(/^\[\w+\].+$/gm, "").trim()))'

Repository: oven-sh/bun

Length of output: 190


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant test file sections.
file="test/napi/napi.test.ts"
wc -l "$file"
sed -n '460,540p' "$file"

# Reproduce the regex behavior with Node (bun is unavailable in this sandbox).
node - <<'JS'
const samples = [
  "orphaned=5\n[debug] native\nclosing=5\n",
  "orphaned=5\r\n[debug] native\r\nclosing=5\r\n",
  "orphaned=5\n[debug] native\n[info] extra\nclosing=5\n",
];
for (const s of samples) {
  const out = s.replaceAll(/^\[\w+\].+$/gm, "").trim();
  console.log(JSON.stringify({ input: s, output: out, lines: out.split("\n") }));
}
JS

Repository: oven-sh/bun

Length of output: 4211


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact normalization blocks in the file.
file="test/napi/napi.test.ts"
grep -n 'replaceAll(/^\[\\w\+\].\+\$/gm, "")\|replaceAll("\\r\\n", "\\n")' "$file" || true

# Show nearby context for both occurrences.
sed -n '475,492p' "$file"
sed -n '511,528p' "$file"

# Check regex behavior on newline-terminated and mid-stream log lines.
node - <<'JS'
const cases = [
  ["single log line between records", "orphaned=5\n[debug] native\nclosing=5\n"],
  ["multiple log lines between records", "orphaned=5\n[debug] native\n[info] extra\nclosing=5\n"],
  ["windows newlines", "orphaned=5\r\n[debug] native\r\nclosing=5\r\n"],
];
for (const [name, s] of cases) {
  const out = s.replaceAll(/^\[\w+\].+$/gm, "").trim();
  console.log(name);
  console.log(JSON.stringify(out));
  console.log(out.split("\n"));
}
JS

Repository: oven-sh/bun

Length of output: 1862


Consume the newline when stripping debug lines. replaceAll(/^\[\w+\].+$/gm, "") leaves line endings behind, so any debug line between expected records turns into a blank line and breaks the exact stdout match. Apply the same fix in both normalization blocks.

🤖 Prompt for 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.

In `@test/napi/napi.test.ts` around lines 483 - 486, Update both stdout
normalization blocks in the NAPI tests to have the debug-line regex consume the
trailing newline when removing lines, preventing blank lines between expected
records. Preserve the existing CRLF normalization and trimming behavior.

@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.

No new findings after the round-3 fixes — both earlier inline comments (registration-failure finalizer ordering, Windows CRLF in the teardown test) are addressed in the current diff. Deferring to human review given the scope: cross-thread ownership handoff in native N-API code is exactly the kind of change a maintainer should sign off on.

Checked: the env_dead/env_teardown_done handoff — every foreign-thread path that reads them holds lock, and the free only happens after the guard drops.
Checked: push/release now dispatch off *mut Self and end the borrow before free_orphaned.
Checked: abortThreadSafeFunctions() iterates a moved-out snapshot with the lock dropped, so a finalizer that unregisters/creates can't corrupt the set.
Ruled out: missing per-iteration exception isolation between TSFN finalizers — clearExceptionsBetweenFinalizers() brackets the whole abortThreadSafeFunctions() phase, and Finalizer::run doesn't propagate into the next iteration.

Extended reasoning...

Overview

This PR fixes a use-after-free in napi_threadsafe_function when the creating worker's VM is torn down while addon-owned threads still hold references. It touches src/runtime/napi/napi_body.rs (the TSFN state machine: enqueue/push, release/release_locked, env_teardown, schedule_dispatch, destroy), src/jsc/bindings/napi.{h,cpp} (a per-env TSFN registry and abortThreadSafeFunctions() wired into NapiEnv::cleanup()), plus codegen/internal-for-testing plumbing for a live-count probe and ~300 lines of new native test fixtures across async_tests.cpp, standalone_tests.cpp, module.js, a worker fixture, and napi.test.ts.

Security risks

None in the classic sense (no auth/injection surface). The risk profile here is memory safety: cross-thread ownership handoff, freeing through raw pointers, and lock-guarded state publication. A mistake manifests as UAF, double-free, leak, or hang — the PR history already surfaced and fixed five such bugs across three rounds.

Level of scrutiny

High. This is production-critical native code on the N-API hot path, with an explicit ownership model spanning the JS thread, addon threads, and env teardown. The PR itself went through three self-review rounds where the ownership model was revised (round 2's "only release frees" was overturned in round 3), and two of my own prior inline findings were fixed. That churn is a strong signal that a maintainer should read the final env_teardown / release_locked / push interaction end-to-end rather than rely on automated review alone.

Other factors

  • Both prior claude[bot] inline findings are resolved in the current diff (free_orphaned on registration failure without running the addon finalizer; .replaceAll("\\r\\n", "\\n") on the printf-based test).
  • The bug-hunting pass this run found nothing; the one candidate raised (per-iteration exception isolation in abortThreadSafeFunctions()) was verified not to be an issue — the phase is bracketed by clearExceptionsBetweenFinalizers() and finalizers run through Finalizer::run, which does not leak one finalizer's throw into the next iteration's native call.
  • Test coverage is unusually thorough (Node-parity via checkSameOutput, a live-count leak test, an ASAN-targeted orphan test with MIMALLOC_PURGE_DELAY=0, and negative controls documented in the PR body), but the correctness argument still rests on a hand-stated ownership table that deserves human eyes.
  • The PR explicitly scopes out napi_async_work's analogous back-pointer, which is a design call worth a maintainer nod.

@Jarred-Sumner
Jarred-Sumner merged commit 73b6c14 into main Jul 13, 2026
73 of 76 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/napi-tsfn-env-teardown branch July 13, 2026 23:31
cirospaciari added a commit that referenced this pull request Jul 14, 2026
Picks up the safety comment on `ThreadSafeFunction::free_orphaned`, which is
what `cargo clippy` was failing on for this PR: `undocumented_unsafe_blocks` is
denied workspace-wide, #34067 introduced the block without a comment, and the
Clippy workflow has no `push:` trigger so main never caught it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants