From 1ccb59272040cd8aee4c92dc29668ecf432d390b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 19:32:53 -0300 Subject: [PATCH 1/3] feat(runtime): canary wrapper lifetime mode Adds "wrapperLifetime": { "mode": "canary" }, the portable resurrection- free model: the one the Node-API runtimes and Android can implement too. - A wrapper in scope is a canary: a phantom-weak JS object that dies with its JS references and releases the runtime's retain through the same queue as "rooted". No retain counts are read. - kNonMasking named interceptors on the three ObjC instance templates forward JS properties to a null-prototype state object. Prototype members (native methods and properties, JS-extended class methods) are found before the interceptors and never reach them. - The state object is rooted from its first write until the native object is deallocated, reported by an associated token's -dealloc. A canary built later for the same object forwards to the same state. The state cannot be rooted only once the canary dies: a state reachable only through the canary is garbage in the same collection. So state that references its own object (a closure over `this`) keeps it alive; the iOS-only alternative, unrooting at major-GC start when nothing native owns the object, is left as a marked switch point in WrapperLifetimeCanary.h. Records are shared between the registry and the canary and carry a died flag, so objc_removeAssociatedObjects on a live object leaves its canary with no state instead of a dangling record. Teardown empties every state handle while the isolate is alive and later token deallocs find the registry closed. Requires V8's own-property lookups through kNonMasking interceptors to skip a prototype's named interceptor (crbug 585053531), which the V8 prebuilts get from v8_nonmasking_own_lookup.patch. --- NativeScript/runtime/ArgConverter.mm | 2 + NativeScript/runtime/ClassBuilder.mm | 3 + NativeScript/runtime/MetadataBuilder.mm | 2 + NativeScript/runtime/NsBuiltinModules.cpp | 2 + NativeScript/runtime/ObjectManager.h | 11 + NativeScript/runtime/WrapperLifetime.h | 9 +- NativeScript/runtime/WrapperLifetime.mm | 32 +- NativeScript/runtime/WrapperLifetimeCanary.h | 53 +++ NativeScript/runtime/WrapperLifetimeCanary.mm | 393 ++++++++++++++++++ .../app/tests/WrapperLifetimeModesTests.js | 163 +++++++- TestRunner/app/tests/WrapperLifetimeTests.js | 2 + docs/knowledge/v8-resurrecting-finalizers.md | 20 + docs/ns-builtin-modules.md | 6 +- types/ns-runtime.d.ts | 15 +- v8ios.xcodeproj/project.pbxproj | 8 + 15 files changed, 699 insertions(+), 22 deletions(-) create mode 100644 NativeScript/runtime/WrapperLifetimeCanary.h create mode 100644 NativeScript/runtime/WrapperLifetimeCanary.mm diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index ce3c0375..ed57f272 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -12,6 +12,7 @@ #include "WrapperLifetime.h" #include "Runtime.h" #include "RuntimeConfig.h" +#include "WrapperLifetimeCanary.h" using namespace v8; using namespace std; @@ -1183,6 +1184,7 @@ throw NativeScriptException( instanceTemplate->SetHandler(IndexedPropertyHandlerConfiguration(IndexedPropertyGetterCallback, IndexedPropertySetterCallback)); + WrapperLifetimeCanary::ConfigureInstanceTemplate(instanceTemplate); Local emptyInstanceCtorFunc; if (!emptyInstanceCtorFuncTemplate->GetFunction(context).ToLocal(&emptyInstanceCtorFunc)) { diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 999a69e6..37defafc 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -13,6 +13,7 @@ #include "ObjectManager.h" #include "Runtime.h" #include "TNSDerivedClass.h" +#include "WrapperLifetimeCanary.h" using namespace v8; @@ -100,6 +101,8 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { Local extendedClassCtorFuncTemplate = FunctionTemplate::New(isolate, ExtendedClassConstructorCallback, ext); extendedClassCtorFuncTemplate->InstanceTemplate()->SetInternalFieldCount(1); + WrapperLifetimeCanary::ConfigureInstanceTemplate( + extendedClassCtorFuncTemplate->InstanceTemplate()); Local extendClassCtorFunc; if (!extendedClassCtorFuncTemplate->GetFunction(context).ToLocal(&extendClassCtorFunc)) { diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index e7131fc8..9f2e17be 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -14,6 +14,7 @@ #include "SymbolLoader.h" #include "Tasks.h" #include "Worker.h" +#include "WrapperLifetimeCanary.h" using namespace v8; @@ -553,6 +554,7 @@ throw NativeScriptException( ctorFuncTemplate = FunctionTemplate::New(isolate, ClassConstructorCallback, ext); ctorFuncTemplate->InstanceTemplate()->SetInternalFieldCount(1); + WrapperLifetimeCanary::ConfigureInstanceTemplate(ctorFuncTemplate->InstanceTemplate()); ctorFuncTemplate->SetClassName(tns::ToV8String(isolate, meta->name())); Local baseCtorFunc; diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 09155926..636bf6e4 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -246,6 +246,8 @@ void GetWrapperLifetimeStatsCallback(const FunctionCallbackInfo& info) { {"promotions", stats->promotions}, {"demotions", stats->demotions}, {"queuedReleases", stats->queuedReleases}, + {"canaryStates", stats->canaryStates}, + {"canaryNativeDeaths", stats->canaryNativeDeaths}, }; for (const auto& [name, value] : fields) { result diff --git a/NativeScript/runtime/ObjectManager.h b/NativeScript/runtime/ObjectManager.h index d75c9e61..2bb97682 100644 --- a/NativeScript/runtime/ObjectManager.h +++ b/NativeScript/runtime/ObjectManager.h @@ -6,6 +6,8 @@ namespace tns { +struct CanaryRecord; + class ObjectManager; class ObjCDataWrapper; @@ -51,6 +53,11 @@ struct ObjectWeakCallbackState { bool lifetimeTracked_ = false; // The handle is currently strong. bool lifetimeStrong_ = false; + // Canary mode: the wrapper forwards JS properties to a state object that + // outlives it (WrapperLifetimeCanary), found through this record once the + // object has state. + bool lifetimeCanary_ = false; + std::shared_ptr canaryRecord_; }; class ObjectManager { @@ -79,6 +86,10 @@ class ObjectManager { uint64_t promotions = 0; uint64_t demotions = 0; uint64_t queuedReleases = 0; + // Canary mode: state objects created for native objects, and state + // objects let go of because their native object was deallocated. + uint64_t canaryStates = 0; + uint64_t canaryNativeDeaths = 0; }; static LifetimeStats* GetLifetimeStats(v8::Isolate* isolate); diff --git a/NativeScript/runtime/WrapperLifetime.h b/NativeScript/runtime/WrapperLifetime.h index 54b4a917..ca16df3e 100644 --- a/NativeScript/runtime/WrapperLifetime.h +++ b/NativeScript/runtime/WrapperLifetime.h @@ -19,11 +19,16 @@ enum class WrapperLifetimeMode { // Phantom callbacks, no resurrection: a wrapper is a strong root while // something other than the runtime retains its object, weak otherwise. kRooted, + // Phantom callbacks, no resurrection: the wrapper is a canary that dies with + // its JS references. JS properties set on it live on a separate state object + // that stays alive until the native object is deallocated, and a canary + // built for the same object later shares it (WrapperLifetimeCanary). + kCanary, }; enum class WrapperLifetimeScope { - // Only JS-extended instances (TNSDerivedClass) are rooted; other wrappers are - // weak and die with their JS references. + // Only JS-extended instances (TNSDerivedClass) are rooted, or canaries; + // other wrappers are weak and die with their JS references. kExtended, kAll, }; diff --git a/NativeScript/runtime/WrapperLifetime.mm b/NativeScript/runtime/WrapperLifetime.mm index 8a63976c..869854e9 100644 --- a/NativeScript/runtime/WrapperLifetime.mm +++ b/NativeScript/runtime/WrapperLifetime.mm @@ -13,6 +13,7 @@ #include "Helpers.h" #include "Runtime.h" #import "TNSDerivedClass.h" +#include "WrapperLifetimeCanary.h" using namespace v8; @@ -33,6 +34,8 @@ void ReadConfig() { if ([mode isKindOfClass:[NSString class]]) { if ([mode isEqualToString:@"rooted"]) { detail::wrapperLifetimeMode = WrapperLifetimeMode::kRooted; + } else if ([mode isEqualToString:@"canary"]) { + detail::wrapperLifetimeMode = WrapperLifetimeMode::kCanary; } else if (![mode isEqualToString:@"resurrecting"]) { Log("NativeScript: unknown wrapperLifetime mode \"%s\"; using \"resurrecting\".", [mode UTF8String]); @@ -116,7 +119,8 @@ bool IsBlock(id obj) { return blockClass != nil && [obj isKindOfClass:blockClass]; } -bool IsRootable(id obj) { +// Rooted (rooted mode) or a canary (canary mode). +bool IsInScope(id obj) { if (IsTaggedPointer(obj) || IsBlock(obj)) { return false; } @@ -258,7 +262,15 @@ void ReleaseAll(Isolate* isolate, RootedState* rooted) { } // namespace const char* WrapperLifetimeModeName(WrapperLifetimeMode mode) { - return mode == WrapperLifetimeMode::kRooted ? "rooted" : "resurrecting"; + switch (mode) { + case WrapperLifetimeMode::kRooted: + return "rooted"; + case WrapperLifetimeMode::kCanary: + return "canary"; + case WrapperLifetimeMode::kResurrecting: + break; + } + return "resurrecting"; } const char* WrapperLifetimeScopeName(WrapperLifetimeScope scope) { @@ -267,6 +279,7 @@ void ReleaseAll(Isolate* isolate, RootedState* rooted) { void WrapperLifetime::Init(Isolate* isolate) { std::call_once(configOnce_, ReadConfig); + WrapperLifetimeCanary::Init(isolate); if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kRooted) { return; } @@ -277,7 +290,7 @@ void ReleaseAll(Isolate* isolate, RootedState* rooted) { void WrapperLifetime::Adopt(Isolate* isolate, ObjCDataWrapper* wrapper, const std::shared_ptr>& handle, id target) { - if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kRooted || target == nil) { + if (GetWrapperLifetimeMode() == WrapperLifetimeMode::kResurrecting || target == nil) { return; } RootedState* rooted = Caches::StateFor(isolate); @@ -291,9 +304,17 @@ void ReleaseAll(Isolate* isolate, RootedState* rooted) { } state->lifetimeWrapper_ = wrapper; state->lifetimeTarget_ = target; - state->runtimeRetains_ = AcquireRuntimeRetain(target); + if (GetWrapperLifetimeMode() == WrapperLifetimeMode::kRooted) { + state->runtimeRetains_ = AcquireRuntimeRetain(target); + } wrapper->SetLifetimeState(state); - if (IsRootable(target)) { + if (GetWrapperLifetimeMode() == WrapperLifetimeMode::kCanary) { + state->lifetimeCanary_ = IsInScope(target); + if (state->lifetimeCanary_) { + state->canaryRecord_ = WrapperLifetimeCanary::Find(isolate, target); + } + MakeWeak(state); + } else if (IsInScope(target)) { // Born strong: a wrapper just handed to JS or native is in use; the next // major collection decides whether native code keeps it. state->lifetimeTracked_ = true; @@ -333,6 +354,7 @@ void ReleaseAll(Isolate* isolate, RootedState* rooted) { } void WrapperLifetime::DrainForTeardown(Isolate* isolate) { + WrapperLifetimeCanary::CloseForTeardown(isolate); RootedState* rooted = Caches::ExistingStateFor(isolate); if (rooted == nullptr) { return; diff --git a/NativeScript/runtime/WrapperLifetimeCanary.h b/NativeScript/runtime/WrapperLifetimeCanary.h new file mode 100644 index 00000000..7cd90b7b --- /dev/null +++ b/NativeScript/runtime/WrapperLifetimeCanary.h @@ -0,0 +1,53 @@ +#ifndef WrapperLifetimeCanary_h +#define WrapperLifetimeCanary_h + +#include +#include "Common.h" + +namespace tns { + +struct CanaryRecord; + +// The canary wrapper lifetime mode (WrapperLifetimeMode::kCanary). +// +// An ObjC object's wrapper is a canary: a phantom-weak JS object that dies +// with its JS references, releasing the runtime's retain on the object. JS +// properties set on a canary are forwarded by kNonMasking named interceptors +// to a state object, so prototype members (native methods and properties, +// JS-extended class methods) never reach these handlers. A canary built later +// for the same object forwards to the same state. +// +// The state object is rooted from its first write until the native object is +// deallocated (an associated token's -dealloc reports it). It cannot be rooted +// only once the canary dies instead: a state reachable only through the canary +// is garbage in the same collection, before any phantom callback runs. The +// cost is that state referencing its own canary (a closure over `this`, a +// WeakMap value naming the object) keeps the canary, and through its retain the +// native object, alive. This is the portable contract, the one the Node-API +// runtimes and Android can implement. +// +// SWITCH POINT (iOS only, not implemented): to collect that self-referencing +// shape, a major-GC prologue could make a record's state handle weak while the +// object is not natively owned and no canary is alive, the way the rooted mode +// scans retain counts (WrapperLifetime.mm, PrologueCallback). Records already +// carry everything such a scan needs: the native object and the state handle. +class WrapperLifetimeCanary { + public: + static void Init(v8::Isolate* isolate); + + // Installs the forwarding handlers on an ObjC instance template. No-op unless + // the mode is canary. + static void ConfigureInstanceTemplate(v8::Local instanceTemplate); + + // The record of an object that already has state in this isolate, for a new + // canary to share. Null otherwise. + static std::shared_ptr Find(v8::Isolate* isolate, id object); + + // Teardown: lets go of every state object while the isolate is alive. Tokens + // that outlive the isolate find the registry closed. + static void CloseForTeardown(v8::Isolate* isolate); +}; + +} // namespace tns + +#endif /* WrapperLifetimeCanary_h */ diff --git a/NativeScript/runtime/WrapperLifetimeCanary.mm b/NativeScript/runtime/WrapperLifetimeCanary.mm new file mode 100644 index 00000000..d28209df --- /dev/null +++ b/NativeScript/runtime/WrapperLifetimeCanary.mm @@ -0,0 +1,393 @@ +#include "WrapperLifetimeCanary.h" +#import +#include +#include +#include +#include +#include +#include +#include "Caches.h" +#include "DataWrapper.h" +#include "EventLoop.h" +#include "Helpers.h" +#include "ObjectManager.h" +#include "Runtime.h" +#include "WrapperLifetime.h" + +using namespace v8; + +namespace tns { + +// A native object's state in one isolate, shared by the registry and the +// object's live canary. Its token's -dealloc marks it died: normally when the +// object is deallocated, but objc_removeAssociatedObjects can release the token +// of a live object, whose canary then sees no state and starts a new record on +// its next write. Teardown empties the handle. +struct CanaryRecord { + Global state; + std::atomic died{false}; +}; + +namespace { + +// Per-isolate. Shared with the tokens associated with native objects, which +// can be deallocated on any thread and after the isolate is gone. +struct CanaryRegistry { + std::mutex mutex; + std::unordered_map> records; + // Records whose token was deallocated; their handles are reset on the + // runtime's thread. + std::vector> dead; + bool closed = false; + bool drainPosted = false; + // Lets wrapper creation skip the lookup while no object has state. + std::atomic count{0}; + Isolate* isolate = nullptr; + std::weak_ptr loop; + // The association key: one per isolate, and never reused while a token + // keeps this registry alive. + char associationKey = 0; +}; + +struct CanaryState { + std::shared_ptr registry = std::make_shared(); +}; + +void DrainDead(const std::shared_ptr& registry) { + std::vector> dead; + { + std::lock_guard lock(registry->mutex); + registry->drainPosted = false; + if (registry->closed) { + return; + } + dead.swap(registry->dead); + } + ObjectManager::LifetimeStats* stats = ObjectManager::GetLifetimeStats(registry->isolate); + for (std::shared_ptr& record : dead) { + record->state.Reset(); + if (stats != nullptr) { + stats->canaryNativeDeaths++; + } + } +} + +// From the token's -dealloc, on whichever thread deallocates the object. +void NativeDied(const std::shared_ptr& registry, void* object) { + std::shared_ptr loop; + { + std::lock_guard lock(registry->mutex); + if (registry->closed) { + return; + } + auto it = registry->records.find(object); + if (it == registry->records.end()) { + return; + } + // Erased now, inside the object's -dealloc: an object later allocated at + // this address must not find this record. + it->second->died.store(true, std::memory_order_release); + registry->dead.push_back(std::move(it->second)); + registry->records.erase(it); + registry->count.fetch_sub(1, std::memory_order_relaxed); + if (registry->drainPosted) { + return; + } + loop = registry->loop.lock(); + registry->drainPosted = loop != nullptr; + } + // A post dropped at shutdown is fine: teardown resets every handle. + if (loop != nullptr && !loop->PostInternal([registry]() { DrainDead(registry); })) { + std::lock_guard lock(registry->mutex); + registry->drainPosted = false; + } +} + +std::shared_ptr RegistryFor(Isolate* isolate) { + CanaryState* state = Caches::StateFor(isolate); + return state != nullptr ? state->registry : nullptr; +} + +} // namespace + +} // namespace tns + +// Associated with a native object once it has state in an isolate; its +// -dealloc runs when the object is deallocated. +@interface TNSCanaryLifecycleToken : NSObject { + @public + std::shared_ptr registry_; + void* object_; +} +@end + +@implementation TNSCanaryLifecycleToken +- (void)dealloc { + if (registry_ != nullptr) { + tns::NativeDied(registry_, object_); + } + [super dealloc]; +} +@end + +namespace tns { + +namespace { + +// The adopted canary registration of an interceptor's holder, or null when the +// holder is not a canary: a plain wrapper outside the scope, a released one, or +// a `super` accessor object (internal field 1 holds a class name). +ObjectWeakCallbackState* CanaryOf(Isolate* isolate, Local holder) { + if (holder->InternalFieldCount() > 1 && holder->GetInternalField(1).As()->IsString()) { + return nullptr; + } + BaseDataWrapper* wrapper = tns::GetValue(isolate, holder); + if (wrapper == nullptr || wrapper->Type() != WrapperType::ObjCObject) { + return nullptr; + } + ObjectWeakCallbackState* state = static_cast(wrapper)->LifetimeState(); + return state != nullptr && state->lifetimeCanary_ ? state : nullptr; +} + +// The record's state is usable: its token is alive and teardown has not +// emptied it. Handles are only reset under the Locker these callers hold. +bool Usable(const std::shared_ptr& record) { + return record != nullptr && !record->died.load(std::memory_order_acquire) && + !record->state.IsEmpty(); +} + +// The state object of the holder, if it has one. +bool ExistingState(Isolate* isolate, Local holder, Local* out) { + ObjectWeakCallbackState* canary = CanaryOf(isolate, holder); + if (canary == nullptr || !Usable(canary->canaryRecord_)) { + return false; + } + *out = Local::New(isolate, canary->canaryRecord_->state); + return true; +} + +// The state object of the holder, created on its first write. +bool StateForWrite(Isolate* isolate, Local holder, Local* out) { + ObjectWeakCallbackState* canary = CanaryOf(isolate, holder); + if (canary == nullptr) { + return false; + } + if (canary->canaryRecord_ == nullptr || canary->canaryRecord_->died.load(std::memory_order_acquire)) { + std::shared_ptr registry = RegistryFor(isolate); + if (registry == nullptr) { + return false; + } + if (registry->loop.expired()) { + if (Runtime* runtime = Runtime::GetRuntime(isolate)) { + registry->loop = runtime->GetEventLoop(); + } + } + id object = canary->lifetimeTarget_; + bool created = false; + { + std::lock_guard lock(registry->mutex); + if (registry->closed) { + return false; + } + std::shared_ptr& slot = registry->records[(__bridge void*)object]; + if (slot == nullptr) { + slot = std::make_shared(); + slot->state.Reset(isolate, Object::New(isolate, Null(isolate), nullptr, nullptr, 0)); + registry->count.fetch_add(1, std::memory_order_relaxed); + created = true; + } + canary->canaryRecord_ = slot; + } + if (created) { + TNSCanaryLifecycleToken* token = [[TNSCanaryLifecycleToken alloc] init]; + token->registry_ = registry; + token->object_ = (__bridge void*)object; + objc_setAssociatedObject(object, ®istry->associationKey, token, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [token release]; + if (ObjectManager::LifetimeStats* stats = ObjectManager::GetLifetimeStats(isolate)) { + stats->canaryStates++; + } + } + } + if (!Usable(canary->canaryRecord_)) { + return false; + } + *out = Local::New(isolate, canary->canaryRecord_->state); + return true; +} + +bool HasOwn(Local context, Local state, Local property) { + return state->HasOwnProperty(context, property).FromMaybe(false); +} + +Intercepted Getter(Local property, const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!ExistingState(isolate, info.Holder(), &state)) { + return Intercepted::kNo; + } + Local context = isolate->GetCurrentContext(); + if (!HasOwn(context, state, property)) { + return Intercepted::kNo; + } + Local value; + // An accessor defined through the canary runs with the canary as `this` + // (interceptors do not see the receiver, only the holder). + if (state->Get(context, property, info.Holder()).ToLocal(&value)) { + info.GetReturnValue().Set(value); + } + return Intercepted::kYes; +} + +Intercepted Setter(Local property, Local value, + const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!StateForWrite(isolate, info.Holder(), &state)) { + return Intercepted::kNo; + } + Local context = isolate->GetCurrentContext(); + Maybe result = state->HasRealNamedCallbackProperty(context, property).FromMaybe(false) + ? state->Set(context, property, value, info.Holder()) + : state->Set(context, property, value); + if (result.IsJust()) { + info.GetReturnValue().Set(result.FromJust()); + } + return Intercepted::kYes; +} + +Intercepted Query(Local property, const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!ExistingState(isolate, info.Holder(), &state)) { + return Intercepted::kNo; + } + Local context = isolate->GetCurrentContext(); + if (!HasOwn(context, state, property)) { + return Intercepted::kNo; + } + PropertyAttribute attributes; + if (state->GetPropertyAttributes(context, property).To(&attributes)) { + info.GetReturnValue().Set(v8::Integer::New(isolate, attributes)); + } + return Intercepted::kYes; +} + +Intercepted Deleter(Local property, const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!ExistingState(isolate, info.Holder(), &state)) { + return Intercepted::kNo; + } + Local context = isolate->GetCurrentContext(); + if (!HasOwn(context, state, property)) { + return Intercepted::kNo; + } + bool deleted; + if (state->Delete(context, property).To(&deleted)) { + info.GetReturnValue().Set(deleted); + } + return Intercepted::kYes; +} + +void Enumerator(const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!ExistingState(isolate, info.Holder(), &state)) { + return; + } + Local context = isolate->GetCurrentContext(); + Local names; + if (state + ->GetPropertyNames(context, KeyCollectionMode::kOwnOnly, PropertyFilter::ALL_PROPERTIES, + IndexFilter::kSkipIndices) + .ToLocal(&names)) { + info.GetReturnValue().Set(names); + } +} + +Intercepted Definer(Local property, const PropertyDescriptor& descriptor, + const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!StateForWrite(isolate, info.Holder(), &state)) { + return Intercepted::kNo; + } + Local context = isolate->GetCurrentContext(); + bool defined; + if (state->DefineProperty(context, property, const_cast(descriptor)) + .To(&defined)) { + info.GetReturnValue().Set(defined); + } + return Intercepted::kYes; +} + +Intercepted Descriptor(Local property, const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local state; + if (!ExistingState(isolate, info.Holder(), &state)) { + return Intercepted::kNo; + } + Local context = isolate->GetCurrentContext(); + if (!HasOwn(context, state, property)) { + return Intercepted::kNo; + } + Local descriptor; + if (state->GetOwnPropertyDescriptor(context, property).ToLocal(&descriptor)) { + info.GetReturnValue().Set(descriptor); + } + return Intercepted::kYes; +} + +} // namespace + +void WrapperLifetimeCanary::Init(Isolate* isolate) { + if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kCanary) { + return; + } + CanaryState* state = Caches::StateFor(isolate); + if (state == nullptr) { + return; + } + state->registry->isolate = isolate; + if (Runtime* runtime = Runtime::GetRuntime(isolate)) { + state->registry->loop = runtime->GetEventLoop(); + } +} + +void WrapperLifetimeCanary::ConfigureInstanceTemplate(Local instanceTemplate) { + if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kCanary) { + return; + } + instanceTemplate->SetHandler(NamedPropertyHandlerConfiguration( + Getter, Setter, Query, Deleter, Enumerator, Definer, Descriptor, Local(), + PropertyHandlerFlags::kNonMasking)); +} + +std::shared_ptr WrapperLifetimeCanary::Find(Isolate* isolate, id object) { + CanaryState* state = Caches::StateFor(isolate); + if (state == nullptr || state->registry->count.load(std::memory_order_relaxed) == 0) { + return nullptr; + } + std::lock_guard lock(state->registry->mutex); + auto it = state->registry->records.find((__bridge void*)object); + return it != state->registry->records.end() ? it->second : nullptr; +} + +void WrapperLifetimeCanary::CloseForTeardown(Isolate* isolate) { + CanaryState* state = Caches::ExistingStateFor(isolate); + if (state == nullptr) { + return; + } + std::lock_guard lock(state->registry->mutex); + state->registry->closed = true; + for (auto& entry : state->registry->records) { + entry.second->state.Reset(); + } + for (std::shared_ptr& record : state->registry->dead) { + record->state.Reset(); + } +} + +} // namespace tns diff --git a/TestRunner/app/tests/WrapperLifetimeModesTests.js b/TestRunner/app/tests/WrapperLifetimeModesTests.js index 8dfa862c..6859399b 100644 --- a/TestRunner/app/tests/WrapperLifetimeModesTests.js +++ b/TestRunner/app/tests/WrapperLifetimeModesTests.js @@ -1,11 +1,14 @@ // Wrapper lifetime under the configured mode ("wrapperLifetime" in the app -// config). The suite runs under the default mode; the rooted expectations run -// when the TestRunner app is configured with "mode": "rooted". +// config). The suite runs under the default mode; the other expectations run +// when the TestRunner app is configured with "mode": "rooted" or "canary". describe("Wrapper lifetime modes", function () { var runtime = require("ns:runtime"); var mode = runtime.getConfig("wrapperLifetimeMode"); var scope = runtime.getConfig("wrapperLifetimeScope"); var rooted = mode === "rooted"; + var canary = mode === "canary"; + // JS state on a natively owned object survives its wrapper's death. + var keepsState = rooted || canary; var Extended = (function (_super) { __extends(Extended, _super); @@ -19,6 +22,18 @@ describe("Wrapper lifetime modes", function () { return n > 0 ? scrub(n - 1) + n : 0; } + // Watches specific objects without retaining them; unlike the dealloc log, + // it ignores objects earlier specs left to the collector. + function watch(obj) { + var table = NSHashTable.weakObjectsHashTable(); + table.addObject(obj); + return table; + } + + function alive(table) { + return table.allObjects.count; + } + function deallocs() { return (TNSGetOutput().match(/TNSAllocLog dealloc/g) || []).length; } @@ -45,7 +60,7 @@ describe("Wrapper lifetime modes", function () { // The name carries the mode so a run's log shows which one it exercised. it("reports the configured mode and scope (" + mode + "/" + scope + ")", function () { - expect(["resurrecting", "rooted"].indexOf(mode)).not.toBe(-1); + expect(["resurrecting", "rooted", "canary"].indexOf(mode)).not.toBe(-1); expect(["extended", "all"].indexOf(scope)).not.toBe(-1); expect(function () { runtime.setConfig("wrapperLifetimeMode", "rooted"); @@ -61,7 +76,7 @@ describe("Wrapper lifetime modes", function () { })(); settle(2, function () { var holder = owner.objectAtIndex(0); - if (rooted) { + if (keepsState) { expect(holder.state).toBeDefined(); expect(function () { holder.state.item.isEqual(null); @@ -71,7 +86,7 @@ describe("Wrapper lifetime modes", function () { }); }); - it("keeps the JS state of a natively owned plain object when every wrapper is rooted", function (done) { + it("keeps the JS state of a natively owned plain object when every wrapper is in scope", function (done) { var owner = NSMutableArray.new(); (function () { var obj = TNSAllocLog.new(); @@ -80,7 +95,7 @@ describe("Wrapper lifetime modes", function () { })(); settle(2, function () { var obj = owner.objectAtIndex(0); - if (rooted && scope === "all") { + if (keepsState && scope === "all") { expect(obj.state && obj.state.value).toBe(42); } done(); @@ -167,7 +182,7 @@ describe("Wrapper lifetime modes", function () { })(); settle(2, function () { var view = cell.owner.deref(); - if (rooted && view !== undefined) { + if (keepsState && view !== undefined) { expect(function () { view.color.isEqual(null); view.nativeView.isEqual(null); @@ -198,7 +213,7 @@ describe("Wrapper lifetime modes", function () { })(); settle(2, function () { expect(owner.count).toBe(4); - if (rooted) { + if (keepsState) { expect(owner.objectAtIndex(0).state).toBe(0); } done(); @@ -220,7 +235,7 @@ describe("Wrapper lifetime modes", function () { }); }); - it("counts rooted-mode activity", function (done) { + it("counts phantom-mode activity", function (done) { var before = runtime.getWrapperLifetimeStats(); (function () { for (var i = 0; i < 8; i++) { @@ -229,7 +244,7 @@ describe("Wrapper lifetime modes", function () { })(); settle(2, function () { var after = runtime.getWrapperLifetimeStats(); - if (rooted) { + if (mode !== "resurrecting") { expect(after.queuedReleases).toBeGreaterThan(before.queuedReleases); expect(after.resurrections).toBe(before.resurrections); } else { @@ -238,4 +253,132 @@ describe("Wrapper lifetime modes", function () { done(); }); }); + + // Own-property semantics hold whatever the mode: in canary mode the names + // live on the state object and the canary's interceptors present them. + it("keeps own-property semantics for JS properties on a native object", function () { + var obj = Extended.new(); + obj.a = 1; + Object.defineProperty(obj, "hidden", { value: 2, enumerable: false, configurable: true }); + Object.defineProperty(obj, "self", { + get: function () { + return this; + }, + configurable: true + }); + + expect(Object.keys(obj)).toEqual(["a"]); + expect(Object.getOwnPropertyNames(obj).sort()).toEqual(["a", "hidden", "self"]); + expect("a" in obj).toBe(true); + expect(Object.prototype.hasOwnProperty.call(obj, "hidden")).toBe(true); + expect(obj.hidden).toBe(2); + expect(obj.self).toBe(obj); + expect(Object.getOwnPropertyDescriptor(obj, "a").value).toBe(1); + + expect(delete obj.a).toBe(true); + expect("a" in obj).toBe(false); + expect(obj.a).toBeUndefined(); + // Prototype members are not own properties. + expect(Object.prototype.hasOwnProperty.call(obj, "isEqual")).toBe(false); + }); + + it("hands back a native object's JS properties on a new wrapper after the old one died", function (done) { + var owner = NSMutableArray.new(); + (function () { + var obj = Extended.new(); + obj.tag = "kept"; + owner.addObject(obj); + })(); + settle(2, function () { + var obj = owner.objectAtIndex(0); + if (keepsState) { + expect(obj.tag).toBe("kept"); + } + done(); + }); + }); + + it("leaves plain objects outside the extended scope without forwarding", function (done) { + var owner = NSMutableArray.new(); + (function () { + var obj = TNSAllocLog.new(); + obj.tag = "lost"; + owner.addObject(obj); + })(); + settle(2, function () { + if (canary && scope === "extended") { + expect(owner.objectAtIndex(0).tag).toBeUndefined(); + } + done(); + }); + }); + + it("lets a native object's JS state go when the object is deallocated", function (done) { + var owner = NSMutableArray.new(); + var before = runtime.getWrapperLifetimeStats(); + var watched; + (function () { + var obj = Extended.new(); + obj.tag = "state"; + owner.addObject(obj); + watched = watch(obj); + })(); + settle(2, function () { + owner.removeAllObjects(); + settle(3, function () { + var after = runtime.getWrapperLifetimeStats(); + expect(alive(watched)).toBe(0); + if (canary) { + expect(after.canaryStates).toBeGreaterThan(before.canaryStates); + expect(after.canaryNativeDeaths).toBeGreaterThan(before.canaryNativeDeaths); + } + done(); + }); + }); + }); + + // The documented cost of the canary contract: state that references its own + // object keeps the state, the object's wrapper and the native object alive + // until native code would have released it anyway, which nothing does here. + it("keeps a native object whose JS state references it", function (done) { + var watched; + (function () { + var obj = Extended.new(); + obj.callback = function () { + return obj; + }; + watched = watch(obj); + })(); + settle(3, function () { + expect(alive(watched)).toBe(canary ? 1 : 0); + done(); + }); + }); + + // Third-party code can strip a live object's associations, which releases + // the token that reports native death; the state goes with it. + it("survives a live object's associations being removed", function () { + if (typeof objc_removeAssociatedObjects !== "function") { + return; + } + var obj = Extended.new(); + obj.tag = "first"; + objc_removeAssociatedObjects(obj); + if (canary) { + expect(obj.tag).toBeUndefined(); + } + obj.tag = "second"; + expect(obj.tag).toBe("second"); + }); + + // V8 refuses to freeze, seal or prevent extensions on an object with + // interceptors, which every canary has. + it("refuses to freeze a canary", function () { + if (!canary) { + return; + } + expect(function () { + Object.freeze(Extended.new()); + }).toThrowError(TypeError); + }); }); diff --git a/TestRunner/app/tests/WrapperLifetimeTests.js b/TestRunner/app/tests/WrapperLifetimeTests.js index 5c62bdd9..f5e7930c 100644 --- a/TestRunner/app/tests/WrapperLifetimeTests.js +++ b/TestRunner/app/tests/WrapperLifetimeTests.js @@ -233,6 +233,8 @@ describe("Wrapper lifetime", function () { "demotions", "promotions", "queuedReleases", + "canaryStates", + "canaryNativeDeaths", "stateLostCandidates", "stateLostConfirmed", ].sort()); diff --git a/docs/knowledge/v8-resurrecting-finalizers.md b/docs/knowledge/v8-resurrecting-finalizers.md index 4a151166..5ab28b48 100644 --- a/docs/knowledge/v8-resurrecting-finalizers.md +++ b/docs/knowledge/v8-resurrecting-finalizers.md @@ -223,6 +223,26 @@ installed in this mode. `"scope": "extended"` (default) roots only JS-extended i JS-referencing native cycles alive (ARC rules: break them with a `WeakRef`). Specs: `WrapperLifetimeModesTests.js`, run under each mode. +`"mode": "canary"` (`WrapperLifetimeCanary.{h,mm}`) is the portable variant, the shape Android and +the Node-API runtimes can implement. A wrapper in scope is a canary: a phantom-weak JS object +that dies with its JS references, releasing the runtime's retain through the same queue. +`kNonMasking` named interceptors on the instance templates forward JS properties to a +null-prototype state object, so prototype members never reach them. The state is rooted from +its first write until the native object is deallocated, which an associated token's `-dealloc` +reports; a canary built later for the same object forwards to the same state. It cannot be +rooted at canary death instead, because a state reachable only through the canary is garbage in +the same collection. The cost: state that references its own object (a closure over `this`) +keeps it alive. Identity changes across a canary's death, so `===` with an old wrapper, `WeakMap` +keys and `WeakRef`s of the old canary, and native `#private` fields do not carry over. Other +limits: `Object.freeze`, `seal` and `preventExtensions` throw on a canary (V8 refuses them on +objects with interceptors); index-keyed properties are not forwarded and die with the canary; +a write through an object whose prototype is a canary lands in the canary's state, since +interceptors see the holder but not the receiver; and `"scope": "all"` is experimental, because +attaching the token aborts for any class the ObjC runtime marks as forbidding associated +objects, which has no public query. Relies on +V8's own-property lookup through `kNonMasking` interceptors when the prototype has a named +interceptor (`v8_nonmasking_own_lookup.patch` in v8-buildscripts, crbug 585053531). + Two facts worth knowing when reasoning about which objects can husk: - `Pointer`/`interop.alloc` objects never husk at runtime — `Caches::PointerInstances` diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 692c8fcd..57184348 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -116,7 +116,7 @@ Config keys: | `releasedObjectPolicy` | `"report"` \| `"throw"` | process-wide (main-isolate writes only; read live by every isolate) | `"report"` | | `debug` | comma-separated category list, e.g. `"esm,fetch"` | process-wide (main-isolate writes only; read live by every isolate) | the `NS_DEBUG` environment variable, or `""` | | `wrapperLifetimeWarnings` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | the app config `"wrapperLifetime": { "warnings": … }`, or on in debug builds | -| `wrapperLifetimeMode` | `"resurrecting"` \| `"rooted"` | process-wide, read-only | the app config `"wrapperLifetime": { "mode": … }`, or `"resurrecting"` | +| `wrapperLifetimeMode` | `"resurrecting"` \| `"rooted"` \| `"canary"` | process-wide, read-only | the app config `"wrapperLifetime": { "mode": … }`, or `"resurrecting"` | | `wrapperLifetimeScope` | `"extended"` \| `"all"` | process-wide, read-only | the app config `"wrapperLifetime": { "scope": … }`, or `"extended"` | ```js @@ -163,7 +163,9 @@ every build (they only cost an increment on finalizer and error paths): | `stateLostConfirmed` | candidates whose object was still alive a runloop turn later; each class is also reported once in the log | | `promotions` | `"rooted"` only: weak wrappers made strong because native code retained their object | | `demotions` | `"rooted"` only: strong wrappers made weak at a major collection because native code no longer retained their object | -| `queuedReleases` | `"rooted"` only: collected wrappers whose native object was queued for release on the runtime's thread | +| `queuedReleases` | `"rooted"` and `"canary"`: collected wrappers whose native object was queued for release on the runtime's thread | +| `canaryStates` | `"canary"` only: state objects created to hold native objects' JS properties | +| `canaryNativeDeaths` | `"canary"` only: state objects let go of because their native object was deallocated | A wrapper whose native object was released keeps its JS identity. Passing it to native code follows `releasedObjectPolicy`: under `"report"` the argument diff --git a/types/ns-runtime.d.ts b/types/ns-runtime.d.ts index 24a03e1a..6752b93b 100644 --- a/types/ns-runtime.d.ts +++ b/types/ns-runtime.d.ts @@ -51,10 +51,15 @@ declare module "ns:runtime" { * while something other than the runtime retains its object, and is * weak otherwise; native objects of collected wrappers are released on * the runtime's thread. + * - `"canary"`: no resurrection. A wrapper in scope dies with its JS + * references; JS properties set on it live on a state object kept until + * the native object is deallocated, and a wrapper built for the object + * later has them again (as a new object: `===` with the old one fails). + * State that references its own object keeps that object alive. */ - wrapperLifetimeMode: "resurrecting" | "rooted"; + wrapperLifetimeMode: "resurrecting" | "rooted" | "canary"; /** - * Which wrappers `"rooted"` roots, from the app config + * Which wrappers `"rooted"` roots, or `"canary"` makes canaries, from the app config * `"wrapperLifetime": { "scope": ... }`. Read-only: `setConfig` throws. * * - `"extended"` (default): instances of JS-extended classes. @@ -84,8 +89,12 @@ declare module "ns:runtime" { promotions: number; /** `"rooted"` only: strong wrappers made weak because native code let go of their object. */ demotions: number; - /** `"rooted"` only: collected wrappers whose native object was queued for release. */ + /** `"rooted"` and `"canary"`: collected wrappers whose native object was queued for release. */ queuedReleases: number; + /** `"canary"` only: state objects created for native objects' JS properties. */ + canaryStates: number; + /** `"canary"` only: state objects let go of because their native object was deallocated. */ + canaryNativeDeaths: number; } /** diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index ce12226f..be43030d 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -313,6 +313,8 @@ C2DDEBAF229EAC8300345BFE /* ObjectManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB86229EAC8300345BFE /* ObjectManager.mm */; }; DD6B0F1ACE8AE363C72245E9 /* WrapperLifetime.h in Headers */ = {isa = PBXBuildFile; fileRef = 9E6F52B357F2776D77D71D8A /* WrapperLifetime.h */; }; 1DE238EF3BDD0EF221E13C44 /* WrapperLifetime.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13A9D13202DBEA9487A90B03 /* WrapperLifetime.mm */; }; + A0636BA435F52099D6955AC5 /* WrapperLifetimeCanary.h in Headers */ = {isa = PBXBuildFile; fileRef = 06AF90E16F4178A9D3261C14 /* WrapperLifetimeCanary.h */; }; + 2A9D21FFAFE7419B7E69B585 /* WrapperLifetimeCanary.mm in Sources */ = {isa = PBXBuildFile; fileRef = CB7C1CED65B5F547091F0B28 /* WrapperLifetimeCanary.mm */; }; C2DDEBB0229EAC8300345BFE /* StringHasher.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB87229EAC8300345BFE /* StringHasher.h */; }; C2DDEBB1229EAC8300345BFE /* Caches.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB88229EAC8300345BFE /* Caches.cpp */; }; C2DDEBB2229EAC8300345BFE /* ModuleInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB89229EAC8300345BFE /* ModuleInternal.h */; }; @@ -845,6 +847,8 @@ C2DDEB86229EAC8300345BFE /* ObjectManager.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ObjectManager.mm; sourceTree = ""; }; 9E6F52B357F2776D77D71D8A /* WrapperLifetime.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WrapperLifetime.h; sourceTree = ""; }; 13A9D13202DBEA9487A90B03 /* WrapperLifetime.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = WrapperLifetime.mm; sourceTree = ""; }; + 06AF90E16F4178A9D3261C14 /* WrapperLifetimeCanary.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WrapperLifetimeCanary.h; sourceTree = ""; }; + CB7C1CED65B5F547091F0B28 /* WrapperLifetimeCanary.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = WrapperLifetimeCanary.mm; sourceTree = ""; }; C2DDEB87229EAC8300345BFE /* StringHasher.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StringHasher.h; sourceTree = ""; }; C2DDEB88229EAC8300345BFE /* Caches.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Caches.cpp; sourceTree = ""; }; C2DDEB89229EAC8300345BFE /* ModuleInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModuleInternal.h; sourceTree = ""; }; @@ -1530,6 +1534,8 @@ C2DDEB86229EAC8300345BFE /* ObjectManager.mm */, 9E6F52B357F2776D77D71D8A /* WrapperLifetime.h */, 13A9D13202DBEA9487A90B03 /* WrapperLifetime.mm */, + 06AF90E16F4178A9D3261C14 /* WrapperLifetimeCanary.h */, + CB7C1CED65B5F547091F0B28 /* WrapperLifetimeCanary.mm */, C20AB5E526E1015200E2B41D /* OneByteStringResource.h */, C20AB5E426E1015200E2B41D /* OneByteStringResource.cpp */, 4A5C201A2E2B000300000002 /* Performance.h */, @@ -1688,6 +1694,7 @@ C20AB5E726E1015300E2B41D /* OneByteStringResource.h in Headers */, C2DDEBAC229EAC8300345BFE /* ObjectManager.h in Headers */, DD6B0F1ACE8AE363C72245E9 /* WrapperLifetime.h in Headers */, + A0636BA435F52099D6955AC5 /* WrapperLifetimeCanary.h in Headers */, 6573B9E7291FE2A700B0ED7C /* jsi.h in Headers */, 6573B9E2291FE2A700B0ED7C /* jsi-inl.h in Headers */, C2D21601248AAAD900EDC646 /* Constants.h in Headers */, @@ -2373,6 +2380,7 @@ AA8C47B22E27114300649BF5 /* ModuleInternalCallbacks.mm in Sources */, C2DDEBAF229EAC8300345BFE /* ObjectManager.mm in Sources */, 1DE238EF3BDD0EF221E13C44 /* WrapperLifetime.mm in Sources */, + 2A9D21FFAFE7419B7E69B585 /* WrapperLifetimeCanary.mm in Sources */, F6191AB029C0FCE8003F588F /* JsV8InspectorClient.mm in Sources */, A1B2C3D40000000000000004 /* WorkerInspectorClient.mm in Sources */, C2DDEB9C229EAC8300345BFE /* ClassBuilder.cpp in Sources */, From 683780aded5f7e91addbc821c555ff0b4b535834 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 19:56:22 -0300 Subject: [PATCH 2/3] fix(runtime): iterate an NSArray's receiver instead of capturing it SymbolIterator::Set gave each NSArray wrapper its own Symbol.iterator function bound to the wrapper. Under the canary wrapper lifetime mode with "scope": "all" that property lives on the array's state object, so the state referenced its own canary and the array was never released. The function now iterates its receiver, which is what `for...of` and `arr[Symbol.iterator]()` pass anyway. --- NativeScript/runtime/SymbolIterator.mm | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/NativeScript/runtime/SymbolIterator.mm b/NativeScript/runtime/SymbolIterator.mm index 15fdec02..33c94083 100644 --- a/NativeScript/runtime/SymbolIterator.mm +++ b/NativeScript/runtime/SymbolIterator.mm @@ -10,17 +10,18 @@ void SymbolIterator::Set(Local context, Local object) { Isolate* isolate = v8::Isolate::GetCurrent(); + // Iterates its receiver rather than capturing `object`: in the canary + // wrapper lifetime mode this property lives on the object's state, which a + // reference back to the object would keep alive until native death. Local iterator; - bool success = v8::Function::New( - context, - [](const FunctionCallbackInfo& args) { - Isolate* isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); - Local object = args.Data(); - Local result = CreateIteratorObject(context, object); - args.GetReturnValue().Set(result); - }, - object) + bool success = v8::Function::New(context, + [](const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + Local result = + CreateIteratorObject(context, args.This()); + args.GetReturnValue().Set(result); + }) .ToLocal(&iterator); tns::Assert(success, isolate); From 79b835be5a15d69324e08868359caa53524ed045 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 20:53:51 -0300 Subject: [PATCH 3/3] chore(v8): pin v8-14.9.207.39-10 The canary wrapper lifetime mode needs v8_nonmasking_own_lookup.patch (crbug 585053531), first shipped in -10. Moving from -7 also brings -8 (Apple TV variants) and -9 (Mac Catalyst with its JIT tiers, whose runtime support is #533). --- V8_RELEASE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/V8_RELEASE b/V8_RELEASE index ac872018..93699c90 100644 --- a/V8_RELEASE +++ b/V8_RELEASE @@ -1 +1 @@ -v8-14.9.207.39-7 +v8-14.9.207.39-10