Skip to content

fix: make static per-Runtime caches thread-safe (fatal "jsi::Function has already been deleted" crashes) - #1451

Open
jslok wants to merge 2 commits into
mrousavy:mainfrom
jslok:fix/thread-safe-static-runtime-caches
Open

fix: make static per-Runtime caches thread-safe (fatal "jsi::Function has already been deleted" crashes)#1451
jslok wants to merge 2 commits into
mrousavy:mainfrom
jslok:fix/thread-safe-static-runtime-caches

Conversation

@jslok

@jslok jslok commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Nitro keeps five static unordered_maps keyed by jsi::Runtime* with no locking: JSICache::_globalCache, Dispatcher::_globalCache, CommonGlobals::_cache, PropNameIDCache::_cache, HybridObjectPrototype::_prototypeCache. Apps running multiple Runtimes (the RN runtime + react-native-worklets runtimes, e.g. VisionCamera frame processors) read and mutate these concurrently from different threads — and concurrent unordered_map mutation is UB. (HybridObjectPrototype::ensureInitialized already has a mutex "in case we try to create HybridObjects in parallel Runtimes", but the maps themselves are unguarded.)

We are seeing this crash in production (Android arm64, RN 0.86 bridgeless, release builds): fatal Non-js exception: Cannot call SyncJSCallback<void()> - the underlying jsi::Function has already been deleted! (also the void(const std::string&) variant), always shortly after additional worklet runtimes spin up, plus occasional [libNitroModules.so] SIGSEGVs consistent with the same race.

Mechanism (worst case)

  1. Thread A (worklet runtime init) inserts into JSICache::_globalCache, triggering a rehash, while thread B (main runtime) runs getOrCreateCachefind() spuriously misses the live entry.
  2. Thread B creates a duplicate JSICache for the main runtime. In release, defineGlobal is a plain setProperty, so __nitroJsiCache is silently replaced (debug throws, so the race never reproduces in dev builds).
  3. The orphaned cache is GC'd and ~JSICache() force-destroys every jsi::Function created through it — while the runtime is alive and healthy.
  4. The next stored callback fired from native throws into the RuntimeScheduler → app-killing fatal. (A queued callAndForget invocation always holds a strong BorrowingReference copy, so cache destruction is the only way _function can be null under it.)

Fix

Commit 1 — a mutex per static map, scoped strictly to the map operations and never held across a JSI call (getOrCreateCache is re-entrant via defineGlobalgetGlobalFunctiongetOrCreateCache). For the nested caches only the outer map[&runtime] slot acquisition is locked: the inner per-Runtime map is only ever touched from that Runtime's own thread, and unordered_map value references are stable across rehash. Hot paths pay an uncontended lock around a lookup — behavior otherwise unchanged.

Commit 2 (separable if you'd rather discuss it) — callAndForget checks a new SyncJSCallback::isAlive() inside the dispatched lambda and logs-and-skips a force-deleted function instead of throwing an uncaught fatal. It is fire-and-forget — results/completions are ignored by contract — and this also covers the legitimate case of a queued invocation draining after Runtime teardown. The Promise-returning call() path is unchanged (its throw surfaces as a catchable rejection).

Not touched, audited as safe: Prototype::get's cache (all mutations serialized by ensureInitialized's mutex) and NitroTypeInfo::replaceRegex's regex cache (debug-only code path).

Possibly related: #1104.

🤖 Generated with Claude Code

jslok and others added 2 commits July 27, 2026 19:18
JSICache::_globalCache, Dispatcher::_globalCache, CommonGlobals::_cache,
PropNameIDCache::_cache and HybridObjectPrototype::_prototypeCache are
static unordered_maps keyed by jsi::Runtime* with no synchronization.
They are read and mutated concurrently from every Runtime's thread (RN
runtime + worklet runtimes), which is UB.

Worst case: a racy find() miss in JSICache::getOrCreateCache makes a
live Runtime create a DUPLICATE JSICache. In release, defineGlobal is a
plain setProperty that silently replaces __nitroJsiCache (debug throws),
so the orphaned cache gets GC'd and its destructor force-destroys every
jsi::Function created through it - any later invocation of a stored
callback then throws 'the underlying jsi::Function has already been
deleted!' into the RuntimeScheduler and crashes the app.

Each mutex is scoped to the map operations only and never held across a
JSI call (getOrCreateCache is re-entrant via defineGlobal ->
getGlobalFunction -> getOrCreateCache). For the nested caches only the
outer slot acquisition is locked: the inner per-Runtime map is only ever
touched from that Runtime's own thread, and unordered_map value
references stay valid across rehash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
If the underlying jsi::Function is force-deleted (JSICache/Runtime
teardown) between scheduling and execution, the dispatched lambda in
AsyncJSCallback::callAndForget threw a std::runtime_error that escalates
to an uncaught fatal in the target Runtime's scheduler.

callAndForget explicitly ignores results and completions, so add
SyncJSCallback::isAlive() and log-and-skip instead of crashing. The
Promise-returning call() path is unchanged (its throw surfaces as a
catchable rejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
nitro-docs Skipped Skipped Jul 28, 2026 2:19am

Request Review

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.

1 participant