Skip to content

fix(ffi/objc): keep bridge objects in their own runtime and survive its teardown - #88

Open
NathanWalker wants to merge 5 commits into
android-react-nativefrom
fix/objc-bridge-runtime-teardown
Open

NathanWalker wants to merge 5 commits into
android-react-nativefrom
fix/objc-bridge-runtime-teardown

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Important

Squash merge this PR. The first commit (931c15e) describes a value registry that review replaced, and the branch also contains a merge of android-react-native. Use this as the squash commit message (title + body), replacing GitHub's default list of commit messages:

fix(ffi/objc): keep bridge objects in their own runtime and survive its teardown

react-native-worklets shares jsi::HostObjects between runtimes by reference,
so a native object returned from a worklet reached the React Native runtime
still bound to the UI runtime's bridge. Using it mixed the two heaps, and
finalizing it after the UI runtime was gone crashed on reload.

- Bridge host objects throw when used from a runtime other than their
  bridge's (Hermes), pointing at interop.handleof / interop.object.
- NativeScript.runOnUI converts NSObject results to handles inside the
  worklet and adopts them in the caller's runtime.
- On Hermes each bridge is bound to its runtime with setRuntimeData. When
  the runtime is destroyed, the bridge's caches are cleared and later
  inserts are no-ops; objects that own a Value abandon it instead of
  releasing it into the freed heap; blocks and method implementations
  called afterwards log and return zero.

Replaces the iOS teardown part of #77 (item 2 of the "NativeScript bugs" list in ng-native/ng-native#931). Reworked after review: there is no value registry. This PR stops native objects from crossing runtimes, and keeps teardown safe for the case that can't be prevented.

Summary

react-native-worklets shares every jsi::HostObject between runtimes by reference, before any custom serializer runs. So a native object returned from a worklet arrived in the React Native runtime still bound to the UI runtime's bridge. Using it there mixed the two heaps. If it was finalized after the UI runtime was gone, its destructor released the UI bridge's cached Values into a destroyed runtime:

