From 931c15e610e90f7f71ca581b656e285901c7bdf3 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 24 Oct 2026 10:52:33 -0700 Subject: [PATCH 1/4] fix(ffi/objc): release a bridge's Values when its runtime is destroyed 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. --- .../ffi/objc/shared/bridge/Callbacks.mm | 30 +++++- .../ffi/objc/shared/bridge/Install.mm | 38 ++++++++ .../ffi/objc/shared/bridge/ObjCBridge.mm | 94 ++++++++++++++++++- .../ffi/objc/shared/bridge/TypeConv.mm | 3 + .../shared/bridge/host_objects/Interop.mm | 16 +++- .../objc/shared/bridge/host_objects/Struct.mm | 3 + 6 files changed, 175 insertions(+), 9 deletions(-) diff --git a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm index 0188e6e78..b8c87da30 100644 --- a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm +++ b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm @@ -403,6 +403,9 @@ Function persistentEngineFunction(Runtime& runtime, const Function& function) { threadPolicy_(threadPolicy), bindThis_(bindThis), roundTripValidationKey_(roundTripValidationKey) { + if (bridge_ != nullptr) { + bridge_->holdRuntimeValue(function_); + } closure_ = static_cast( ffi_closure_alloc(sizeof(ffi_closure), &executable_)); if (closure_ == nullptr || executable_ == nullptr || @@ -483,7 +486,7 @@ void retainBlockCopy(const void* blockPointer) { } auto self = shared_from_this(); if (bridge_ != nullptr && runtime_ != nullptr && function_ != nullptr && - blockPointer != nullptr) { + blockPointer != nullptr && !bridge_->runtimeValuesReleased()) { bridge_->rememberRoundTripValue(*runtime_, blockPointer, Value(*runtime_, *function_), false, roundTripValidationKey_); @@ -553,7 +556,7 @@ bool releaseBlockCopy(const void* blockPointer) { } if (bridge != nullptr && runtime != nullptr && - pointerToForget != nullptr) { + pointerToForget != nullptr && !bridge->runtimeValuesReleased()) { NativeApiRuntimeScope runtimeScope(*runtime); bridge->forgetRoundTripValue(*runtime, pointerToForget); } @@ -577,6 +580,9 @@ bool releaseBlockCopy(const void* blockPointer) { } void invoke(void* ret, void* args[]) { + if (runtimeDestroyed(ret)) { + return; + } if (runtime_ == nullptr || function_ == nullptr || signature_ == nullptr) { throwNativeApiCallbackException("Invalid callback."); } @@ -735,7 +741,27 @@ void invoke(void* ret, void* args[]) { } private: + // A native caller can keep a block or method implementation past its + // runtime (e.g. across a JavaScript reload); it gets a zero result instead. + bool runtimeDestroyed(void* ret) { + if (bridge_ == nullptr || !bridge_->runtimeValuesReleased()) { + return false; + } + std::string name = signature_ != nullptr ? signature_->selectorName : ""; + if (name.empty()) { + name = block_ ? "block" : "native callback"; + } + NSLog(@"NativeScript: %s called after its JavaScript runtime was " + @"destroyed; returning zero/nil.", + name.c_str()); + zeroReturnValue(ret); + return true; + } + void invokeOnCurrentThread(void* ret, void* args[], std::string* error) { + if (runtimeDestroyed(ret)) { + return; + } try { NativeApiRuntimeScope runtimeScope(*runtime_); size_t nativeArgOffset = signature_->implicitArgumentCount; diff --git a/NativeScript/ffi/objc/shared/bridge/Install.mm b/NativeScript/ffi/objc/shared/bridge/Install.mm index 611747cf8..4a1f4d95f 100644 --- a/NativeScript/ffi/objc/shared/bridge/Install.mm +++ b/NativeScript/ffi/objc/shared/bridge/Install.mm @@ -1,5 +1,43 @@ +#ifdef TARGET_ENGINE_HERMES +// Runtime data is destroyed with the runtime while its Values can still be +// released: Hermes frees it before tearing down its heap, and jsi's default +// implementation frees it from a finalizer of that runtime's own global. +class NativeApiRuntimeBridges final { + public: + ~NativeApiRuntimeBridges() { + for (auto& bridge : bridges_) { + bridge->releaseRuntimeValues(); + } + } + + void add(std::shared_ptr bridge) { + bridges_.push_back(std::move(bridge)); + } + + private: + std::vector> bridges_; +}; + +constexpr facebook::jsi::UUID kNativeApiRuntimeBridgesUUID{ + 0x5f0e2c71, 0x8b3d, 0x4a6e, 0x9c41, 0x2d7a6b13e8f4}; + +void BindNativeApiBridgeToRuntime(Runtime& runtime, + std::shared_ptr bridge) { + auto bridges = std::static_pointer_cast( + runtime.getRuntimeData(kNativeApiRuntimeBridgesUUID)); + if (bridges == nullptr) { + bridges = std::make_shared(); + runtime.setRuntimeData(kNativeApiRuntimeBridgesUUID, bridges); + } + bridges->add(std::move(bridge)); +} +#endif + Object CreateNativeApi(Runtime& runtime, const NativeApiConfig& config) { auto bridge = std::make_shared(config); +#ifdef TARGET_ENGINE_HERMES + BindNativeApiBridgeToRuntime(runtime, bridge); +#endif return Object::createFromHostObject(runtime, std::make_shared(std::move(bridge))); } diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..1d99f278b 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -545,6 +545,79 @@ explicit NativeApiBridge(const NativeApiConfig& config) } } + // Called while the bridge's runtime is being destroyed, when its Values can + // still be released. Host objects can be shared into another runtime and + // outlive this one, so from here on the bridge holds no Values. + void releaseRuntimeValues() { + decltype(roundTripValues_) roundTripValues; + decltype(roundTripCacheFramesByThread_) roundTripFrames; + decltype(recentRoundTripValues_) recentRoundTripValues; +#ifdef TARGET_ENGINE_HERMES + std::shared_ptr roundTripRoot; +#endif + { + std::lock_guard lock(roundTripValuesMutex_); + runtimeValuesReleased_.store(true, std::memory_order_release); + roundTripValues.swap(roundTripValues_); + roundTripFrames.swap(roundTripCacheFramesByThread_); + recentRoundTripValues.swap(recentRoundTripValues_); + recentRoundTripValueOrder_.clear(); +#ifdef TARGET_ENGINE_HERMES + rootedRoundTripValues_.clear(); + roundTripRoot.swap(roundTripRootCache_); +#endif + roundTripValuesGeneration_.fetch_add(1, std::memory_order_release); + } + classValues_.clear(); + classPrototypes_.clear(); + pointerValues_.clear(); + objectExpandos_.clear(); + objectExpandoOwnerCounts_.clear(); + objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + + std::vector heldValues; + { + std::lock_guard lock(heldRuntimeValuesMutex_); + heldValues.swap(heldRuntimeValues_); + } + for (auto& held : heldValues) { + if (auto value = held.value.lock()) { + held.release(value.get()); + } + } + } + + bool runtimeValuesReleased() const { + return runtimeValuesReleased_.load(std::memory_order_acquire); + } + + // For Values owned outside the bridge's caches. They are released in place + // with the bridge's runtime, leaving the holder an empty Value or Function. + template + void holdRuntimeValue(const std::shared_ptr& value) { +#ifdef TARGET_ENGINE_HERMES + if (value == nullptr) { + return; + } + std::lock_guard lock(heldRuntimeValuesMutex_); + if (runtimeValuesReleased()) { + return; + } + if (heldRuntimeValues_.size() >= heldRuntimeValuesPruneSize_) { + heldRuntimeValues_.erase( + std::remove_if(heldRuntimeValues_.begin(), heldRuntimeValues_.end(), + [](const HeldRuntimeValue& held) { + return held.value.expired(); + }), + heldRuntimeValues_.end()); + heldRuntimeValuesPruneSize_ = + std::max(256, heldRuntimeValues_.size() * 2); + } + heldRuntimeValues_.push_back(HeldRuntimeValue{ + value, [](void* held) { T released(std::move(*static_cast(held))); }}); +#endif + } + MDMetadataReader* metadata() const { return metadata_.get(); } void* selfDl() const { return selfDl_; } @@ -621,7 +694,7 @@ void rememberRoundTripValue(Runtime& runtime, const void* native, const Value& value, bool stringLikeNative = false, uintptr_t validationKey = 0) { - if (native == nullptr) { + if (native == nullptr || runtimeValuesReleased()) { return; } uintptr_t key = @@ -666,7 +739,7 @@ void rememberScopedRoundTripValueWithValidationKey(Runtime& runtime, bool stringLikeNative, bool persistBeyondFrame, uintptr_t validationKey) { - if (native == nullptr) { + if (native == nullptr || runtimeValuesReleased()) { return; } uintptr_t key = @@ -890,7 +963,7 @@ void endRoundTripCacheFrame() { } void rememberClassValue(Runtime& runtime, Class cls, const Value& value) { - if (cls == Nil) { + if (cls == Nil || runtimeValuesReleased()) { return; } classValues_[normalizeRuntimePointer(reinterpret_cast(cls))] = @@ -910,7 +983,7 @@ Value findClassValue(Runtime& runtime, Class cls) const { } void rememberClassPrototype(Runtime& runtime, Class cls, const Value& value) { - if (cls == Nil) { + if (cls == Nil || runtimeValuesReleased()) { return; } classPrototypes_[normalizeRuntimePointer(reinterpret_cast(cls))] = @@ -931,7 +1004,7 @@ Value findClassPrototype(Runtime& runtime, Class cls) const { void setObjectExpando(Runtime& runtime, const void* native, const std::string& property, const Value& value) { - if (native == nullptr || property.empty()) { + if (native == nullptr || property.empty() || runtimeValuesReleased()) { return; } objectExpandos_[normalizeRuntimePointer(reinterpret_cast(native))] @@ -1095,6 +1168,9 @@ void cachePropertyGetter(Class cls, const std::string& property, void rememberPointerValue(Runtime& runtime, const void* native, const Value& value) { + if (runtimeValuesReleased()) { + return; + } pointerValues_[reinterpret_cast(native)] = std::make_shared(runtime, value); } @@ -2166,6 +2242,14 @@ static void appendSurfaceMember( std::thread::id jsThreadId_ = std::this_thread::get_id(); std::mutex retainedLifetimesMutex_; std::vector> retainedLifetimes_; + struct HeldRuntimeValue { + std::weak_ptr value; + void (*release)(void*); + }; + std::mutex heldRuntimeValuesMutex_; + std::vector heldRuntimeValues_; + size_t heldRuntimeValuesPruneSize_ = 256; + std::atomic runtimeValuesReleased_{false}; }; class NativeApiRoundTripCacheFrameGuard final { diff --git a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm index 908c144c6..5654e283b 100644 --- a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm +++ b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm @@ -1268,6 +1268,9 @@ throw JSError(runtime, "This native return type is not supported by " if (data_ == nullptr) { if (slotIndex == 0) { pendingValue_ = std::make_shared(runtime, value); + if (bridge_ != nullptr) { + bridge_->holdRuntimeValue(pendingValue_); + } NATIVE_API_SET_RETURN(true); } ensureStorage(runtime, type_, frame, slotIndex + 1); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm index 5913da68d..c230b1adb 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm @@ -10,7 +10,11 @@ pointer_(pointer), kind_(std::move(kind)), adopted_(adopted), - backingValue_(std::move(backingValue)) {} + backingValue_(std::move(backingValue)) { + if (bridge_ != nullptr) { + bridge_->holdRuntimeValue(backingValue_); + } + } ~NativeApiPointerHostObject() override { if (adopted_ && pointer_ != nullptr) { @@ -26,6 +30,9 @@ std::shared_ptr backingValue() const { return backingValue_; } void setBackingValue(Runtime& runtime, const Value& value) { backingValue_ = std::make_shared(runtime, value); + if (bridge_ != nullptr) { + bridge_->holdRuntimeValue(backingValue_); + } } bool adopted() const { return adopted_; } void adopt() { adopted_ = true; } @@ -183,7 +190,12 @@ Value get(Runtime& runtime, const PropNameID& name) override { ownsData_(ownsData), byteLength_(byteLength), pendingValue_(std::move(pendingValue)), - backingValue_(std::move(backingValue)) {} + backingValue_(std::move(backingValue)) { + if (bridge_ != nullptr) { + bridge_->holdRuntimeValue(pendingValue_); + bridge_->holdRuntimeValue(backingValue_); + } + } ~NativeApiReferenceHostObject() override { for (id object : retainedObjects_) { diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm index a71cea169..483b420bc 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm @@ -11,6 +11,9 @@ ownedData_(std::move(storageOwner)), backingValue_(std::move(backingValue)), ownsData_(ownsData) { + if (bridge_ != nullptr) { + bridge_->holdRuntimeValue(backingValue_); + } size_t size = info_ != nullptr ? info_->size : 0; if (ownedData_ != nullptr) { data_ = const_cast(data); From 90f9ba7f593e9763d860b5aba1ff2d7d5a4abb21 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 25 Oct 2026 13:26:16 -0700 Subject: [PATCH 2/4] refactor(ffi/objc): drop the held-value registry; abandon Values of a 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. --- NativeScript/ffi/objc/hermes/NativeApiJsi.mm | 35 ++++++++++++++ .../ffi/objc/shared/bridge/Callbacks.mm | 6 +-- .../ffi/objc/shared/bridge/Install.mm | 38 ++------------- .../ffi/objc/shared/bridge/ObjCBridge.mm | 47 +++---------------- .../ffi/objc/shared/bridge/TypeConv.mm | 3 -- .../shared/bridge/host_objects/Interop.mm | 23 ++++----- .../objc/shared/bridge/host_objects/Struct.mm | 9 ++-- 7 files changed, 62 insertions(+), 99 deletions(-) diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm index 9b3e2f93d..fd8b87ec6 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm +++ b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm @@ -257,6 +257,41 @@ throw JSError(runtime, }); } +#define NATIVESCRIPT_NATIVE_API_HAS_ENGINE_RUNTIME_TEARDOWN 1 + +// Runtime data is destroyed with the runtime while its Values can still be +// released: Hermes frees it before tearing down its heap, and jsi's default +// implementation frees it from a finalizer of that runtime's own global. +class NativeApiRuntimeBridges final { + public: + ~NativeApiRuntimeBridges() { + for (auto& bridge : bridges_) { + bridge->releaseRuntimeValues(); + } + } + + void add(std::shared_ptr bridge) { + bridges_.push_back(std::move(bridge)); + } + + private: + std::vector> bridges_; +}; + +constexpr facebook::jsi::UUID kNativeApiRuntimeBridgesUUID{ + 0x5f0e2c71, 0x8b3d, 0x4a6e, 0x9c41, 0x2d7a6b13e8f4}; + +void BindNativeApiBridgeToRuntime(Runtime& runtime, + std::shared_ptr bridge) { + auto bridges = std::static_pointer_cast( + runtime.getRuntimeData(kNativeApiRuntimeBridgesUUID)); + if (bridges == nullptr) { + bridges = std::make_shared(); + runtime.setRuntimeData(kNativeApiRuntimeBridgesUUID, bridges); + } + bridges->add(std::move(bridge)); +} + } // namespace #include "../shared/bridge/Install.mm" diff --git a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm index b8c87da30..25548f656 100644 --- a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm +++ b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm @@ -403,9 +403,6 @@ Function persistentEngineFunction(Runtime& runtime, const Function& function) { threadPolicy_(threadPolicy), bindThis_(bindThis), roundTripValidationKey_(roundTripValidationKey) { - if (bridge_ != nullptr) { - bridge_->holdRuntimeValue(function_); - } closure_ = static_cast( ffi_closure_alloc(sizeof(ffi_closure), &executable_)); if (closure_ == nullptr || executable_ == nullptr || @@ -458,6 +455,9 @@ throw JSError(runtime, } ~NativeApiCallback() { + if (bridge_ != nullptr) { + bridge_->releaseOrAbandonValue(function_); + } if (closure_ != nullptr) { ffi_closure_free(closure_); closure_ = nullptr; diff --git a/NativeScript/ffi/objc/shared/bridge/Install.mm b/NativeScript/ffi/objc/shared/bridge/Install.mm index 4a1f4d95f..253cf32cc 100644 --- a/NativeScript/ffi/objc/shared/bridge/Install.mm +++ b/NativeScript/ffi/objc/shared/bridge/Install.mm @@ -1,43 +1,11 @@ -#ifdef TARGET_ENGINE_HERMES -// Runtime data is destroyed with the runtime while its Values can still be -// released: Hermes frees it before tearing down its heap, and jsi's default -// implementation frees it from a finalizer of that runtime's own global. -class NativeApiRuntimeBridges final { - public: - ~NativeApiRuntimeBridges() { - for (auto& bridge : bridges_) { - bridge->releaseRuntimeValues(); - } - } - - void add(std::shared_ptr bridge) { - bridges_.push_back(std::move(bridge)); - } - - private: - std::vector> bridges_; -}; - -constexpr facebook::jsi::UUID kNativeApiRuntimeBridgesUUID{ - 0x5f0e2c71, 0x8b3d, 0x4a6e, 0x9c41, 0x2d7a6b13e8f4}; - -void BindNativeApiBridgeToRuntime(Runtime& runtime, - std::shared_ptr bridge) { - auto bridges = std::static_pointer_cast( - runtime.getRuntimeData(kNativeApiRuntimeBridgesUUID)); - if (bridges == nullptr) { - bridges = std::make_shared(); - runtime.setRuntimeData(kNativeApiRuntimeBridgesUUID, bridges); - } - bridges->add(std::move(bridge)); -} +#ifndef NATIVESCRIPT_NATIVE_API_HAS_ENGINE_RUNTIME_TEARDOWN +inline void BindNativeApiBridgeToRuntime(Runtime&, + std::shared_ptr) {} #endif Object CreateNativeApi(Runtime& runtime, const NativeApiConfig& config) { auto bridge = std::make_shared(config); -#ifdef TARGET_ENGINE_HERMES BindNativeApiBridgeToRuntime(runtime, bridge); -#endif return Object::createFromHostObject(runtime, std::make_shared(std::move(bridge))); } diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 1d99f278b..19279a35b 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -574,48 +574,20 @@ void releaseRuntimeValues() { objectExpandos_.clear(); objectExpandoOwnerCounts_.clear(); objectExpandosGeneration_.fetch_add(1, std::memory_order_release); - - std::vector heldValues; - { - std::lock_guard lock(heldRuntimeValuesMutex_); - heldValues.swap(heldRuntimeValues_); - } - for (auto& held : heldValues) { - if (auto value = held.value.lock()) { - held.release(value.get()); - } - } } bool runtimeValuesReleased() const { return runtimeValuesReleased_.load(std::memory_order_acquire); } - // For Values owned outside the bridge's caches. They are released in place - // with the bridge's runtime, leaving the holder an empty Value or Function. + // A destroyed runtime freed its Values with its heap; releasing one would + // touch freed memory. template - void holdRuntimeValue(const std::shared_ptr& value) { -#ifdef TARGET_ENGINE_HERMES - if (value == nullptr) { - return; - } - std::lock_guard lock(heldRuntimeValuesMutex_); - if (runtimeValuesReleased()) { - return; + void releaseOrAbandonValue(std::shared_ptr& value) { + if (value != nullptr && runtimeValuesReleased()) { + new std::shared_ptr(std::move(value)); } - if (heldRuntimeValues_.size() >= heldRuntimeValuesPruneSize_) { - heldRuntimeValues_.erase( - std::remove_if(heldRuntimeValues_.begin(), heldRuntimeValues_.end(), - [](const HeldRuntimeValue& held) { - return held.value.expired(); - }), - heldRuntimeValues_.end()); - heldRuntimeValuesPruneSize_ = - std::max(256, heldRuntimeValues_.size() * 2); - } - heldRuntimeValues_.push_back(HeldRuntimeValue{ - value, [](void* held) { T released(std::move(*static_cast(held))); }}); -#endif + value.reset(); } MDMetadataReader* metadata() const { return metadata_.get(); } @@ -2242,13 +2214,6 @@ static void appendSurfaceMember( std::thread::id jsThreadId_ = std::this_thread::get_id(); std::mutex retainedLifetimesMutex_; std::vector> retainedLifetimes_; - struct HeldRuntimeValue { - std::weak_ptr value; - void (*release)(void*); - }; - std::mutex heldRuntimeValuesMutex_; - std::vector heldRuntimeValues_; - size_t heldRuntimeValuesPruneSize_ = 256; std::atomic runtimeValuesReleased_{false}; }; diff --git a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm index 5654e283b..908c144c6 100644 --- a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm +++ b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm @@ -1268,9 +1268,6 @@ throw JSError(runtime, "This native return type is not supported by " if (data_ == nullptr) { if (slotIndex == 0) { pendingValue_ = std::make_shared(runtime, value); - if (bridge_ != nullptr) { - bridge_->holdRuntimeValue(pendingValue_); - } NATIVE_API_SET_RETURN(true); } ensureStorage(runtime, type_, frame, slotIndex + 1); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm index c230b1adb..10752e42e 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm @@ -10,13 +10,12 @@ pointer_(pointer), kind_(std::move(kind)), adopted_(adopted), - backingValue_(std::move(backingValue)) { - if (bridge_ != nullptr) { - bridge_->holdRuntimeValue(backingValue_); - } - } + backingValue_(std::move(backingValue)) {} ~NativeApiPointerHostObject() override { + if (bridge_ != nullptr) { + bridge_->releaseOrAbandonValue(backingValue_); + } if (adopted_ && pointer_ != nullptr) { if (bridge_ != nullptr) { bridge_->forgetPointerValue(pointer_); @@ -30,9 +29,6 @@ std::shared_ptr backingValue() const { return backingValue_; } void setBackingValue(Runtime& runtime, const Value& value) { backingValue_ = std::make_shared(runtime, value); - if (bridge_ != nullptr) { - bridge_->holdRuntimeValue(backingValue_); - } } bool adopted() const { return adopted_; } void adopt() { adopted_ = true; } @@ -190,14 +186,13 @@ Value get(Runtime& runtime, const PropNameID& name) override { ownsData_(ownsData), byteLength_(byteLength), pendingValue_(std::move(pendingValue)), - backingValue_(std::move(backingValue)) { - if (bridge_ != nullptr) { - bridge_->holdRuntimeValue(pendingValue_); - bridge_->holdRuntimeValue(backingValue_); - } - } + backingValue_(std::move(backingValue)) {} ~NativeApiReferenceHostObject() override { + if (bridge_ != nullptr) { + bridge_->releaseOrAbandonValue(pendingValue_); + bridge_->releaseOrAbandonValue(backingValue_); + } for (id object : retainedObjects_) { [object release]; } diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm index 483b420bc..7d6b58495 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Struct.mm @@ -11,9 +11,6 @@ ownedData_(std::move(storageOwner)), backingValue_(std::move(backingValue)), ownsData_(ownsData) { - if (bridge_ != nullptr) { - bridge_->holdRuntimeValue(backingValue_); - } size_t size = info_ != nullptr ? info_->size : 0; if (ownedData_ != nullptr) { data_ = const_cast(data); @@ -29,6 +26,12 @@ } } + ~NativeApiStructObjectHostObject() override { + if (bridge_ != nullptr) { + bridge_->releaseOrAbandonValue(backingValue_); + } + } + void* data() const { return data_; } std::shared_ptr info() const { return info_; } std::shared_ptr> storageOwner() const { From afc17c97cb5d7209a5e1b318cbdd2960e833c91a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 25 Oct 2026 13:32:04 -0700 Subject: [PATCH 3/4] feat(ffi/objc): throw when a bridge object is used from another runtime 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. --- NativeScript/ffi/objc/hermes/NativeApiJsi.mm | 1 + .../ffi/objc/shared/bridge/HostObject.mm | 2 ++ .../ffi/objc/shared/bridge/ObjCBridge.mm | 16 ++++++++++++++++ NativeScript/ffi/objc/shared/bridge/TypeConv.mm | 5 +++++ .../ffi/objc/shared/bridge/host_objects/Class.mm | 3 +++ .../objc/shared/bridge/host_objects/Interop.mm | 3 +++ .../objc/shared/bridge/host_objects/Object.mm | 8 ++++++++ .../objc/shared/bridge/host_objects/Protocol.mm | 2 ++ 8 files changed, 40 insertions(+) diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm index fd8b87ec6..61dca798e 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm +++ b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm @@ -283,6 +283,7 @@ void add(std::shared_ptr bridge) { void BindNativeApiBridgeToRuntime(Runtime& runtime, std::shared_ptr bridge) { + bridge->setOwnerRuntime(runtime); auto bridges = std::static_pointer_cast( runtime.getRuntimeData(kNativeApiRuntimeBridgesUUID)); if (bridges == nullptr) { diff --git a/NativeScript/ffi/objc/shared/bridge/HostObject.mm b/NativeScript/ffi/objc/shared/bridge/HostObject.mm index d16a91f96..857d6fdbe 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObject.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObject.mm @@ -24,6 +24,7 @@ explicit NativeApiHostObject(std::shared_ptr bridge) : bridge_(std::move(bridge)) {} Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "runtime") { return makeString(runtime, NATIVESCRIPT_NATIVE_API_RUNTIME_NAME); @@ -507,6 +508,7 @@ throw JSError(runtime, } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; names.reserve(11); addPropertyName(runtime, names, "runtime"); diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 19279a35b..b2907c74f 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -590,6 +590,21 @@ void releaseOrAbandonValue(std::shared_ptr& value) { value.reset(); } + void setOwnerRuntime(const Runtime& runtime) { ownerRuntime_ = &runtime; } + + // Worklets shares host objects between runtimes by reference. Using one in + // a runtime other than its bridge's would mix the two heaps. + void requireOwnerRuntime(Runtime& runtime) const { + if (ownerRuntime_ != nullptr && ownerRuntime_ != &runtime) { + throw JSError( + runtime, + "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."); + } + } + MDMetadataReader* metadata() const { return metadata_.get(); } void* selfDl() const { return selfDl_; } @@ -2215,6 +2230,7 @@ static void appendSurfaceMember( std::mutex retainedLifetimesMutex_; std::vector> retainedLifetimes_; std::atomic runtimeValuesReleased_{false}; + const Runtime* ownerRuntime_ = nullptr; }; class NativeApiRoundTripCacheFrameGuard final { diff --git a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm index 908c144c6..5e00602bb 100644 --- a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm +++ b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm @@ -1217,6 +1217,7 @@ throw JSError(runtime, "This native return type is not supported by " } Value NativeApiReferenceHostObject::get(Runtime& runtime, const PropNameID& name) { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "kind") { return makeString(runtime, "reference"); @@ -1258,6 +1259,7 @@ throw JSError(runtime, "This native return type is not supported by " NativeApiHostSetResult NativeApiReferenceHostObject::set(Runtime& runtime, const PropNameID& name, const Value& value) { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); auto index = parseArrayIndexProperty(property); if (property != "value" && !index) { @@ -1283,6 +1285,7 @@ throw JSError(runtime, "This native return type is not supported by " } Value NativeApiStructObjectHostObject::get(Runtime& runtime, const PropNameID& name) { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "kind") { return makeString(runtime, info_ != nullptr && info_->isUnion ? "union" : "struct"); @@ -1328,6 +1331,7 @@ throw JSError(runtime, "This native return type is not supported by " NativeApiHostSetResult NativeApiStructObjectHostObject::set(Runtime& runtime, const PropNameID& name, const Value& value) { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (info_ == nullptr || data_ == nullptr) { throw JSError(runtime, "Struct is not initialized."); @@ -1345,6 +1349,7 @@ throw JSError(runtime, "This native return type is not supported by " } std::vector NativeApiStructObjectHostObject::getPropertyNames(Runtime& runtime) { + bridge_->requireOwnerRuntime(runtime); std::vector names; addPropertyName(runtime, names, "kind"); addPropertyName(runtime, names, "name"); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm index e74c4032b..04124c280 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm @@ -19,6 +19,7 @@ static Class classRespondingToClassSelector(Class cls, SEL selector) { } Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "kind") { return makeString(runtime, "class"); @@ -249,6 +250,7 @@ throw JSError( } NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value& value) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); Class cls = objc_lookUpClass(symbol_.runtimeName.c_str()); if (cls == nil) { @@ -284,6 +286,7 @@ throw JSError(runtime, } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; names.reserve(8); addPropertyName(runtime, names, "kind"); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm index 10752e42e..94a08e8e8 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Interop.mm @@ -42,6 +42,7 @@ void clearWithoutFree() { } Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "kind") { return makeString(runtime, kind_); @@ -147,6 +148,7 @@ Value get(Runtime& runtime, const PropNameID& name) override { } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; names.reserve(3); addPropertyName(runtime, names, "kind"); @@ -212,6 +214,7 @@ void ensureStorage(Runtime& runtime, NativeApiType type, Value get(Runtime& runtime, const PropNameID& name) override; NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value& value) override; std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; addPropertyName(runtime, names, "kind"); addPropertyName(runtime, names, "value"); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm index 2fd5b80be..67ac12843 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm @@ -12,6 +12,7 @@ } Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "next") { return Function::createFromHostFunction( @@ -24,6 +25,7 @@ Value get(Runtime& runtime, const PropNameID& name) override { } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; addPropertyName(runtime, names, "next"); return names; @@ -178,6 +180,7 @@ NativeApiSymbol nativeApiSymbolForRuntimeClass( } Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "kind") { return makeString(runtime, "super"); @@ -240,6 +243,7 @@ throw JSError( } NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value& value) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (receiver_ == nil || dispatchClass_ == Nil) { throw JSError(runtime, "Cannot set property on nil super."); @@ -281,6 +285,7 @@ throw JSError(runtime, } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; addPropertyName(runtime, names, "kind"); addPropertyName(runtime, names, "toString"); @@ -840,6 +845,7 @@ bool invokeEnginePrototypeSetter(Runtime& runtime, const std::string& property, } Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); // Fast path: check expando cache first (hot path for method calls). @@ -1297,6 +1303,7 @@ throw JSError(runtime, } NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value& value) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (object_ == nil) { throw JSError(runtime, "Cannot set property on nil object."); @@ -1354,6 +1361,7 @@ throw JSError( } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; names.reserve(6); addPropertyName(runtime, names, "kind"); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm index 5c689f627..c30912d0c 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm @@ -15,6 +15,7 @@ const NativeApiSymbol& symbol() const { return symbol_; } Value get(Runtime& runtime, const PropNameID& name) override { + bridge_->requireOwnerRuntime(runtime); std::string property = name.utf8(runtime); if (property == "kind") { return makeString(runtime, "protocol"); @@ -91,6 +92,7 @@ Value get(Runtime& runtime, const PropNameID& name) override { } std::vector getPropertyNames(Runtime& runtime) override { + bridge_->requireOwnerRuntime(runtime); std::vector names; addPropertyName(runtime, names, "kind"); addPropertyName(runtime, names, "name"); From 66e9228f34f788164da411c666e592452a137975 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 25 Oct 2026 14:06:26 -0700 Subject: [PATCH 4/4] feat(react-native): runOnUI returns native objects as handles 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. --- packages/react-native/src/index.ts | 73 +++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index b15d01120..fc385ad86 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1337,6 +1337,75 @@ export function installWorklets( return true; } +const nativeObjectHandleKey = "__nativeScriptObjectHandle"; + +// Worklets hands a returned native object to this runtime by reference, still +// bound to the UI runtime. Only its handle crosses; the +1 retained here keeps +// it alive until nativeObjectsFromHandles adopts it. +function nativeObjectsToHandles(value: unknown): unknown { + "worklet"; + + if (value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(nativeObjectsToHandles); + } + const prototype = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null) { + const result: Record = {}; + for (const key of Object.keys(value)) { + result[key] = nativeObjectsToHandles( + (value as Record)[key], + ); + } + return result; + } + const { NSObject, CFRetain } = globalThis as Record; + if (typeof NSObject !== "function" || !(value instanceof NSObject)) { + return value; + } + const handle = nativeHandleForNSObject(value); + if (handle === undefined) { + return value; + } + CFRetain(value); + return { [nativeObjectHandleKey]: handle }; +} + +function nativeObjectsFromHandles(value: unknown): unknown { + if (value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(nativeObjectsFromHandles); + } + const handle = (value as Record)[nativeObjectHandleKey]; + if (typeof handle === "string") { + const interop = (globalThis as Record).interop; + return interop.Pointer(Number(handle)).takeRetainedValue(); + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + return value; + } + const result: Record = {}; + for (const key of Object.keys(value)) { + result[key] = nativeObjectsFromHandles( + (value as Record)[key], + ); + } + return result; +} + +function runWorkletReturningHandles( + callback: (...args: Args) => ReturnValue, + args: Args, +): unknown { + "worklet"; + + return nativeObjectsToHandles(callback(...args)); +} + export function runOnUI( callback: (...args: Args) => ReturnValue | Promise, ...args: Args @@ -1352,7 +1421,9 @@ export function runOnUI( "NativeScript.runOnUI requires a worklet callback", ); } - return worklets.runOnUIAsync(callback, ...args); + return worklets + .runOnUIAsync(runWorkletReturningHandles, callback, args) + .then(nativeObjectsFromHandles) as Promise; } function callbackInvoker(