Repository navigation
fix(ext/napi): don't resurrect a released threadsafe function in acquire - #36054
Merged
Merged
Conversation
`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
enabled auto-merge (squash)
July 15, 2026 10:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Windows panic reported in #35990 / #36047 still reproduces on canaries after the fix in #36032:
#36032 closed the abort-frees-early path, but left a second way to double-free that it didn't cover:
napi_acquire_threadsafe_function.acquiredid a plainfetch_addonthread_countafter a non-atomicis_closingcheck:This races the final
release:thread_count == 1. Thread R callsrelease:fetch_updatedoes1 -> 0(result == Ok(1), will free), but hasn't setis_closingyet.acquire:is_closingstillfalse(R sets it only after thefetch_update), so Q doesfetch_add:0 -> 1and believes it holds a live ref.is_closing, spawns the drop β box freed.release:fetch_update1 -> 0,result == Ok(1)β spawns a second drop of the already-freed box β use-after-free + double free β theis_closedassert panics.This shows up under heavy concurrent addons β notably napi-rs's
ThreadsafeFunction, which callsacquireon every clone β during teardown, exactly matching the reporter's "deno teardown crash after pass" logs.Fix
acquirenow uses afetch_updateCAS that refuses to incrementthread_countfrom zero, returningnapi_closinginstead. Zero becomes a one-way terminal state, so exactly one release ever observes the1 -> 0transition and the box is freed exactly once. This matches Node.js, where acquiring a closing tsfn fails.Test
test_tsfn_acquire_release_raceraces anacquireagainst the sole owningrelease(looped from JS to hit the narrow window). Verified locally:deno test async_test.jscrashes with SIGSEGV (exit 139) on this test.Refs #35990
Closes #36047