Skip to content

napi: keep dispatching threadsafe function calls when a callback blocks in a nested event loop - #36831

Open
robobun wants to merge 17 commits into
mainfrom
farm/4e5ad0c4/tsfn-nested-wait-deadlock
Open

napi: keep dispatching threadsafe function calls when a callback blocks in a nested event loop#36831
robobun wants to merge 17 commits into
mainfrom
farm/4e5ad0c4/tsfn-nested-wait-deadlock

Conversation

@robobun

@robobun robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #36828

Problem

A promise that can only settle through napi threadsafe function calls never settles under bun test on 1.4 canary (works on 1.3.13 and under bun run). Reported with @temporalio/worker: native.workerPollWorkflowActivation() / native.workerPollActivityTask() promises never resolve, the worker never leaves DRAINING, and teardown then fails with error: Channel has been shut down.

Cause

The threadsafe function dispatch loop coalesces pushes: while dispatch_state is Running, napi_call_threadsafe_function only flips it to Pending and relies on the running on_dispatch loop to pick the item up, without enqueueing a new event-loop task.

bun:test's expect(promise).resolves / .rejects waits for the promise synchronously by running a nested event loop (Expect::process_promise -> wait_for_promise). When that wait runs inside a threadsafe function dispatch (directly in the callback, or in a microtask drained between two batched callbacks, which is where the temporal repro blocks), the dispatch loop is parked underneath the wait. Every subsequent push coalesces into that blocked loop and is never dispatched, so the promise the nested loop is waiting on can never settle: deadlock. The main thread parks in epoll_wait forever, which is why the hung process also ignores SIGTERM.

Captured stack of the hang (test runner -> TSFN dispatch -> microtask -> expect().rejects.toThrow() -> wait_for_promise -> auto_tick parked in epoll):

epoll_pwait2 <- us_loop_run_bun_tick <- auto_tick <- wait_for_promise
<- Expect::process_promise <- to_throw <- (microtask drain)
<- ThreadSafeFunction::call <- ThreadSafeFunction::on_dispatch <- EventLoop::tick

The coalescing design predates 1.4; the regression window only shifted timing so the two neon channel sends land in one dispatch batch deterministically. The deadlock is latent in any ordering where a callback blocks in a nested wait.

Fix

In src/runtime/napi/napi_body.rs:

  • schedule_dispatch also enqueues a dispatch task when the previous state was Running. If the running loop is live it drains the item first and the extra task is a cheap no-op; if it is blocked in a nested event loop, the task is what keeps the function draining.
  • dispatch_one schedules one backup dispatch per on_dispatch when items remain queued behind the one being called, so queued-behind items cannot be stranded when that call blocks.
  • on_dispatch tracks in-flight dispatch tasks and its own re-entrancy depth, and only frees a Closed threadsafe function from the last task at the outermost frame, since more than one task can now reference it. (Without this, a second queued task dereferences the freed function; ASAN catches it in the existing test_create_tsfn_with_async_context test.)

Test

New addon target tsfn_nested_wait_addon plus a bun test fixture covering both orders: a call pushed while the callback is already blocked in a nested wait, and a call already queued behind the blocking one. Both deadlock and time out without the fix, and pass in under a second with it.

Verification

  • bun bd test test/napi/tsfn-nested-wait.test.ts: pass with fix, times out without (git stash push -- src/).
  • The full temporal repro from the issue (time-skipping test server, failing workflow, worker.runUntil + expect().rejects.toThrow()) now passes under bun bd test: 1 pass, 0 fail in ~90s on a debug build; unfixed it hangs past a 300s test timeout.
  • bun bd test test/napi/napi.test.ts -t threadsafe: same results as unmodified main in this environment (one pre-existing 5s timeout on a slow debug container, identical on main).

[review] gate passed · iteration 3 · 5 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/napi/tsfn-nested-wait.test.ts
bun test v1.4.0 (b31f15e1d)

test/napi/tsfn-nested-wait.test.ts:
killed 1 dangling process
(fail) threadsafe function calls are dispatched while a callback blocks in a nested promise wait [60012.09ms]
  ^ this test timed out after 60000ms.

 0 pass
 1 fail
Ran 1 test across 1 file. [62.02s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (d7cb10b2d)

test/napi/tsfn-nested-wait.test.ts:
(pass) threadsafe function calls are dispatched while a callback blocks in a nested promise wait [11.96ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [180.00ms]
__F:0:S:0
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/napi/tsfn-nested-wait.test.ts
bun test v1.4.0 (b31f15e1d)

test/napi/tsfn-nested-wait.test.ts:
(pass) threadsafe function calls are dispatched while a callback blocks in a nested promise wait [459.90ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [2.49s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 691ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/9] cxx obj/src/jsc/bindings/napi.cpp.o
[2/9] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[3/9] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 238 extern-C blocks audited
[4/9] gen cpp.rs (cppbind)
[4/9] 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/sr
... (truncated)
diff hotspot
src/runtime/napi/napi_body.rs                  | 274 ++++++++++++++++---------
 test/napi/napi-app/binding.gyp                 |  13 ++
 test/napi/napi-app/tsfn-nested-wait.fixture.ts |  67 ++++++
 test/napi/napi-app/tsfn_nested_wait_addon.cpp  | 151 ++++++++++++++
 test/napi/tsfn-nested-wait.test.ts             |  87 ++++++++
 5 files changed, 499 insertions(+), 93 deletions(-)

gate history · 9 passed · 0 rejected · iteration 3

evidence per changed file
file                                            reads  edits  tests
src/runtime/napi/napi_body.rs                      28     67      0
test/napi/napi-app/binding.gyp                      1      2      0
test/napi/napi-app/tsfn-nested-wait.fixture.ts      1      4      0
test/napi/napi-app/tsfn_nested_wait_addon.cpp       0      3      0
test/napi/tsfn-nested-wait.test.ts                  1      5      0

root cause · written by the author bot

When JavaScript re-entered the event loop from inside a ThreadSafeFunction callback, such as by synchronously waiting on a promise during a nested dispatch, the dispatcher's single in-flight task was blocked and newly queued callbacks were never scheduled, so the queue stalled and NAPI promises never settled. The fix makes the ThreadSafeFunction track dispatch depth and in-flight dispatch tasks, scheduling backup dispatches so work queued during a nested wait still drains in FIFO order, and it defers destruction until all nested dispatches complete to keep teardown safe. Regression coverage…

…ks in a nested event loop

A threadsafe function's dispatch loop coalesces pushes: while the loop is
marked Running, a push only flips the state to Pending and relies on the
running loop to pick the item up. That breaks when the dispatched callback
(or a microtask drained between two batched callbacks) re-enters the event
loop and blocks until a promise settles, as bun:test's
expect(promise).resolves / .rejects does via wait_for_promise: the promise
can only settle through further threadsafe function calls, but those calls
are coalesced into the blocked loop and never dispatched, deadlocking the
process.

Fix:
- schedule_dispatch also enqueues a dispatch task when the previous state
  was Running; if the running loop is live it drains the item first and the
  extra task is a cheap no-op, and if it is blocked in a nested event loop
  the task is what keeps the function draining.
- dispatch_one schedules one backup dispatch per on_dispatch when items
  remain queued behind the one being called, so queued-behind items cannot
  be stranded either.
- on_dispatch tracks in-flight dispatch tasks and its own re-entrancy
  depth, and only frees a Closed threadsafe function from the last task at
  the outermost frame, since more than one task can now reference it.

Fixes #36828
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The runtime now tracks nested ThreadSafeFunction dispatches, queued tasks, and deferred destruction. Callback dispatch uses short-lived reborrows. A native N-API addon and regression tests cover concurrent and pre-queued callbacks during nested promise waits.

ThreadSafeFunction nested dispatch

Layer / File(s) Summary
Dispatch state and scheduling
src/runtime/napi/napi_body.rs
ThreadSafeFunction tracks dispatch depth, pending destruction, and in-flight dispatch tasks. Scheduling accounts for pending and running dispatches.
Nested dispatch draining and teardown
src/runtime/napi/napi_body.rs
Queue draining tracks remaining work, schedules backup dispatches, preserves queued work, defers destruction, and invokes JavaScript and C callbacks with scoped targets.
Nested-wait regression coverage
test/napi/napi-app/*, test/napi/tsfn-nested-wait.test.ts
The native addon exposes concurrent and queued scenarios. The fixture verifies callback order [1, 2, 3]. The test builds the addon and verifies successful execution.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#36801: Both changes modify Node-API ThreadSafeFunction dispatch behavior in src/runtime/napi/napi_body.rs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #36828 by preventing nested-event-loop deadlocks and adding regression tests for both affected dispatch orders.
Out of Scope Changes check ✅ Passed All modified files support the dispatch fix, native regression addon, build configuration, or targeted tests for issue #36828.
Title check ✅ Passed The title clearly summarizes the primary fix for threadsafe function dispatch during nested event-loop waits.
Description check ✅ Passed The description explains the problem, cause, fix, tests, verification, and linked issue, although it does not use the template headings exactly.

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

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)

2653-2711: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Call schedule_dispatch() while holding self.lock.

dispatch_one drops the lock before the backup call, but schedule_dispatch() requires the lock while reading event_loop. This can race with env_teardown, which sets event_loop to None under the same lock. Move the backup-dispatch decision into the locked block.

🤖 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 2653 - 2711, Move the
backup-dispatch decision and self.schedule_dispatch() call into the self.lock
guard block in dispatch_one, after determining that remaining > 0 and
scheduled_backup is false. Update scheduled_backup while holding the lock, and
remove the unlocked scheduling block so event_loop cannot race with
env_teardown.
🤖 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 `@src/runtime/napi/napi_body.rs`:
- Around line 2634-2643: In the finalizer-task scheduling block, move the
inflight_dispatch_tasks fetch_add before
loop_.enqueue_task(Task::init(self_ptr)). Preserve the existing loop teardown
guard and task initialization, ensuring the task is counted before it is
published to the event loop.

---

Outside diff comments:
In `@src/runtime/napi/napi_body.rs`:
- Around line 2653-2711: Move the backup-dispatch decision and
self.schedule_dispatch() call into the self.lock guard block in dispatch_one,
after determining that remaining > 0 and scheduled_backup is false. Update
scheduled_backup while holding the lock, and remove the unlocked scheduling
block so event_loop cannot race with env_teardown.
🪄 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: 461fe26e-ac08-4cfe-b108-242227639c05

📥 Commits

Reviewing files that changed from the base of the PR and between 074656d and 4f0f6d1.

📒 Files selected for processing (5)
  • src/runtime/napi/napi_body.rs
  • test/napi/napi-app/binding.gyp
  • test/napi/napi-app/tsfn-nested-wait.fixture.ts
  • test/napi/napi-app/tsfn_nested_wait_addon.cpp
  • test/napi/tsfn-nested-wait.test.ts

Comment thread src/runtime/napi/napi_body.rs Outdated
…ion lock

schedule_dispatch documents that callers must hold the lock (it reads
event_loop, which env_teardown clears under the same lock), and every other
caller does. Move the backup-dispatch decision in dispatch_one inside the
guard instead of after it drops.
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 969b7eb: the backup dispatch in dispatch_one is now scheduled inside the lock guard, honoring schedule_dispatch's documented lock contract (event_loop is cleared by env_teardown under the same lock). The finalizer-task counter ordering is unchanged; both statements run on the JS thread and the queue is only drained by that thread, so the task cannot be observed before it is counted (replied on the thread).

Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated

@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 `@src/runtime/napi/napi_body.rs`:
- Around line 2685-2698: Remove the pre-callback backup scheduling from the
dispatch path around on_dispatch, and avoid holding or materializing &mut
ThreadSafeFunction across self.call() or any JavaScript entry. Retain only a raw
*mut Self while invoking the callback, then reacquire a short-lived mutable
reference after it returns, revalidate the function state, and perform any
required backup scheduling without permitting on_dispatch re-entry during the
original mutable borrow.
🪄 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: ad9aab57-7572-4c97-9ff2-a70c7aee80de

📥 Commits

Reviewing files that changed from the base of the PR and between 4f0f6d1 and 969b7eb.

📒 Files selected for processing (1)
  • src/runtime/napi/napi_body.rs

Comment thread src/runtime/napi/napi_body.rs Outdated

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/napi/napi_body.rs:2696-2697 — Nit: schedule_dispatch's doc comment (line 2824) still says "Caller must hold lock", but this new backup-dispatch call runs after _g drops at the end of the 'brk block. It's safe — dispatch_one and env_teardown both run on the JS thread so the event_loop read can't race — but the comment now states an invariant a caller violates, which will send future readers hunting for a bug here. Consider updating the comment to note JS-thread callers are exempt.

    Extended reasoning...

    What

    schedule_dispatch at napi_body.rs:2824 is documented as:

    Caller must hold lock. Reached from addon threads (enqueue, release_locked), so it may only take a shared &EventLoop: the JS thread can be inside tick() with its own &mut at the same time.

    This PR adds a new caller in dispatch_one at line 2697 — the backup dispatch for the nested-event-loop case — which invokes self.schedule_dispatch() after the lock guard has been dropped.

    Step-by-step

    1. dispatch_one enters the 'brk block and takes the lock at line 2658: let _g = self.lock.lock_guard();.
    2. The block computes (task, remaining) and break 'brks at line 2685.
    3. The 'brk block closes at line 2686, dropping _g and releasing the lock.
    4. Control reaches line 2688–2697, where remaining > 0 && !*scheduled_backup calls self.schedule_dispatch() — with the lock not held.

    So the doc comment's stated precondition is violated by a caller this PR introduces.

    Why the code is still correct

    The lock requirement exists to serialize schedule_dispatch's read of self.event_loop against env_teardown clearing it (per the field comment at ~line 2427: "Written under lock by env_teardown on the JS thread"). Addon-thread callers (enqueue, release_locked) genuinely need the lock for that. dispatch_one, however, runs only on the JS thread (via on_dispatch), and env_teardown also runs only on the JS thread, so they cannot execute concurrently and no race on event_loop is possible. The other fields schedule_dispatch touches (dispatch_state, inflight_dispatch_tasks) are atomic. So there's no functional bug.

    Why it's worth flagging

    Per the repo review guidelines ("Only comment what the code cannot say"), an invariant comment that a caller visibly violates is a maintenance hazard in two directions: a future reader will either (a) flag line 2697 as a locking bug and "fix" it by moving the call back under _g (harmless but pointless), or worse (b) conclude the lock requirement is vestigial and drop it for the addon-thread callers too — which would introduce a real race with env_teardown.

    Suggested fix

    Update the doc comment on schedule_dispatch to something like:

    Addon-thread callers (enqueue, release_locked) must hold lock so the event_loop read is serialized against env_teardown clearing it. JS-thread callers (dispatch_one's backup dispatch) don't need the lock — env_teardown is JS-thread-only and cannot race.

    Nit severity — no functional bug, just a doc/code mismatch introduced by the PR.

dispatch_one and call now take *mut Self and form only scoped reborrows
that end before any user JS runs (the callback, the microtask drain), since
a nested event loop can re-enter on_dispatch for the same function. No
&mut ThreadSafeFunction is live across a JS entry anymore.
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

source-lints failing

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in the latest push: the fn-long &mut *this reborrow in dispatch_one (the shape test/internal/source-lints/fn-long-mut-reborrow.test.ts bans) is replaced by a call-scoped (*this).take_one_locked(..) method for the locked dequeue. source-lints passes locally (80 pass, 0 fail), clippy stays clean, tests re-verified.

Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs Outdated
…item

The between-callbacks microtask drain ran after the next item was already
popped, so a drain that blocked in a nested event loop stranded that item:
later items were delivered out of push order, and with nothing left in the
queue the nested wait could never settle. Move the drain ahead of the
dequeue in dispatch_one so a blocked drain leaves the item reachable for a
nested dispatch, in FIFO order. call no longer enters JS before the
callback and loses its is_first/Result plumbing.

Covers the ordering with a new fixture case that blocks in a microtask
between two queued calls.
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
A push landing in the unbacked Pending window between drain-loop iterations
coalesced with no task queued; if the next iteration's microtask drain then
blocked, the item was unreachable. Enqueue whenever no dispatch task is in
flight, regardless of dispatch state, so every push is backed by a task or
by one already queued. Also drop the stale caller list on loop_mut.
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs

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

Beyond the inline nit: re-traced my prior Pending-window scenario against 69712df's inflight-only gate in schedule_dispatch — the push at step 4 now sees inflight==0 under lock and enqueues, so that ordering is closed. Also checked that maybe_queue_finalizer's new inflight increment is balanced on the env-torn-down early return and by on_dispatch's unconditional decrement. The gate evidence showing the test passing on release-without-fix was examined — the coalescing is timing-dependent and only the debug/ASAN lane hits it deterministically.

Extended reasoning...

d7cb10b is a doc-only follow-up to my last nit; the substantive logic hasn't changed since 69712df. This is still a concurrency-heavy change to TSFN dispatch (atomics across addon/JS threads, re-entrant on_dispatch, deferred-destroy accounting via dispatch_depth/pending_destroy/inflight_dispatch_tasks) that has needed three correction rounds to converge — a maintainer should be the one to sign off. Recording what this pass covered so it isn't re-derived.

Comment thread test/napi/tsfn-nested-wait.test.ts

@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 this pass — all prior inline comments (aliasing UB in the re-entrant dispatch, FIFO reordering across the microtask drain, the unbacked-Pending window, stale doc comments, and the mtime rebuild gate) have been addressed. Deferring to a human because this rewrites the core TSFN dispatch loop for re-entrancy: raw-pointer access across nested on_dispatch frames, deferred destroy gated on dispatch_depth + inflight_dispatch_tasks, and the schedule_dispatch gate now keyed solely on the atomic in-flight count under lock.

Checked this round: schedule_dispatch is only reached while lock is held (enqueue, release_locked, take_one_locked), so the load-then-fetch_add on inflight_dispatch_tasks cannot race another scheduler; the env_dead early return in on_dispatch skips the deferred-destroy path but env_teardown owns the free there; and maybe_queue_finalizer's enqueue_task is now counted in inflight_dispatch_tasks so a Closed check in a later task defers correctly.

Extended reasoning...

Overview

The PR reworks ThreadSafeFunction dispatch in src/runtime/napi/napi_body.rs so queued calls keep draining when a callback (or the between-callbacks microtask drain) blocks in a nested event loop — the #36828 deadlock. It adds three fields (dispatch_depth, pending_destroy, inflight_dispatch_tasks), converts dispatch_one/call to raw-*mut Self so no &mut spans re-entrant user JS, moves the node#38506 microtask drain ahead of the dequeue, extracts the locked dequeue into take_one_locked (which schedules a backup dispatch when items remain and none is in flight), and rewrites schedule_dispatch to gate purely on inflight_dispatch_tasks under lock. A new native addon target plus a spawned bun test fixture cover the callback-blocks, queued-behind, and microtask-blocks orderings.

Security risks

None user-facing. The risk surface is memory safety and cross-thread lifetime: multiple queued concurrent tasks now reference the same heap allocation, and destroy is deferred until the outermost frame with zero in-flight tasks. The PR description notes ASAN caught a UAF in an earlier revision without the deferral; the current diff's pending_destroy + dispatch_depth == 0 && inflight == 0 gate is the mechanism that closes it. The env_dead early-return path intentionally does not free — env_teardown owns that.

Level of scrutiny

High. This is exactly the REVIEW.md "most-blocked category": raw-pointer re-entrancy, refcount-like balancing across every terminal path, and an atomic shared with addon threads. The change went through several rounds here already (aliasing UB, dropped-item across nested finalize, FIFO reorder, unbacked-Pending race), each of which the author fixed. That iteration history and the concurrency subtlety are why a maintainer should read the final shape rather than relying on automated review alone.

Other factors

All prior inline findings from earlier passes are resolved in the current diff. The evidence block shows the new test deadlocks on an ASAN debug build without the fix and passes with it (release-without-fix passing is expected — the bug is timing-dependent). The existing test/napi/napi.test.ts -t threadsafe suite was re-run per the description. The test harness follows the sibling napi.test.ts conventions after the mtime-gate follow-up. Nothing outstanding from human reviewers beyond Jarred's "plz fix" requests, all of which were actioned.

@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 bugs found this pass, but this reworks the TSFN dispatch/destroy state machine (raw *mut Self re-entrancy, three new lifetime-tracking fields, atomics gating destroy) — worth a maintainer's eyes before merge.

Checked: inflight_dispatch_tasks is balanced on every enqueue path (schedule_dispatch, maybe_queue_finalizer's add/sub-on-None, on_dispatch's fetch_sub); destroy is deferred correctly when a nested frame observes Closed (traced the finalize-task-during-drain ordering); the schedule_dispatch gate change closes the unbacked-Pending window without unbounded task growth (extra tasks drain as no-ops); AsyncTaskTracker is Copy so the copied-out (*this).tracker in call() is equivalent to the old self.tracker. Earlier findings (FIFO reorder, drain-before-backup gap, stale caller lists, mtime rebuild gate) are all addressed in the current diff.

Extended reasoning...

Overview

The PR changes ~180 lines in src/runtime/napi/napi_body.rs to fix #36828: threadsafe function calls deadlock when a callback (or the between-callbacks microtask drain) blocks in a nested event loop under bun test. It adds three fields to ThreadSafeFunction (dispatch_depth, pending_destroy, inflight_dispatch_tasks), converts dispatch_one/call from &mut self to raw-*mut Self associated fns to permit re-entrancy, moves the microtask drain ahead of the dequeue, rewrites schedule_dispatch to gate solely on the in-flight task count, and defers destroy until the outermost frame with zero in-flight tasks. New addon target + fixture cover three orderings (concurrent push, queued-behind, microtask-between).

Security risks

None identified. This is internal event-loop scheduling; no user input parsing, no auth/crypto/permissions surface.

Level of scrutiny

High. Per REVIEW.md this is squarely in the most-blocked category: native memory safety with manual free (destroy/free_orphaned), reference-count-like balancing on every terminal path (inflight_dispatch_tasks), thread-affinity rules (addon threads vs JS thread, lock-held vs not), and re-entrancy through user JS. The correctness of "whoever is last out frees it" now depends on the interaction of five pieces of state (dispatch_state, closing, dispatch_depth, pending_destroy, inflight_dispatch_tasks) across nested frames and concurrent addon-thread pushes. Three earlier review passes on this PR each found real issues (FIFO ordering violation, unbacked-Pending race after the drain move, stale doc invariants), which is a signal the state space is subtle enough to warrant a human owner's read of the final shape.

Other factors

  • I traced the destroy-deferral through several orderings (nested frame sees Closed with depth>0 → sets pending_destroy; outer frame frees at depth→0 with inflight==0; multiple queued tasks after Closed each defer until the last) and did not find a UAF or double-free.
  • schedule_dispatch is serialized under lock and inflight_dispatch_tasks is atomic; the load-then-add there is not a TOCTOU because all callers hold lock. The unlocked fetch_sub at the top of on_dispatch can race with the load, but the outcome is at most one extra no-op task, not a lost wakeup.
  • maybe_queue_finalizer now increments inflight before loop_mut() and compensates with a fetch_sub on the env-torn-down path — balanced.
  • The call() refactor copies the callback target into a local Target enum before invoking JS so no &*this borrow spans the user call; tracker is #[derive(Copy)] (just a u64 id) so the by-value copy is behavior-preserving.
  • Tests: the new fixture is spawned as a subprocess with pipes drained concurrently, uses it.skipIf(!canBuildNodeAddons()), and the mtime-gated rebuild matches the sibling napi.test.ts convention. All prior inline findings from this reviewer are resolved in the current head.

Given the complexity and that Jarred has been actively steering this PR, deferring rather than auto-approving.

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.

NAPI promise never settles under bun test on 1.4 canary (works on 1.3.13, and works under bun run)

3 participants