Skip to content

fix(ext/napi): don't resurrect a released threadsafe function in acquire - #36054

Merged
bartlomieju merged 1 commit into
mainfrom
fix/napi-tsfn-acquire-resurrection
Jul 15, 2026
Merged

bartlomieju merged 1 commit into
mainfrom
fix/napi-tsfn-acquire-resurrection

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Problem

The Windows panic reported in #35990 / #36047 still reproduces on canaries after the fix in #36032:

thread 'tokio-runtime-worker' panicked at ext/napi/node_api.rs:947:5:
assertion failed: self.is_closed.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed).is_ok()

#36032 closed the abort-frees-early path, but left a second way to double-free that it didn't cover: napi_acquire_threadsafe_function.

acquire did a plain fetch_add on thread_count after a non-atomic is_closing check:

pub fn acquire(&self) -> napi_status {
    if self.is_closing.load(Ordering::SeqCst) {
        return napi_closing;
    }
    self.thread_count.fetch_add(1, Ordering::Relaxed);
    napi_ok
}

This races the final release:

  1. thread_count == 1. Thread R calls release: fetch_update does 1 -> 0 (result == Ok(1), will free), but hasn't set is_closing yet.
  2. Thread Q calls acquire: is_closing still false (R sets it only after the fetch_update), so Q does fetch_add: 0 -> 1 and believes it holds a live ref.
  3. R sets is_closing, spawns the drop β†’ box freed.
  4. Q later calls release: fetch_update 1 -> 0, result == Ok(1) β†’ spawns a second drop of the already-freed box β†’ use-after-free + double free β†’ the is_closed assert panics.

This shows up under heavy concurrent addons β€” notably napi-rs's ThreadsafeFunction, which calls acquire on every clone β€” during teardown, exactly matching the reporter's "deno teardown crash after pass" logs.

Fix

acquire now uses a fetch_update CAS that refuses to increment thread_count from zero, returning napi_closing instead. Zero becomes a one-way terminal state, so exactly one release ever observes the 1 -> 0 transition and the box is freed exactly once. This matches Node.js, where acquiring a closing tsfn fails.

Test

test_tsfn_acquire_release_race races an acquire against the sole owning release (looped from JS to hit the narrow window). Verified locally:

  • Before the fix: deno test async_test.js crashes with SIGSEGV (exit 139) on this test.
  • After the fix: all 9 async tests pass, repeatedly.

Refs #35990
Closes #36047

`napi_acquire_threadsafe_function` did a plain `fetch_add` on `thread_count`
after a non-atomic `is_closing` check. That races the final
`napi_release_threadsafe_function`: if `acquire` increments the count from 0
back to 1 in the window after the final release read zero but before it marked
the tsfn closing, it resurrects a tsfn that is already being freed. The
resurrecting thread's own later release then observes the `1 -> 0` transition a
second time and spawns a second drop of the same box β€” a use-after-free and
double free, which trips the `is_closed` assertion in `TsFn::drop`:

    assertion failed: self.is_closed
      .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
      .is_ok()

This is the residual case not covered by #36032, which fixed the abort path but
left `acquire` able to bring the count back from zero. It surfaces under heavy
concurrent addons (e.g. napi-rs `ThreadsafeFunction`, which acquires on every
clone) during teardown, matching the Windows panic reported in #35990 / #36047.

`acquire` now uses a `fetch_update` CAS that refuses to increment from zero,
returning `napi_closing` instead. Zero becomes a one-way terminal state, so
exactly one release ever observes the `1 -> 0` transition and the box is freed
exactly once. This matches Node.js, where acquiring a closing tsfn fails.

Adds a regression test that races an acquire against the sole owning release;
it crashes (SIGSEGV / double-free assert) on the previous code and passes now.

Closes #36047
Refs #35990
@bartlomieju
bartlomieju enabled auto-merge (squash) July 15, 2026 10:27
@bartlomieju
bartlomieju merged commit 58d4d24 into main Jul 15, 2026
268 of 270 checks passed
@bartlomieju
bartlomieju deleted the fix/napi-tsfn-acquire-resurrection branch July 15, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant