From 1ccb59272040cd8aee4c92dc29668ecf432d390b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 19:32:53 -0300 Subject: [PATCH 1/6] 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/6] 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/6] 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 From 497500e5e4d75719fc020eda88e9a5909a666ce6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 13 Nov 2026 01:31:34 -0300 Subject: [PATCH 4/6] docs(runtime): a canary change is invisible to === Only one canary lives at a time, and anything referencing the old one keeps it alive, so no comparison can see the new one. Only weak references to the old canary and native #private brands notice. --- docs/knowledge/v8-resurrecting-finalizers.md | 6 ++++-- types/ns-runtime.d.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/knowledge/v8-resurrecting-finalizers.md b/docs/knowledge/v8-resurrecting-finalizers.md index 5ab28b48..858dd73b 100644 --- a/docs/knowledge/v8-resurrecting-finalizers.md +++ b/docs/knowledge/v8-resurrecting-finalizers.md @@ -232,8 +232,10 @@ its first write until the native object is deallocated, which an associated toke 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 +keeps it alive. A canary built after a death is a new object, but `===` cannot tell: only one +canary lives at a time, and anything still referencing the old one would have kept it alive. +What does notice is whatever keys on the canary without keeping it: `WeakMap` keys and `WeakRef`s +of the old canary, and native `#private` fields, which are branded on it. 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 diff --git a/types/ns-runtime.d.ts b/types/ns-runtime.d.ts index 6752b93b..3d4730d5 100644 --- a/types/ns-runtime.d.ts +++ b/types/ns-runtime.d.ts @@ -54,7 +54,8 @@ declare module "ns:runtime" { * - `"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). + * later has them again. Weak references to the old wrapper (WeakMap keys, + * WeakRef) and native `#private` fields do not carry over. * State that references its own object keeps that object alive. */ wrapperLifetimeMode: "resurrecting" | "rooted" | "canary"; From 4dfeb0461d986b1c566464844eec495dd15070f1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 13 Nov 2026 02:03:02 -0300 Subject: [PATCH 5/6] feat(runtime): choose how canary mode notices native deaths at build time NS_CANARY_DEATH_SIGNAL selects between two ways of learning that a native object with state was deallocated: - NS_CANARY_DEATH_ASSOCIATION (default): an associated token's -dealloc reports it immediately, on any thread. The ObjC runtime aborts for a class that forbids associated objects, and there is no public query. - NS_CANARY_DEATH_WEAK_SWEEP: each record keeps an ObjC weak reference (objc_initWeakOrNil, which declines instead of aborting), and a sweep posted after every major collection lets dead objects' state go. A class refusing weak references keeps its properties on the canary. Both share the records; a re-wrap or write that finds a dead record not yet swept starts over, so a recycled address never inherits state. --- NativeScript/runtime/WrapperLifetimeCanary.h | 4 +- NativeScript/runtime/WrapperLifetimeCanary.mm | 153 ++++++++++++++++-- .../app/tests/WrapperLifetimeModesTests.js | 9 +- docs/knowledge/v8-resurrecting-finalizers.md | 16 +- 4 files changed, 162 insertions(+), 20 deletions(-) diff --git a/NativeScript/runtime/WrapperLifetimeCanary.h b/NativeScript/runtime/WrapperLifetimeCanary.h index 7cd90b7b..22a8212e 100644 --- a/NativeScript/runtime/WrapperLifetimeCanary.h +++ b/NativeScript/runtime/WrapperLifetimeCanary.h @@ -18,7 +18,9 @@ struct CanaryRecord; // 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 +// deallocated, reported by an associated token's -dealloc or found by a sweep +// of weak references after major collections (NS_CANARY_DEATH_SIGNAL in the +// .mm chooses). 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 diff --git a/NativeScript/runtime/WrapperLifetimeCanary.mm b/NativeScript/runtime/WrapperLifetimeCanary.mm index d28209df..e6eb6065 100644 --- a/NativeScript/runtime/WrapperLifetimeCanary.mm +++ b/NativeScript/runtime/WrapperLifetimeCanary.mm @@ -14,18 +14,51 @@ #include "Runtime.h" #include "WrapperLifetime.h" +// How canary mode learns that a native object with state was deallocated, so +// its state can be let go of: +// - ASSOCIATION: a token associated with the object reports it from its +// -dealloc, immediately and on any thread. The ObjC runtime aborts the +// process for a class that forbids associated objects (no public query). +// - WEAK_SWEEP: each record holds a weak reference to the object, checked by a +// sweep after every major collection, so state lingers until then. Classes +// that refuse weak references keep their JS properties on the canary. +#define NS_CANARY_DEATH_ASSOCIATION 1 +#define NS_CANARY_DEATH_WEAK_SWEEP 2 +#ifndef NS_CANARY_DEATH_SIGNAL +#define NS_CANARY_DEATH_SIGNAL NS_CANARY_DEATH_ASSOCIATION +#endif + +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP +extern "C" id objc_initWeakOrNil(id* location, id value); +extern "C" void objc_destroyWeak(id* location); +#endif + 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. +// object's live canary. Marked died once the object is known to be gone (with +// ASSOCIATION, also when objc_removeAssociatedObjects releases the token of a +// live object): its 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}; +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP + // Zeroed by the ObjC runtime when the object is deallocated, on any thread. + id object = nil; + + ~CanaryRecord() { objc_destroyWeak(&object); } + + // A racy read is enough: the slot only goes from the object to nil, and is + // never dereferenced here. + bool ObjectAlive() const { + return __atomic_load_n(reinterpret_cast(&object), __ATOMIC_ACQUIRE) != nullptr; + } +#else + bool ObjectAlive() const { return true; } +#endif }; namespace { @@ -40,19 +73,23 @@ std::vector> dead; bool closed = false; bool drainPosted = false; + bool sweepPosted = false; // Lets wrapper creation skip the lookup while no object has state. std::atomic count{0}; Isolate* isolate = nullptr; std::weak_ptr loop; +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION // The association key: one per isolate, and never reused while a token // keeps this registry alive. char associationKey = 0; +#endif }; struct CanaryState { std::shared_ptr registry = std::make_shared(); }; +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION void DrainDead(const std::shared_ptr& registry) { std::vector> dead; { @@ -102,6 +139,41 @@ void NativeDied(const std::shared_ptr& registry, void* object) { registry->drainPosted = false; } } +#endif + +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP +// Lets go of the state of every object that was deallocated. Runs as an event +// loop task (under the Locker, on the runtime's thread), never inside the GC. +void Sweep(const std::shared_ptr& registry) { + std::vector> dead; + { + std::lock_guard lock(registry->mutex); + registry->sweepPosted = false; + if (registry->closed) { + return; + } + // Records a re-wrap or a write already found dead. + dead.swap(registry->dead); + for (auto it = registry->records.begin(); it != registry->records.end();) { + if (it->second->ObjectAlive()) { + ++it; + continue; + } + it->second->died.store(true, std::memory_order_release); + dead.push_back(std::move(it->second)); + it = registry->records.erase(it); + registry->count.fetch_sub(1, std::memory_order_relaxed); + } + } + ObjectManager::LifetimeStats* stats = ObjectManager::GetLifetimeStats(registry->isolate); + for (std::shared_ptr& record : dead) { + record->state.Reset(); + if (stats != nullptr) { + stats->canaryNativeDeaths++; + } + } +} +#endif std::shared_ptr RegistryFor(Isolate* isolate) { CanaryState* state = Caches::StateFor(isolate); @@ -112,6 +184,7 @@ void NativeDied(const std::shared_ptr& registry, void* object) { } // namespace tns +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION // Associated with a native object once it has state in an isolate; its // -dealloc runs when the object is deallocated. @interface TNSCanaryLifecycleToken : NSObject { @@ -129,6 +202,7 @@ - (void)dealloc { [super dealloc]; } @end +#endif namespace tns { @@ -149,13 +223,27 @@ - (void)dealloc { 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. +// The record's state is usable: its object is not known to be gone 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(); } +// Under the registry mutex: a record whose object is gone but which no sweep +// has reached yet is moved out, so an object now at that address starts over. +void RetireIfDead(CanaryRegistry* registry, + std::unordered_map>::iterator it) { + if (it->second->ObjectAlive()) { + return; + } + 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); +} + // The state object of the holder, if it has one. bool ExistingState(Isolate* isolate, Local holder, Local* out) { ObjectWeakCallbackState* canary = CanaryOf(isolate, holder); @@ -189,22 +277,36 @@ bool StateForWrite(Isolate* isolate, Local holder, Local* out) { if (registry->closed) { return false; } + auto existing = registry->records.find((__bridge void*)object); + if (existing != registry->records.end()) { + RetireIfDead(registry.get(), existing); + } 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)); + auto record = std::make_shared(); +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP + if (objc_initWeakOrNil(&record->object, object) == nil) { + // The class refuses weak references. + registry->records.erase((__bridge void*)object); + return false; + } +#endif + record->state.Reset(isolate, Object::New(isolate, Null(isolate), nullptr, nullptr, 0)); + slot = std::move(record); registry->count.fetch_add(1, std::memory_order_relaxed); created = true; } canary->canaryRecord_ = slot; } if (created) { +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION 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]; +#endif if (ObjectManager::LifetimeStats* stats = ObjectManager::GetLifetimeStats(isolate)) { stats->canaryStates++; } @@ -340,6 +442,30 @@ Intercepted Descriptor(Local property, const PropertyCallbackInfo(isolate); + if (state == nullptr || state->registry->count.load(std::memory_order_relaxed) == 0) { + return; + } + std::shared_ptr registry = state->registry; + std::shared_ptr loop = registry->loop.lock(); + { + std::lock_guard lock(registry->mutex); + if (registry->sweepPosted || loop == nullptr) { + return; + } + registry->sweepPosted = true; + } + if (!loop->PostInternal([registry]() { Sweep(registry); })) { + std::lock_guard lock(registry->mutex); + registry->sweepPosted = false; + } +} +#endif + } // namespace void WrapperLifetimeCanary::Init(Isolate* isolate) { @@ -354,6 +480,10 @@ Intercepted Descriptor(Local property, const PropertyCallbackInforegistry->loop = runtime->GetEventLoop(); } +#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP + isolate->AddGCPrologueCallback( + PostSweepAfterGC, static_cast(kGCTypeMarkSweepCompact | kGCTypeIncrementalMarking)); +#endif } void WrapperLifetimeCanary::ConfigureInstanceTemplate(Local instanceTemplate) { @@ -372,7 +502,12 @@ Intercepted Descriptor(Local property, const PropertyCallbackInfo lock(state->registry->mutex); auto it = state->registry->records.find((__bridge void*)object); - return it != state->registry->records.end() ? it->second : nullptr; + if (it == state->registry->records.end()) { + return nullptr; + } + std::shared_ptr record = it->second; + RetireIfDead(state->registry.get(), it); + return record->ObjectAlive() ? record : nullptr; } void WrapperLifetimeCanary::CloseForTeardown(Isolate* isolate) { diff --git a/TestRunner/app/tests/WrapperLifetimeModesTests.js b/TestRunner/app/tests/WrapperLifetimeModesTests.js index 6859399b..330d5994 100644 --- a/TestRunner/app/tests/WrapperLifetimeModesTests.js +++ b/TestRunner/app/tests/WrapperLifetimeModesTests.js @@ -355,8 +355,9 @@ describe("Wrapper lifetime modes", function () { }); }); - // Third-party code can strip a live object's associations, which releases - // the token that reports native death; the state goes with it. + // Third-party code can strip a live object's associations. Where an + // associated token reports native death, the state goes with it; where a + // weak-reference sweep does, nothing changes. it("survives a live object's associations being removed", function () { if (typeof objc_removeAssociatedObjects !== "function") { return; @@ -364,9 +365,7 @@ describe("Wrapper lifetime modes", function () { var obj = Extended.new(); obj.tag = "first"; objc_removeAssociatedObjects(obj); - if (canary) { - expect(obj.tag).toBeUndefined(); - } + expect([undefined, "first"]).toContain(obj.tag); obj.tag = "second"; expect(obj.tag).toBe("second"); }); diff --git a/docs/knowledge/v8-resurrecting-finalizers.md b/docs/knowledge/v8-resurrecting-finalizers.md index 858dd73b..ba23bdf9 100644 --- a/docs/knowledge/v8-resurrecting-finalizers.md +++ b/docs/knowledge/v8-resurrecting-finalizers.md @@ -228,8 +228,16 @@ the Node-API runtimes can implement. A wrapper in scope is a canary: a phantom-w 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 +its first write until the native object is deallocated; a canary built later for the same +object forwards to the same state. How the deallocation is noticed is a compile-time choice +(`NS_CANARY_DEATH_SIGNAL` in `WrapperLifetimeCanary.mm`): by default a token associated with the +object reports it from its `-dealloc`, immediately and on any thread, but the ObjC runtime aborts +the process for a class that forbids associated objects, and there is no public query for that; +the alternative keeps an ObjC weak reference in each record (`objc_initWeakOrNil`, which declines +instead of aborting for classes that refuse weak references) and sweeps the records after every +major collection, so state lingers until then. With the default, `"scope": "all"` is +experimental for that reason; with `"extended"` only instances of JS-extended classes get a +token (the flag is inherited, so a JS subclass of a forbidding class would still abort). 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. A canary built after a death is a new object, but `===` cannot tell: only one @@ -239,9 +247,7 @@ of the old canary, and native `#private` fields, which are branded on it. 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 +interceptors see the holder but not the receiver. 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). From a84450df0cada7501401929319e91d73b400648b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 13 Nov 2026 02:17:01 -0300 Subject: [PATCH 6/6] feat(runtime): JS-extended classes report their own deallocation in canary mode In canary mode every JS-extended class gets a -dealloc that reports the instance to every isolate's canary registry (an instance can have state in an isolate other than the one that defined its class), then calls the superclass's. Their records need no associated token or weak reference, so the default "extended" scope attaches no associations at all. --- NativeScript/runtime/ClassBuilder.mm | 2 + NativeScript/runtime/WrapperLifetimeCanary.h | 10 +- NativeScript/runtime/WrapperLifetimeCanary.mm | 105 ++++++++++++++---- .../app/tests/WrapperLifetimeModesTests.js | 25 +++++ docs/knowledge/v8-resurrecting-finalizers.md | 7 +- 5 files changed, 123 insertions(+), 26 deletions(-) diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 37defafc..affb8258 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -83,6 +83,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { tns::Assert(extendedClass != nil, isolate); class_addProtocol(extendedClass, @protocol(TNSDerivedClass)); class_addProtocol(object_getClass(extendedClass), @protocol(TNSDerivedClass)); + WrapperLifetimeCanary::InstallDeallocHook(extendedClass); if (!nativeSignature.IsEmpty()) { ClassBuilder::ExposeDynamicMembers(context, extendedClass, implementationObject, @@ -229,6 +230,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { tns::Assert(extendedClass != nil, isolate); class_addProtocol(extendedClass, @protocol(TNSDerivedClass)); class_addProtocol(object_getClass(extendedClass), @protocol(TNSDerivedClass)); + WrapperLifetimeCanary::InstallDeallocHook(extendedClass); extendedClassName = class_getName(extendedClass); diff --git a/NativeScript/runtime/WrapperLifetimeCanary.h b/NativeScript/runtime/WrapperLifetimeCanary.h index 22a8212e..255859e2 100644 --- a/NativeScript/runtime/WrapperLifetimeCanary.h +++ b/NativeScript/runtime/WrapperLifetimeCanary.h @@ -18,8 +18,9 @@ struct CanaryRecord; // for the same object forwards to the same state. // // The state object is rooted from its first write until the native object is -// deallocated, reported by an associated token's -dealloc or found by a sweep -// of weak references after major collections (NS_CANARY_DEATH_SIGNAL in the +// deallocated. A JS-extended class's own -dealloc reports its instances; for +// other objects an associated token's -dealloc does, or a sweep of weak +// references after major collections finds it (NS_CANARY_DEATH_SIGNAL in the // .mm chooses). 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 @@ -37,6 +38,11 @@ class WrapperLifetimeCanary { public: static void Init(v8::Isolate* isolate); + // Gives a JS-extended class a -dealloc that reports its instances' deaths, + // so their state needs no associated token or weak reference. No-op unless + // the mode is canary. + static void InstallDeallocHook(Class extendedClass); + // Installs the forwarding handlers on an ObjC instance template. No-op unless // the mode is canary. static void ConfigureInstanceTemplate(v8::Local instanceTemplate); diff --git a/NativeScript/runtime/WrapperLifetimeCanary.mm b/NativeScript/runtime/WrapperLifetimeCanary.mm index e6eb6065..cf44e239 100644 --- a/NativeScript/runtime/WrapperLifetimeCanary.mm +++ b/NativeScript/runtime/WrapperLifetimeCanary.mm @@ -1,5 +1,6 @@ #include "WrapperLifetimeCanary.h" #import +#include #include #include #include @@ -12,10 +13,12 @@ #include "Helpers.h" #include "ObjectManager.h" #include "Runtime.h" +#import "TNSDerivedClass.h" #include "WrapperLifetime.h" // How canary mode learns that a native object with state was deallocated, so -// its state can be let go of: +// its state can be let go of. Instances of JS-extended classes are reported by +// their class's own -dealloc (InstallDeallocHook) either way; for others: // - ASSOCIATION: a token associated with the object reports it from its // -dealloc, immediately and on any thread. The ObjC runtime aborts the // process for a class that forbids associated objects (no public query). @@ -28,10 +31,8 @@ #define NS_CANARY_DEATH_SIGNAL NS_CANARY_DEATH_ASSOCIATION #endif -#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP extern "C" id objc_initWeakOrNil(id* location, id value); extern "C" void objc_destroyWeak(id* location); -#endif using namespace v8; @@ -45,8 +46,12 @@ struct CanaryRecord { Global state; std::atomic died{false}; -#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP - // Zeroed by the ObjC runtime when the object is deallocated, on any thread. + // Something reports this object's deallocation: a JS-extended class's + // -dealloc, or the associated token. Otherwise only the weak reference below + // tells, when a sweep reads it. + bool reportsDeath = false; + // An ObjC weak reference to the object, zeroed by the runtime when it is + // deallocated, on any thread. Set for records nothing reports. id object = nil; ~CanaryRecord() { objc_destroyWeak(&object); } @@ -54,11 +59,9 @@ // A racy read is enough: the slot only goes from the object to nil, and is // never dereferenced here. bool ObjectAlive() const { - return __atomic_load_n(reinterpret_cast(&object), __ATOMIC_ACQUIRE) != nullptr; + return reportsDeath || + __atomic_load_n(reinterpret_cast(&object), __ATOMIC_ACQUIRE) != nullptr; } -#else - bool ObjectAlive() const { return true; } -#endif }; namespace { @@ -89,7 +92,6 @@ bool ObjectAlive() const { std::shared_ptr registry = std::make_shared(); }; -#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION void DrainDead(const std::shared_ptr& registry) { std::vector> dead; { @@ -109,8 +111,11 @@ void DrainDead(const std::shared_ptr& registry) { } } -// From the token's -dealloc, on whichever thread deallocates the object. +// From a reporter's -dealloc, on whichever thread deallocates the object. void NativeDied(const std::shared_ptr& registry, void* object) { + if (registry->count.load(std::memory_order_relaxed) == 0) { + return; + } std::shared_ptr loop; { std::lock_guard lock(registry->mutex); @@ -139,7 +144,35 @@ void NativeDied(const std::shared_ptr& registry, void* object) { registry->drainPosted = false; } } -#endif + +// Every isolate's registry, for the extended classes' -dealloc: an instance can +// have state in an isolate other than the one that defined its class. +std::mutex registriesMutex_; +std::vector> registries_; + +void ReportDeallocToAllRegistries(void* object) { + std::vector> live; + { + std::lock_guard lock(registriesMutex_); + for (auto it = registries_.begin(); it != registries_.end();) { + if (std::shared_ptr registry = it->lock()) { + if (registry->count.load(std::memory_order_relaxed) > 0) { + live.push_back(std::move(registry)); + } + ++it; + } else { + it = registries_.erase(it); + } + } + } + for (const std::shared_ptr& registry : live) { + NativeDied(registry, object); + } +} + +bool IsExtendedInstance(id object) { + return [object_getClass(object) conformsToProtocol:@protocol(TNSDerivedClass)]; +} #if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP // Lets go of the state of every object that was deallocated. Runs as an event @@ -271,6 +304,8 @@ bool StateForWrite(Isolate* isolate, Local holder, Local* out) { } } id object = canary->lifetimeTarget_; + // A JS-extended class's own -dealloc reports its instances' deaths. + bool extended = IsExtendedInstance(object); bool created = false; { std::lock_guard lock(registry->mutex); @@ -284,13 +319,12 @@ bool StateForWrite(Isolate* isolate, Local holder, Local* out) { std::shared_ptr& slot = registry->records[(__bridge void*)object]; if (slot == nullptr) { auto record = std::make_shared(); -#if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP - if (objc_initWeakOrNil(&record->object, object) == nil) { + record->reportsDeath = extended || NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION; + if (!record->reportsDeath && objc_initWeakOrNil(&record->object, object) == nil) { // The class refuses weak references. registry->records.erase((__bridge void*)object); return false; } -#endif record->state.Reset(isolate, Object::New(isolate, Null(isolate), nullptr, nullptr, 0)); slot = std::move(record); registry->count.fetch_add(1, std::memory_order_relaxed); @@ -300,12 +334,14 @@ bool StateForWrite(Isolate* isolate, Local holder, Local* out) { } if (created) { #if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_ASSOCIATION - 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 (!extended) { + 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]; + } #endif if (ObjectManager::LifetimeStats* stats = ObjectManager::GetLifetimeStats(isolate)) { stats->canaryStates++; @@ -480,12 +516,39 @@ void PostSweepAfterGC(Isolate* isolate, GCType, GCCallbackFlags) { if (Runtime* runtime = Runtime::GetRuntime(isolate)) { state->registry->loop = runtime->GetEventLoop(); } + { + std::lock_guard lock(registriesMutex_); + registries_.push_back(state->registry); + } #if NS_CANARY_DEATH_SIGNAL == NS_CANARY_DEATH_WEAK_SWEEP isolate->AddGCPrologueCallback( PostSweepAfterGC, static_cast(kGCTypeMarkSweepCompact | kGCTypeIncrementalMarking)); #endif } +void WrapperLifetimeCanary::InstallDeallocHook(Class extendedClass) { + if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kCanary) { + return; + } + static std::mutex hookedMutex; + static std::unordered_map hooked; + { + std::lock_guard lock(hookedMutex); + if (hooked[extendedClass]) { + return; + } + hooked[extendedClass] = true; + } + Class superclass = class_getSuperclass(extendedClass); + IMP dealloc = imp_implementationWithBlock(^(id self) { + ReportDeallocToAllRegistries((__bridge void*)self); + struct objc_super super = {.receiver = self, .super_class = superclass}; + reinterpret_cast(objc_msgSendSuper)(&super, + @selector(dealloc)); + }); + class_addMethod(extendedClass, @selector(dealloc), dealloc, "v@:"); +} + void WrapperLifetimeCanary::ConfigureInstanceTemplate(Local instanceTemplate) { if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kCanary) { return; diff --git a/TestRunner/app/tests/WrapperLifetimeModesTests.js b/TestRunner/app/tests/WrapperLifetimeModesTests.js index 330d5994..1a5356e3 100644 --- a/TestRunner/app/tests/WrapperLifetimeModesTests.js +++ b/TestRunner/app/tests/WrapperLifetimeModesTests.js @@ -337,6 +337,31 @@ describe("Wrapper lifetime modes", function () { }); }); + // Plain objects have no class -dealloc of ours: an associated token or a + // weak-reference sweep reports them. + it("lets a plain 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 = TNSAllocLog.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 && scope === "all") { + 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. diff --git a/docs/knowledge/v8-resurrecting-finalizers.md b/docs/knowledge/v8-resurrecting-finalizers.md index ba23bdf9..0e4bbbcf 100644 --- a/docs/knowledge/v8-resurrecting-finalizers.md +++ b/docs/knowledge/v8-resurrecting-finalizers.md @@ -229,15 +229,16 @@ that dies with its JS references, releasing the runtime's retain through the sam `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; a canary built later for the same -object forwards to the same state. How the deallocation is noticed is a compile-time choice +object forwards to the same state. Instances of JS-extended classes report their own +deallocation: the runtime gives each extended class a `-dealloc` that tells every isolate's +registry, then calls the superclass's. For other objects it is a compile-time choice (`NS_CANARY_DEATH_SIGNAL` in `WrapperLifetimeCanary.mm`): by default a token associated with the object reports it from its `-dealloc`, immediately and on any thread, but the ObjC runtime aborts the process for a class that forbids associated objects, and there is no public query for that; the alternative keeps an ObjC weak reference in each record (`objc_initWeakOrNil`, which declines instead of aborting for classes that refuse weak references) and sweeps the records after every major collection, so state lingers until then. With the default, `"scope": "all"` is -experimental for that reason; with `"extended"` only instances of JS-extended classes get a -token (the flag is inherited, so a JS subclass of a forbidding class would still abort). It cannot be +experimental for that reason; `"extended"` attaches no tokens at all. 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. A canary built after a death is a new object, but `===` cannot tell: only one