Repository navigation
fix(ffi/objc): keep bridge objects in their own runtime and survive its teardown - #88
NathanWalker wants to merge 5 commits into
Conversation
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.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the
|
|
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
Suggested simpler fix
And to fix the actual cause:
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.
|
The bug is real related to the runtime teardown leak which as I would suspect too affects hot reloads/worklets churn. 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.
…bjc-bridge-runtime-teardown
|
Thanks, I reworked it along those lines. Teardown (your 1–5)
Actual cause
Debug Hermes: there's no assert-enabled prebuilt for 250829098.0.17, so I read 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 The branch also merges |
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:
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::HostObjectbetween 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:Objects never cross runtimes
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
Runtimeidentity, and Worklets is Hermes only.runOnUIhands back handles.NativeScript.runOnUIconvertsNSObjectresults to handles inside the worklet. That covers the top level, plain arrays and plain objects. They are the same handle stringsnativeViewHandleuses, 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
jsi::Runtime::setRuntimeData. The binding lives in the Hermes backend (hermes/NativeApiJsi.mm), soffi/objc/sharedstays 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.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.NSTimerthat fires across a reload. That call logsNativeScript: <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.android-react-native)gc()in both runtimesNSTimerblock firing after the reload (with and without GC)NativeScript.runOnUIreturning{ view, nested: { list: [dict, 7, 'text'] } }awaitthrow the message above;interop.object(interop.handleof(v))and native arguments workhermes.cppathermes-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.npm run check:ffi-boundariespasses.Not verified:
runOnUIis 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)
retainedLifetimes_), andgNativeApiClassBuilders.runOnUIdoesn't convert structs, pointers, classes orNSProxyresults. 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.runOnUIresultsconsole.logorawaitof a native object returned by a raw WorkletsrunOnUIAsync.Runtime&per runtime. That is, a host object's callbacks get the samejsi::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.