reload
  UI runtime destroyed
  JS runtime destroyed
    HadesGC::finalizeAll
      ~NativeApiObjectHostObject      (UI bridge's object, shared into JS)
        NativeApiBridge::forgetRoundTripValue / forgetObjectExpandos
          release Value  →  EXC_BAD_ACCESS

Objects never cross runtimes

  • Bridges know their runtime. A bridge remembers the runtime it was installed into. Reading, writing or listing the properties of its objects from any other runtime throws:
    This NativeScript object belongs to another JavaScript runtime, such as the Worklets UI runtime. Pass a handle instead: interop.handleof(object).toHexString() in the runtime that owns it, then interop.object(handle) in this one.
    Passing such an object as a native argument still works, because that only reads the native pointer. The check is Hermes only: the other engines have no stable Runtime identity, and Worklets is Hermes only.
  • runOnUI hands back handles. NativeScript.runOnUI converts NSObject results to handles inside the worklet. That covers the top level, plain arrays and plain objects. They are the same handle strings nativeViewHandle uses, retained once in transit. The JS side adopts them, so the caller gets objects of its own runtime.

Teardown (Hermes), for objects finalized late by the other runtime

  • The runtime-data binding. Each bridge is bound to its runtime with jsi::Runtime::setRuntimeData. The binding lives in the Hermes backend (hermes/NativeApiJsi.mm), so ffi/objc/shared stays engine-neutral; the other engines get a no-op. The deleter runs in ~HermesRuntimeImpl(), before the heap is freed. It clears the bridge's caches and marks the runtime gone. After that, cache inserts are no-ops and lookups miss.
  • Owned Values are abandoned. A few objects own a JS Value: struct, pointer and reference host objects, and NativeApiCallback. Their destructors check the mark, and once the runtime is gone they abandon the Value instead of releasing it, because Hermes freed it with the heap. Nothing is added on construction paths.
  • Late native calls return zero. Native code can call a block or method implementation after its runtime is gone, for example an NSTimer that fires across a reload. That call logs NativeScript: <selector|block> called after its JavaScript runtime was destroyed; returning zero/nil. and returns zero/nil.

Other engines and trees: V8, QuickJS and JSC behave as before. The napi tree is untouched.

Evidence

iOS 27 simulator, RN 0.86.3 / Expo, Hermes 250829098.0.17. The app is an APFS clone of the testbed with bridge sources from this branch. Each run is the first load plus 6 self-reloads.

Scenario Base (android-react-native) This branch
Foreign view + expando held in a JS global, gc() in both runtimes Crash on 1st reload (the reported stack) 7 loads, 0 crash reports
Same, no GC Crash on 1st reload 7 loads, 0 crash reports
NSTimer block firing after the reload (with and without GC) Crash on 1st reload 7 loads, 0 crash reports, 6 log lines
NativeScript.runOnUI returning { view, nested: { list: [dict, 7, 'text'] } } Crash on 1st reload JS-owned objects; reads and expandos work, also after UI GC; 0 crash reports
Foreign object used from JS silently mixes heaps get/set/keys/method call/await throw the message above; interop.object(interop.handleof(v)) and native arguments work
  • Debug Hermes. No assert-enabled prebuilt exists for this version. In hermes.cpp at hermes-v250829098.0.17, Debug builds (ASSERT_ON_DANGLING_VM_REFS) mark Values that are still referenced at destruction as dangling, and keep their list alive. They assert only when such a Value is released later. So abandoning a Value keeps that assert quiet; releasing it would trip the assert, and in Release it is a use-after-free.
  • Compile and CI checks.
    • Syntax-only compile for the iOS simulator: Hermes (standalone and RN), JSC and QuickJS have 0 errors.
    • V8 can't be checked locally, because the generated dispatch file is missing.
    • npm run check:ffi-boundaries passes.

Not verified:

  • A run on assert-enabled Hermes.
  • Full V8, JSC and QuickJS builds.
  • Physical devices.
  • Whether an object handed back by runOnUI is freed once JS drops it. The check was inconclusive, because an object created in a worklet and never returned also stays alive on the UI side.

Follow-ups (not in this PR)

  • D. Two things still keep a UI runtime's bridge alive across reloads when they are used: JS-implemented ObjC methods and function-pointer callbacks (retainedLifetimes_), and gNativeApiClassBuilders.
  • runOnUI doesn't convert structs, pointers, classes or NSProxy results. They still arrive shared, and now throw on use.

Merge Danger

Door: two-way

Blast Radius: bridge teardown and host-object access on Hermes (RN and standalone); NativeScript.runOnUI results

  • Accidental cross-runtime use now throws instead of silently mixing heaps. For example, console.log or await of a native object returned by a raw Worklets runOnUIAsync.
  • The ownership check runs on every access. It is one pointer compare at each host-object entry point.
  • It assumes one Runtime& per runtime. That is, a host object's callbacks get the same jsi::Runtime& the bridge was installed with. That holds for the RN 0.86 Debug and Worklets runtimes tested here. A host that wraps the runtime in a decorator would see every access throw.

react-native-worklets shares host objects between runtimes, so a native
object returned from runOnUI is finalized by the JS runtime, possibly after
the UI runtime is gone. Its destructor released the UI bridge's expandos
and caches into the destroyed runtime (EXC_BAD_ACCESS in
NativeApiBridge::forgetObjectExpandos during HadesGC::finalizeAll), and a
block or method implementation called by native code after a reload
entered the destroyed runtime.

Each bridge is now bound to its runtime through jsi runtime data, which the
runtime destroys while its Values can still be released (Hermes before its
heap, jsi's default implementation from a finalizer of that runtime's own
global). At that point the bridge releases its caches and the Values held
by struct, pointer and reference host objects and by native callbacks, and
stops caching new ones. Host objects finalized later in another runtime
find nothing to release. A callback invoked afterwards logs the selector or
block and returns zero/nil instead of entering the runtime.

Only the Hermes/JSI build has this hook; the V8, QuickJS and JSC engines
keep their current behaviour.
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 87af5141-1da7-4a84-8d19-b496b45df9c8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@ammarahm-ed

ammarahm-ed commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

On iOS, NativeScript objects aren't meant to cross between the JS runtime and the Worklets UI runtime. Our own APIs never do it. Views go back to JS as handle strings and are turned back into views natively.

The problem is that Worklets shares host objects by reference: it wraps the same native object again in the other runtime. That check runs before any custom serializer, so we can't intercept it. Anything a worklet returns or captures that is a NativeScript object ends up shared without anyone asking for it. Nothing on our side guards against that. The shared object keeps using the UI runtime's bridge while creating values in the JS runtime, so the two heaps get mixed. The reload crash is one result of that.

So the fix should be to stop that from happening silently, not to make the runtime teardown handle every value that might have leaked across.

Why this approach is too much

  • It adds a global registry of every value held outside the caches, guarded by a mutex, with periodic pruning.
  • That registration sits on construction paths: callbacks, pointer and reference objects, and nested struct access like rect.origin. That last one is hot in layout-style code. It's a small cost each time, but it's paid all the time, for a case that only matters after a reload.
  • At teardown, it moves values out from under objects that may still be alive in the other runtime, on whatever thread is destroying the runtime.
  • Neither of your repros needs it. The expando crash is fixed by clearing the caches, and the timer crash by the callback check.

Suggested simpler fix

  1. Keep the setRuntimeData binding. When it fires, set a "runtime gone" flag and clear the bridge's caches.
  2. Keep the cache functions as no-ops once the flag is set.
  3. Keep the callback check: after teardown, log and return zero.
  4. In the few objects that own a JS value, check the flag in the destructor. If the runtime is gone, don't release the value. Hermes frees that heap with the runtime anyway, so nothing real is leaked.
  5. Drop the value registry entirely.

And to fix the actual cause:

  • Bridge objects remember which runtime they belong to, and throw a clear error when used from another one, something like "this object belongs to the UI runtime; pass a handle instead".
  • runOnUI converts native results to handles inside the worklet before returning them, so the common case never shares anything.

That turns accidental sharing into a clear error, keeps teardown safe for the one case we can't prevent (an object finalized late by the other runtime), and leaves the hot paths untouched.

One thing to check: a Debug build run to confirm Hermes doesn't complain at teardown about values that were never released.

@DjDeveloperr Can you take a look? I reviewed this with the help of AI, the change as it stands looks a bit invasive to me even though the fix is needed, there must be a simpler way to handle this.

… destroyed runtime

The runtime-data binding now only clears the bridge's caches and marks the
runtime gone. Objects that own a Value (struct, pointer and reference host
objects, callbacks) check that mark in their destructor and leave the Value
alone once the runtime is destroyed, since its heap went with it. Nothing is
registered on construction paths any more.

The binding itself moves to the Hermes backend behind
NATIVESCRIPT_NATIVE_API_HAS_ENGINE_RUNTIME_TEARDOWN, so ffi/objc/shared stays
engine-neutral.
@DjDeveloperr

Copy link
Copy Markdown
Collaborator

The bug is real related to the runtime teardown leak which as I would suspect too affects hot reloads/worklets churn.
Good catch by Ammar on the wrong implementation... needs better destruction handling. A global registry with mutex sounds bad.

And yes by design the objects should not be blindly shared across threads - while totally allowed in practice and sometimes even necessary for concurrency. It's just like native. If you do multi threaded, make sure you handle it well. If there is a teardown crash happening due to it, I would say it's an application bug not runtime - the runtime should not place mutexes in hot path to work around it.

If the error originates solely from UI worker impl then we can add thread-safe handling there.

Worklets shares host objects between runtimes by reference, so a native
object returned from a worklet reaches the React Native runtime still tied to
the UI runtime's bridge, and using it there mixes the two heaps. Each bridge
now remembers the runtime it was installed into (Hermes only, from the same
runtime-data binding), and its host objects throw a clear error, pointing at
handles, when they are read, written or enumerated from any other runtime.
Passing such an object as a native argument still works, since that only
reads the native pointer.
Worklets would share a returned native object by reference, leaving the React
Native runtime with a wrapper bound to the UI runtime's bridge. runOnUI now
runs the worklet through a wrapper that replaces each NSObject in the result
(top level, plain arrays and plain objects) with its handle, retained once,
and the JS side adopts that retain with interop.Pointer(...).takeRetainedValue(),
so the caller gets objects of its own runtime and nothing is shared. This is
the same handle-string mechanism nativeViewHandle uses.
@NathanWalker

Copy link
Copy Markdown
Contributor Author

Thanks, I reworked it along those lines.

Teardown (your 1–5)

  1. Binding. setRuntimeData is still the trigger. It clears the caches and sets the runtime-gone flag. The binding moved into the Hermes backend, which also fixes the check:ffi-boundaries CI failure.
  2. Caches. Cache inserts are no-ops after teardown.
  3. Callbacks. A callback after teardown logs and returns zero.
  4. Destructors. Struct, pointer and reference host objects and NativeApiCallback check the flag in their destructors. After teardown they abandon the Value instead of releasing it.
  5. Registry. The value registry, its mutex and its pruning are gone. Nothing new runs on construction paths, including rect.origin.

Actual cause

  • Bridges remember their runtime. Getting, setting or listing properties from another runtime throws "This NativeScript object belongs to another JavaScript runtime… Pass a handle instead". Passing such an object as a native argument still works, because that only reads the pointer. This is Hermes only, since the other engines have no stable Runtime identity.
  • runOnUI returns handles. NativeScript.runOnUI converts NSObject results inside the worklet: top level, plain arrays and plain objects. They use the same handle strings as nativeViewHandle, retained once in transit, and the JS side adopts them.

Debug Hermes: there's no assert-enabled prebuilt for 250829098.0.17, so I read hermes.cpp at that tag. With ASSERT_ON_DANGLING_VM_REFS, it asserts only when a dangling Value is released after teardown, never for one left unreleased. So abandoning the Value is the right side of that assert.

Simulator: I ran 6 reloads per scenario. Every scenario that crashes on the base's first reload completes all 6 here with no crash reports. That covers a held foreign view with GC, an NSTimer across a reload, and runOnUI returning nested native objects. The table is in the description.

The branch also merges android-react-native to pick up 066a38e (bridge-id keyed caches). Bridges are actually freed at reload now, so without that fix a new bridge at a reused address hit stale cache entries in testing.

@NathanWalker NathanWalker changed the title fix(ffi/objc): release a bridge's Values when its runtime is destroyed fix(ffi/objc): keep bridge objects in their own runtime and survive its teardown Oct 25, 2026

This branch has not been deployed

No deployments
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.

3 participants