From e4a9fcd3eb0891c0e2118f657aee18a765edc185 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 18:43:28 -0300 Subject: [PATCH 1/2] feat(runtime): rooted wrapper lifetime mode Adds the app config "wrapperLifetime": { "mode", "scope" }, read once per process. "resurrecting" (default) is the existing behaviour. "rooted" stops relying on resurrecting finalizers for ObjC object wrappers: - An adopted wrapper's handle is a phantom (kParameter) weak handle instead of a kFinalizer one. Its first pass only queues the registration; the native object is released later, on the runtime's thread, from the event loop or on entry to a native call. - A wrapper in scope is a strong root while something other than the runtime retains its object. A major-GC prologue compares -retainCount (the first non-JS implementation) against the runtime's own retains, counted process-wide, and promotes or demotes. Wrappers are born strong and made strong again when they cross the JS/native boundary. - "scope": "extended" (default) roots only JS-extended instances; "all" roots every ObjC wrapper except blocks and tagged pointers. - The extended classes' retain/release swizzle is not installed in "rooted". A JS-unreachable holder that native code owns therefore stays rooted with everything it references, so nothing it reaches is released under it (the ListView shape in WrapperLifetimeModesTests.js). Cache readers drop an Instances entry whose handle is empty (its release is still queued) instead of handing out a dead wrapper. Teardown releases whatever the queue holds through Caches::ExistingStateFor, since the isolate is already invalidated by then. ns:runtime gains the read-only config keys wrapperLifetimeMode and wrapperLifetimeScope, and the promotions, demotions and queuedReleases counters. --- NativeScript/runtime/ArgConverter.h | 4 + NativeScript/runtime/ArgConverter.mm | 56 ++- NativeScript/runtime/Caches.h | 16 + NativeScript/runtime/ClassBuilder.h | 4 +- NativeScript/runtime/ClassBuilder.mm | 195 ++++----- NativeScript/runtime/DataWrapper.h | 6 + NativeScript/runtime/Interop.mm | 2 + NativeScript/runtime/MetadataBuilder.mm | 27 +- NativeScript/runtime/NsBuiltinModules.cpp | 22 + NativeScript/runtime/ObjectManager.h | 21 + NativeScript/runtime/ObjectManager.mm | 45 +- NativeScript/runtime/WrapperLifetime.h | 96 +++++ NativeScript/runtime/WrapperLifetime.mm | 391 ++++++++++++++++++ TestRunner/app/tests/GCFinalizerTests.js | 16 +- .../app/tests/WrapperLifetimeModesTests.js | 240 +++++++++++ TestRunner/app/tests/WrapperLifetimeTests.js | 15 +- TestRunner/app/tests/index.js | 1 + docs/knowledge/v8-resurrecting-finalizers.md | 14 + docs/ns-builtin-modules.md | 6 + types/ns-runtime.d.ts | 26 ++ v8ios.xcodeproj/project.pbxproj | 8 + 21 files changed, 1077 insertions(+), 134 deletions(-) create mode 100644 NativeScript/runtime/WrapperLifetime.h create mode 100644 NativeScript/runtime/WrapperLifetime.mm create mode 100644 TestRunner/app/tests/WrapperLifetimeModesTests.js diff --git a/NativeScript/runtime/ArgConverter.h b/NativeScript/runtime/ArgConverter.h index 9fa21ee4d..21c1a61fc 100644 --- a/NativeScript/runtime/ArgConverter.h +++ b/NativeScript/runtime/ArgConverter.h @@ -64,6 +64,10 @@ class ArgConverter { // recycled the address and give it a foreign prototype. static std::shared_ptr> FindCachedInstance( v8::Isolate* isolate, const std::shared_ptr& cache, id target); + // The JS `this` for a native call into a JS-implemented member: the object's + // wrapper, `fallback` when it has none (an empty fallback builds one). + static v8::Local WrapCallbackSelf(v8::Local context, id self, + v8::Local fallback); static const Meta* GetMeta(const std::string& name); static const ProtocolMeta* FindProtocolMeta(Protocol* protocol); static void MethodCallback(ffi_cif* cif, void* retValue, void** argValues, diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index ef568b42f..ce3c0375a 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -9,6 +9,7 @@ #include "NSExceptionSupport.h" #include "NativeScriptException.h" #include "ObjectManager.h" +#include "WrapperLifetime.h" #include "Runtime.h" #include "RuntimeConfig.h" @@ -31,8 +32,10 @@ Class allocKlass = nil; bool instanceMethod = !receiver.IsEmpty(); bool callSuper = false; + WrapperLifetime::DrainAtCallEntry(isolate); if (instanceMethod) { BaseDataWrapper* wrapper = tns::GetValue(isolate, receiver); + WrapperLifetime::Escape(wrapper); if (wrapper == nullptr) { tns::ReleasedReason releasedReason = tns::GetReleasedReason(receiver); @@ -368,8 +371,14 @@ throw NativeScriptException( if (data->initialParamIndex_ > 1) { id self_ = *static_cast(argValues[0]); auto it = cache->Instances.find(self_); + if (it != cache->Instances.end() && it->second->IsEmpty()) { + // A dead wrapper whose release is queued: rebuild below. + cache->Instances.erase(it); + it = cache->Instances.end(); + } if (it != cache->Instances.end()) { thiz = it->second->Get(data->isolateWrapper_.Isolate()).As(); + WrapperLifetime::Escape(tns::GetValue(isolate, thiz)); } else { ObjCDataWrapper* wrapper = new ObjCDataWrapper(self_); thiz = ArgConverter::CreateJsWrapper(context, wrapper, Local(), true).As(); @@ -662,6 +671,7 @@ throw NativeScriptException( if (!resultIsOwned) { [result retain]; } + WrapperLifetime::Adopt(isolate, wrapper, poThiz, result); ExternalMemory::ChargeEstimatedSize(isolate, thiz); } } @@ -922,14 +932,24 @@ throw NativeScriptException( auto cache = Caches::Get(isolate); if (receiver.IsEmpty()) { auto it = cache->Instances.find(target); + if (it != cache->Instances.end() && it->second->IsEmpty()) { + // The wrapper died and its release is still queued (WrapperLifetime); + // the queue entry keeps its own retain, so a new wrapper starts over. + cache->Instances.erase(it); + it = cache->Instances.end(); + } if (it != cache->Instances.end()) { receiver = it->second->Get(isolate).As(); + WrapperLifetime::Escape(tns::GetValue(isolate, receiver)); } else { std::shared_ptr> poValue = CreateEmptyObject(context, skipGCRegistration); receiver = poValue->Get(isolate).As(); tns::SetValue(isolate, receiver, wrapper); cache->Instances.emplace(target, poValue); [target retain]; + if (wrapper->Type() == WrapperType::ObjCObject) { + WrapperLifetime::Adopt(isolate, static_cast(wrapper), poValue, target); + } } } else { tns::SetValue(isolate, receiver, wrapper); @@ -986,8 +1006,14 @@ throw NativeScriptException( if (it == cache->Instances.end()) { return nullptr; } + if (it->second->IsEmpty()) { + // A dead wrapper whose release is queued (WrapperLifetime). + cache->Instances.erase(it); + return nullptr; + } BaseDataWrapper* wrapper = tns::GetValue(isolate, it->second->Get(isolate)); + WrapperLifetime::Escape(wrapper); if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { Class expected = static_cast(wrapper)->Klass(); // A KVO-style isa swizzle replaces the class in place but keeps -class @@ -1004,6 +1030,30 @@ throw NativeScriptException( return it->second; } +Local ArgConverter::WrapCallbackSelf(Local context, id self, + Local fallback) { + Isolate* isolate = v8::Isolate::GetCurrent(); + auto cache = Caches::Get(isolate); + auto it = cache->Instances.find(self); + if (it != cache->Instances.end() && it->second->IsEmpty()) { + // A dead wrapper whose release is queued (WrapperLifetime). + cache->Instances.erase(it); + it = cache->Instances.end(); + } + if (it != cache->Instances.end()) { + Local existing = it->second->Get(isolate).As(); + WrapperLifetime::Escape(tns::GetValue(isolate, existing)); + return existing; + } + if (!fallback.IsEmpty()) { + return fallback; + } + ObjCDataWrapper* wrapper = new ObjCDataWrapper(self); + Local result = CreateJsWrapper(context, wrapper, Local()).As(); + tns::DeleteWrapperIfUnused(isolate, result, wrapper); + return result; +} + const Meta* ArgConverter::FindMeta(Class klass, const TypeEncoding* typeEncoding) { if (typeEncoding != nullptr && typeEncoding->type == BinaryTypeEncodingType::InterfaceDeclarationReference) { @@ -1165,8 +1215,10 @@ throw NativeScriptException( std::shared_ptr cache = Caches::Get(isolate); auto it = cache->Instances.find(obj); - if (it != cache->Instances.end()) { - args.GetReturnValue().Set(it->second->Get(isolate)); + if (it != cache->Instances.end() && !it->second->IsEmpty()) { + Local existing = it->second->Get(isolate); + WrapperLifetime::Escape(tns::GetValue(isolate, existing)); + args.GetReturnValue().Set(existing); return v8::Intercepted::kYes; } diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 666d7e631..2b5b35860 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -110,6 +110,22 @@ class Caches { return cache->GetOrCreateState(); } + // Teardown only: the existing state, even after InvalidateIsolate, for a + // subsystem that must finish work its state still holds. Never creates. + template + static T* ExistingStateFor(v8::Isolate* isolate) { + auto raw = isolate->GetData(0); + if (raw == nullptr) { + return nullptr; + } + Caches* cache = reinterpret_cast*>(raw)->get(); + size_t index = StateSlotIndexFor(); + if (index >= cache->stateSlots_.size()) { + return nullptr; + } + return static_cast(cache->stateSlots_[index].get()); + } + inline int getIsolateId() { return isolateId_; } // The id assigned at Init; unlike getIsolateId() it survives diff --git a/NativeScript/runtime/ClassBuilder.h b/NativeScript/runtime/ClassBuilder.h index 8ab4a9f6a..0dd0a9fad 100644 --- a/NativeScript/runtime/ClassBuilder.h +++ b/NativeScript/runtime/ClassBuilder.h @@ -25,6 +25,9 @@ struct PropertyCallbackContext { class ClassBuilder { public: + // The implementation of `method` on the first class above any JS-extended + // (TNSDerivedClass) ones in klass's hierarchy. + static IMP FindNotOverridenMethod(Class klass, SEL method); static v8::Local GetExtendFunction( v8::Isolate* isolate, const InterfaceMeta* interfaceMeta); static Class GetExtendedClass(const std::string& baseClassName, @@ -72,7 +75,6 @@ class ClassBuilder { static std::string GetTypeEncoding(const TypeEncoding* typeEncoding); static BinaryTypeEncodingType GetTypeEncodingType(v8::Isolate* isolate, v8::Local value); - static IMP FindNotOverridenMethod(Class klass, SEL method); struct CacheItem { public: diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index ce792f4aa..999a69e68 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -1,4 +1,5 @@ #include "ClassBuilder.h" +#include "WrapperLifetime.h" #include #include #include @@ -302,108 +303,112 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { class_addMethod(object_getClass(extendedClass), @selector(initialize), newInitialize, "v@:"); - /// We swizzle the retain and release methods for the following reason: - /// When we instantiate a native class via a JavaScript call we add it to the object - /// instances map thus incrementing the retainCount by 1. Then, when the native object is - /// referenced somewhere else its count will become more than 1. Since we want to keep the - /// corresponding JavaScript object alive even if it is not used anywhere, we call GcProtect - /// on it. Whenever the native object is released so that its retainCount is 1 (the object - /// instances map), we unprotect the corresponding JavaScript object in order to make both - /// of them destroyable/GC-able. When the JavaScript object is GC-ed we release the native - /// counterpart as well. - id (*retain)(id, SEL) = - (id (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(retain)); - IMP newRetain = imp_implementationWithBlock(^id(id self) { - if (!isolateWrapper.IsValid()) { - return retain(self, @selector(retain)); - } - if ([self retainCount] == 1) { - auto runtime = Runtime::GetRuntime(isolate); - auto runtimeLoop = runtime->RuntimeLoop(); - void* weakSelf = (__bridge void*)self; - auto gcProtect = [isolateWrapper, weakSelf, isolate]() { - auto innerCache = isolateWrapper.GetCache(); - auto it = innerCache->Instances.find((id)weakSelf); - if (it != innerCache->Instances.end()) { - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - Local value = it->second->Get(isolate); - BaseDataWrapper* wrapper = tns::GetValue(isolate, value); - if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { - ObjCDataWrapper* objcWrapper = static_cast(wrapper); - objcWrapper->GcProtect(); - } - } - }; - if (CFRunLoopGetCurrent() != runtimeLoop) { - // bare entry: the closure does its own Locker ceremony, exactly - // like the performed block it replaces - runtime->GetEventLoop()->PostInternalBare(gcProtect); - } else { - gcProtect(); + // The rooted lifetime mode reads native ownership from retain counts + // when a major collection starts, so it needs no swizzle. + if (GetWrapperLifetimeMode() == WrapperLifetimeMode::kResurrecting) { + /// We swizzle the retain and release methods for the following reason: + /// When we instantiate a native class via a JavaScript call we add it to the object + /// instances map thus incrementing the retainCount by 1. Then, when the native object is + /// referenced somewhere else its count will become more than 1. Since we want to keep the + /// corresponding JavaScript object alive even if it is not used anywhere, we call GcProtect + /// on it. Whenever the native object is released so that its retainCount is 1 (the object + /// instances map), we unprotect the corresponding JavaScript object in order to make both + /// of them destroyable/GC-able. When the JavaScript object is GC-ed we release the native + /// counterpart as well. + id (*retain)(id, SEL) = + (id (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(retain)); + IMP newRetain = imp_implementationWithBlock(^id(id self) { + if (!isolateWrapper.IsValid()) { + return retain(self, @selector(retain)); } - } - - return retain(self, @selector(retain)); - }); - class_addMethod(extendedClass, @selector(retain), newRetain, "@@:"); - - void (*release)(id, SEL) = - (void (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(release)); - IMP newRelease = imp_implementationWithBlock(^(id self) { - if (!isolateWrapper.IsValid()) { - release(self, @selector(release)); - return; - } - - if ([self retainCount] == 2) { - void* weakSelf = (__bridge void*)self; - auto gcUnprotect = [isolateWrapper, weakSelf, isolate]() { - auto innerCache = isolateWrapper.GetCache(); - auto it = innerCache->Instances.find((id)weakSelf); - if (it != innerCache->Instances.end()) { - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - if (it->second != nullptr) { + if ([self retainCount] == 1) { + auto runtime = Runtime::GetRuntime(isolate); + auto runtimeLoop = runtime->RuntimeLoop(); + void* weakSelf = (__bridge void*)self; + auto gcProtect = [isolateWrapper, weakSelf, isolate]() { + auto innerCache = isolateWrapper.GetCache(); + auto it = innerCache->Instances.find((id)weakSelf); + if (it != innerCache->Instances.end()) { + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); Local value = it->second->Get(isolate); BaseDataWrapper* wrapper = tns::GetValue(isolate, value); if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { ObjCDataWrapper* objcWrapper = static_cast(wrapper); - objcWrapper->GcUnprotect(); + objcWrapper->GcProtect(); } } + }; + if (CFRunLoopGetCurrent() != runtimeLoop) { + // bare entry: the closure does its own Locker ceremony, exactly + // like the performed block it replaces + runtime->GetEventLoop()->PostInternalBare(gcProtect); + } else { + gcProtect(); } - }; - auto runtime = Runtime::GetRuntime(isolate); - auto runtimeLoop = runtime->RuntimeLoop(); - if (CFRunLoopGetCurrent() != runtimeLoop) { - // bare entry: the closure does its own Locker ceremony, exactly - // like the performed block it replaces - runtime->GetEventLoop()->PostInternalBare(gcUnprotect); - } else { - auto innerCache = isolateWrapper.GetCache(); - auto it = innerCache->Instances.find(self); - if (it != innerCache->Instances.end()) { - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - if (it->second != nullptr) { - Local value = it->second->Get(isolate); - BaseDataWrapper* wrapper = tns::GetValue(isolate, value); - if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { - ObjCDataWrapper* objcWrapper = static_cast(wrapper); - objcWrapper->GcUnprotect(); + } + + return retain(self, @selector(retain)); + }); + class_addMethod(extendedClass, @selector(retain), newRetain, "@@:"); + + void (*release)(id, SEL) = + (void (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(release)); + IMP newRelease = imp_implementationWithBlock(^(id self) { + if (!isolateWrapper.IsValid()) { + release(self, @selector(release)); + return; + } + + if ([self retainCount] == 2) { + void* weakSelf = (__bridge void*)self; + auto gcUnprotect = [isolateWrapper, weakSelf, isolate]() { + auto innerCache = isolateWrapper.GetCache(); + auto it = innerCache->Instances.find((id)weakSelf); + if (it != innerCache->Instances.end()) { + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + if (it->second != nullptr) { + Local value = it->second->Get(isolate); + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + objcWrapper->GcUnprotect(); + } + } + } + }; + auto runtime = Runtime::GetRuntime(isolate); + auto runtimeLoop = runtime->RuntimeLoop(); + if (CFRunLoopGetCurrent() != runtimeLoop) { + // bare entry: the closure does its own Locker ceremony, exactly + // like the performed block it replaces + runtime->GetEventLoop()->PostInternalBare(gcUnprotect); + } else { + auto innerCache = isolateWrapper.GetCache(); + auto it = innerCache->Instances.find(self); + if (it != innerCache->Instances.end()) { + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + if (it->second != nullptr) { + Local value = it->second->Get(isolate); + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + objcWrapper->GcUnprotect(); + } } } } } - } - release(self, @selector(release)); - }); - class_addMethod(extendedClass, @selector(release), newRelease, "v@:"); + release(self, @selector(release)); + }); + class_addMethod(extendedClass, @selector(release), newRelease, "v@:"); + } info.GetReturnValue().SetUndefined(); }).ToLocalChecked(); @@ -916,10 +921,8 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { id thiz = *static_cast(argValues[0]); auto cache = Caches::Get(isolate); - auto it = cache->Instances.find(thiz); - Local self_ = it != cache->Instances.end() - ? it->second->Get(isolate).As() - : context->implementationObject_->Get(isolate); + Local self_ = ArgConverter::WrapCallbackSelf( + Caches::Get(isolate)->GetContext(), thiz, context->implementationObject_->Get(isolate)); Local v8Context = Caches::Get(isolate)->GetContext(); TryCatch tc(isolate); if (!getterFunc->Call(v8Context, self_, 0, nullptr).ToLocal(&res)) { @@ -964,10 +967,8 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { id thiz = *static_cast(argValues[0]); auto cache = Caches::Get(isolate); - auto it = cache->Instances.find(thiz); - Local self_ = it != cache->Instances.end() - ? it->second->Get(isolate).As() - : context->implementationObject_->Get(isolate); + Local self_ = ArgConverter::WrapCallbackSelf( + Caches::Get(isolate)->GetContext(), thiz, context->implementationObject_->Get(isolate)); uint8_t* argBuffer = (uint8_t*)argValues[2]; const TypeEncoding* typeEncoding = context->meta_->setter()->encodings()->first()->next(); diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index a98655a54..6d4ae4bd5 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -397,6 +397,7 @@ class ObjCDataWrapper : public BaseDataWrapper { : data_(data), typeEncoding_(typeEncoding), klass_(object_getClass(data)) {} + ~ObjCDataWrapper(); const WrapperType Type() { return WrapperType::ObjCObject; } @@ -416,8 +417,13 @@ class ObjCDataWrapper : public BaseDataWrapper { // anything keyed on the raw pointer needs this to detect a recycled slot. Class Klass() { return this->klass_; } + // Registration state of a wrapper adopted by WrapperLifetime; null otherwise. + ObjectWeakCallbackState* LifetimeState() { return this->lifetimeState_; } + void SetLifetimeState(ObjectWeakCallbackState* state) { this->lifetimeState_ = state; } + private: bool adapterClaim_ = false; + ObjectWeakCallbackState* lifetimeState_ = nullptr; id data_; const tns::TypeEncoding* typeEncoding_; Class klass_; diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index d140dba74..05b65de34 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -12,6 +12,7 @@ #include "NSDataAdapter.h" #include "NativeScriptException.h" #include "ObjectManager.h" +#include "WrapperLifetime.h" #include "OneByteStringResource.h" #include "Pointer.h" #include "Reference.h" @@ -763,6 +764,7 @@ static bool SnapshotArrayLike(Isolate* isolate, Local object, LocalType() == WrapperType::ObjCObject) { ObjCDataWrapper* objCDataWrapper = static_cast(wrapper); + WrapperLifetime::Escape(objCDataWrapper); id data = objCDataWrapper->Data(); Interop::SetValue(dest, data); } else if (wrapper->Type() == WrapperType::ObjCClass) { diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index 2cc3e9b53..e7131fc87 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -1325,20 +1325,8 @@ CMethodCall methodCall(context, item->userData_, typeEncoding, args, Local res; id thiz = *static_cast(argValues[0]); - auto cache = Caches::Get(isolate); - auto it = cache->Instances.find(thiz); - Local v8Context = Caches::Get(isolate)->GetContext(); - ObjCDataWrapper* wrapper = nullptr; - Local self_ = - it != cache->Instances.end() - ? it->second->Get(isolate).As() - : ArgConverter::CreateJsWrapper(v8Context, wrapper = new ObjCDataWrapper(thiz), - Local()) - .As(); - if (wrapper != nullptr) { - tns::DeleteWrapperIfUnused(isolate, self_, wrapper); - } + Local self_ = ArgConverter::WrapCallbackSelf(v8Context, thiz, Local()); TryCatch tc(isolate); if (!getterFunc->Call(v8Context, self_, 0, nullptr).ToLocal(&res)) { NSException* ex = ArgConverter::HandleBoundaryException(v8Context, tc); @@ -1388,18 +1376,7 @@ CMethodCall methodCall(context, item->userData_, typeEncoding, args, Local v8Context = Caches::Get(isolate)->GetContext(); id thiz = *static_cast(argValues[0]); - auto cache = Caches::Get(isolate); - auto it = cache->Instances.find(thiz); - ObjCDataWrapper* wrapper = nullptr; - Local self_ = - it != cache->Instances.end() - ? it->second->Get(isolate).As() - : ArgConverter::CreateJsWrapper(v8Context, wrapper = new ObjCDataWrapper(thiz), - Local()) - .As(); - if (wrapper != nullptr) { - tns::DeleteWrapperIfUnused(isolate, self_, wrapper); - } + Local self_ = ArgConverter::WrapCallbackSelf(v8Context, thiz, Local()); uint8_t* argBuffer = (uint8_t*)argValues[2]; const TypeEncoding* typeEncoding = context->meta_->setter()->encodings()->first()->next(); diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 200afb3c6..091559261 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -9,6 +9,7 @@ #include "Interop.h" #include "Messaging.h" #include "ObjectManager.h" +#include "WrapperLifetime.h" #include "ModuleInternalCallbacks.h" #include "Runtime.h" #include "StructuredSerialization.h" @@ -103,6 +104,9 @@ std::string Canonicalize(const std::string& specifier) { constexpr const char* kReleasedObjectPolicyKey = "releasedObjectPolicy"; constexpr const char* kDebugKey = "debug"; constexpr const char* kWrapperLifetimeWarningsKey = "wrapperLifetimeWarnings"; +// Read-only: the lifetime mode is fixed at startup from the app config. +constexpr const char* kWrapperLifetimeModeKey = "wrapperLifetimeMode"; +constexpr const char* kWrapperLifetimeScopeKey = "wrapperLifetimeScope"; void ThrowTypeError(Isolate* isolate, const std::string& message) { isolate->ThrowException( @@ -141,6 +145,11 @@ void SetConfigCallback(const FunctionCallbackInfo& info) { } return; } + if (key == kWrapperLifetimeModeKey || key == kWrapperLifetimeScopeKey) { + ThrowTypeError(isolate, "'" + key + + "' is read-only; set \"wrapperLifetime\" in the app config"); + return; + } if (key == kWrapperLifetimeWarningsKey) { if (!EnsureMainIsolateWrite(isolate, key)) { return; @@ -204,6 +213,16 @@ void GetConfigCallback(const FunctionCallbackInfo& info) { info.GetReturnValue().Set(tns::WrapperLifetimeWarningsEnabled()); return; } + if (key == kWrapperLifetimeModeKey) { + info.GetReturnValue().Set( + tns::ToV8String(isolate, tns::WrapperLifetimeModeName(tns::GetWrapperLifetimeMode()))); + return; + } + if (key == kWrapperLifetimeScopeKey) { + info.GetReturnValue().Set( + tns::ToV8String(isolate, tns::WrapperLifetimeScopeName(tns::GetWrapperLifetimeScope()))); + return; + } ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); } @@ -224,6 +243,9 @@ void GetWrapperLifetimeStatsCallback(const FunctionCallbackInfo& info) { {"releasedAccesses", stats->releasedAccesses}, {"stateLostCandidates", stats->stateLostCandidates}, {"stateLostConfirmed", stats->stateLostConfirmed}, + {"promotions", stats->promotions}, + {"demotions", stats->demotions}, + {"queuedReleases", stats->queuedReleases}, }; for (const auto& [name, value] : fields) { result diff --git a/NativeScript/runtime/ObjectManager.h b/NativeScript/runtime/ObjectManager.h index 046eaff14..d75c9e611 100644 --- a/NativeScript/runtime/ObjectManager.h +++ b/NativeScript/runtime/ObjectManager.h @@ -1,11 +1,13 @@ #ifndef ObjectManager_h #define ObjectManager_h +#include #include "Common.h" namespace tns { class ObjectManager; +class ObjCDataWrapper; // Parameter of the kFinalizer weak callback armed on target_. // @@ -38,6 +40,17 @@ struct ObjectWeakCallbackState { // there would free the state under the frame that owns it. Retirement // observes the flag and leaves the handle to that frame. bool disposing_ = false; + + // Set when WrapperLifetime adopted this registration (an ObjC object wrapper + // under a phantom-callback mode). The JS object is unreadable after its + // phantom callback, so the wrapper and object are cached here. + ObjCDataWrapper* lifetimeWrapper_ = nullptr; + id lifetimeTarget_ = nullptr; + std::atomic* runtimeRetains_ = nullptr; + // May be rooted; scanned when a major collection starts. + bool lifetimeTracked_ = false; + // The handle is currently strong. + bool lifetimeStrong_ = false; }; class ObjectManager { @@ -60,6 +73,12 @@ class ObjectManager { // still alive a runloop turn later (see WrapperLifetimeWarningsEnabled). uint64_t stateLostCandidates = 0; uint64_t stateLostConfirmed = 0; + // Rooted mode (WrapperLifetime): wrappers made strong because native code + // owns their object, made weak because it no longer does, and dead + // wrappers whose object was queued for release. + uint64_t promotions = 0; + uint64_t demotions = 0; + uint64_t queuedReleases = 0; }; static LifetimeStats* GetLifetimeStats(v8::Isolate* isolate); @@ -74,6 +93,8 @@ class ObjectManager { // Disposes every handle from Register() that is still alive. Replaces the // old Isolate::VisitHandlesWithClassIds walk, which V8 removed. static void DisposeAllRegistered(v8::Isolate* isolate); + // Removes a registration from the teardown list. + static void Unlink(ObjectWeakCallbackState* state); private: static void ReleaseNativeCounterpartCallback( diff --git a/NativeScript/runtime/ObjectManager.mm b/NativeScript/runtime/ObjectManager.mm index 5d5c79aed..6d3c3b549 100644 --- a/NativeScript/runtime/ObjectManager.mm +++ b/NativeScript/runtime/ObjectManager.mm @@ -8,6 +8,7 @@ #include "FFICall.h" #include "Helpers.h" #include "Runtime.h" +#include "WrapperLifetime.h" #include #include #include @@ -158,6 +159,7 @@ void NoteStateLostCandidate(Isolate* isolate, Local obj, id target) { void ObjectManager::Init(Isolate* isolate, Local globalTemplate) { globalTemplate->Set(tns::ToV8String(isolate, "__releaseNativeCounterpart"), FunctionTemplate::New(isolate, ReleaseNativeCounterpartCallback)); + WrapperLifetime::Init(isolate); } namespace { @@ -175,6 +177,14 @@ void LinkRegistered(v8::Isolate* isolate, ObjectWeakCallbackState* state) { *state->head_ = state; } +void UnlinkRegistered(ObjectWeakCallbackState* state); + +} // namespace + +void ObjectManager::Unlink(ObjectWeakCallbackState* state) { UnlinkRegistered(state); } + +namespace { + void UnlinkRegistered(ObjectWeakCallbackState* state) { if (state->head_ == nullptr) { return; @@ -248,6 +258,10 @@ void DisposeHandle(v8::Isolate* isolate, // claim every state up front: disposing one entry can re-enter JS from // -dealloc and reach __releaseNativeCounterpart for any other entry this // walk still owns. + // Wrappers that died before teardown are already off the list, waiting in + // the release queue. + WrapperLifetime::DrainForTeardown(isolate); + ObjectWeakCallbackState* state = cache->ObjectManagedValues; cache->ObjectManagedValues = nullptr; for (ObjectWeakCallbackState* claimed = state; claimed != nullptr; claimed = claimed->next_) { @@ -258,13 +272,16 @@ void DisposeHandle(v8::Isolate* isolate, ObjectWeakCallbackState* next = state->next_; std::shared_ptr> handle = state->target_; + bool released = false; if (handle != nullptr && !handle->IsEmpty()) { ObjectManager::DisposeValue(isolate, handle->Get(isolate), true); + released = true; if (handle->IsWeak()) { handle->ClearWeak(); } handle->Reset(); } + WrapperLifetime::Retire(isolate, state, released); delete state; state = next; @@ -521,14 +538,32 @@ void DisposeHandle(v8::Isolate* isolate, if (data != nil) { std::shared_ptr cache = Caches::Get(isolate); auto it = cache->Instances.find(data); - if (it != cache->Instances.end()) { + // An adopted wrapper (WrapperLifetime) owns its registration whether or not + // the Instances entry still names it, and its handle may be strong. + ObjectWeakCallbackState* adopted = objcWrapper->LifetimeState(); + if (adopted != nullptr) { + if (adopted->disposing_) { + // The teardown walk owns this state and already released the object. + return; + } + if (it != cache->Instances.end() && it->second == adopted->target_) { + cache->Instances.erase(it); + } + adopted->target_->Reset(); + WrapperLifetime::Forget(isolate, adopted); + objcWrapper->SetLifetimeState(nullptr); + UnlinkRegistered(adopted); + delete adopted; + } else if (it != cache->Instances.end()) { std::shared_ptr> handle = it->second; + bool wasWeak = handle->IsWeak(); ObjectWeakCallbackState* state = - handle->IsWeak() ? handle->ClearWeak() : nullptr; + wasWeak ? handle->ClearWeak() : nullptr; if (state != nullptr && state->disposing_) { - // Reached from a -dealloc running inside this handle's own finalizer: - // that frame already released the native counterpart and owns the - // state, so restore the weakness and let it finish. + // Reached from a -dealloc running inside this handle's own finalizer + // or the teardown walk: that frame already released the native + // counterpart and owns the state, so restore the weakness and let it + // finish. handle->SetWeak(state, FinalizerCallback, WeakCallbackType::kFinalizer); return; } diff --git a/NativeScript/runtime/WrapperLifetime.h b/NativeScript/runtime/WrapperLifetime.h new file mode 100644 index 000000000..54b4a9176 --- /dev/null +++ b/NativeScript/runtime/WrapperLifetime.h @@ -0,0 +1,96 @@ +#ifndef WrapperLifetime_h +#define WrapperLifetime_h + +#include +#include "Common.h" +#include "DataWrapper.h" +#include "ObjectManager.h" + +namespace tns { + +class ObjCDataWrapper; + +// How ObjC object wrappers live. Process-wide, read once from the app config +// "wrapperLifetime": { "mode": ..., "scope": ... } and fixed for the process. +enum class WrapperLifetimeMode { + // kFinalizer callbacks; a natively retained JS-extended instance refuses + // disposal and is resurrected (ObjectManager::DisposeValue). + kResurrecting, + // Phantom callbacks, no resurrection: a wrapper is a strong root while + // something other than the runtime retains its object, weak otherwise. + kRooted, +}; + +enum class WrapperLifetimeScope { + // Only JS-extended instances (TNSDerivedClass) are rooted; other wrappers are + // weak and die with their JS references. + kExtended, + kAll, +}; + +namespace detail { +// Written once, by the first WrapperLifetime::Init, before any wrapper exists. +inline WrapperLifetimeMode wrapperLifetimeMode = WrapperLifetimeMode::kResurrecting; +inline WrapperLifetimeScope wrapperLifetimeScope = WrapperLifetimeScope::kExtended; +} // namespace detail + +inline WrapperLifetimeMode GetWrapperLifetimeMode() { return detail::wrapperLifetimeMode; } +inline WrapperLifetimeScope GetWrapperLifetimeScope() { return detail::wrapperLifetimeScope; } +const char* WrapperLifetimeModeName(WrapperLifetimeMode mode); +const char* WrapperLifetimeScopeName(WrapperLifetimeScope scope); + +class WrapperLifetime { + public: + static void Init(v8::Isolate* isolate); + + // A newly wrapped ObjC object: `handle` is its Instances entry, registered by + // ObjectManager::Register and holding the entry's +1 on `target`. + static void Adopt(v8::Isolate* isolate, ObjCDataWrapper* wrapper, + const std::shared_ptr>& handle, id target); + + // The object crossed the JS/native boundary: native code returned it to JS, + // or received it as a receiver or argument. A weak, rootable wrapper becomes + // strong until the next major collection re-evaluates it. + static inline void Escape(BaseDataWrapper* wrapper) { + if (GetWrapperLifetimeMode() == WrapperLifetimeMode::kRooted && wrapper != nullptr && + wrapper->Type() == WrapperType::ObjCObject) { + EscapeSlow(static_cast(wrapper)); + } + } + + // Releases the native objects of wrappers that died. Runs only where JS may + // run: release can reach a JS-implemented -dealloc. + static void DrainReleases(v8::Isolate* isolate); + + // JS->native call entry: a long synchronous stretch of JS must not hold dead + // objects until it yields. The gate is process-wide; while another isolate + // has queued releases this costs one state lookup per call. + static inline void DrainAtCallEntry(v8::Isolate* isolate) { + if (pendingReleaseIsolates_.load(std::memory_order_relaxed) > 0) { + DrainReleases(isolate); + } + } + + // __releaseNativeCounterpart retired this adopted wrapper's registration. + static void Forget(v8::Isolate* isolate, ObjectWeakCallbackState* state); + + // Teardown, before the registration walk: releases what the queue still holds. + // The isolate is already invalidated and its event loop stopped, so this + // releases inline on the tearing-down thread. + static void DrainForTeardown(v8::Isolate* isolate); + + // Teardown (ObjectManager::DisposeAllRegistered) disposes an adopted wrapper; + // `released` tells whether DisposeValue already released the object. + static void Retire(v8::Isolate* isolate, ObjectWeakCallbackState* state, bool released); + + private: + static void EscapeSlow(ObjCDataWrapper* wrapper); + + public: + // Isolates whose release queue is not empty. + static std::atomic pendingReleaseIsolates_; +}; + +} // namespace tns + +#endif /* WrapperLifetime_h */ diff --git a/NativeScript/runtime/WrapperLifetime.mm b/NativeScript/runtime/WrapperLifetime.mm new file mode 100644 index 000000000..8a63976c2 --- /dev/null +++ b/NativeScript/runtime/WrapperLifetime.mm @@ -0,0 +1,391 @@ +#include "WrapperLifetime.h" +#import +#include +#include +#include +#include +#include +#include +#include +#include "Caches.h" +#include "ClassBuilder.h" +#include "DataWrapper.h" +#include "Helpers.h" +#include "Runtime.h" +#import "TNSDerivedClass.h" + +using namespace v8; + +namespace tns { + +std::atomic WrapperLifetime::pendingReleaseIsolates_{0}; + +namespace { + +std::once_flag configOnce_; + +void ReadConfig() { + id config = Runtime::GetAppConfigValue("wrapperLifetime"); + if (![config isKindOfClass:[NSDictionary class]]) { + return; + } + id mode = ((NSDictionary*)config)[@"mode"]; + if ([mode isKindOfClass:[NSString class]]) { + if ([mode isEqualToString:@"rooted"]) { + detail::wrapperLifetimeMode = WrapperLifetimeMode::kRooted; + } else if (![mode isEqualToString:@"resurrecting"]) { + Log("NativeScript: unknown wrapperLifetime mode \"%s\"; using \"resurrecting\".", + [mode UTF8String]); + } + } + id scope = ((NSDictionary*)config)[@"scope"]; + if ([scope isKindOfClass:[NSString class]]) { + if ([scope isEqualToString:@"all"]) { + detail::wrapperLifetimeScope = WrapperLifetimeScope::kAll; + } else if (![scope isEqualToString:@"extended"]) { + Log("NativeScript: unknown wrapperLifetime scope \"%s\"; using \"extended\".", + [scope UTF8String]); + } + } +} + +// Per-isolate state of the rooted mode. +struct RootedState { + ~RootedState() { + if (!releases.empty()) { + WrapperLifetime::pendingReleaseIsolates_.fetch_sub(1, std::memory_order_relaxed); + } + } + + // Adopted wrappers that may be rooted; scanned when a major collection starts. + std::unordered_set tracked; + // Wrappers whose JS object died; their objects are released by DrainReleases. + std::vector releases; + bool draining = false; + bool drainPosted = false; + // The first -retainCount implementation that is not JS-implemented, per + // class: the scan runs in a GC prologue, where JS may not run. + std::unordered_map retainCountImps; +}; + +// Retains the runtime holds on an object across every isolate: one per +// Instances entry. A wrapper is natively owned when the object's retain count +// exceeds this. Readers in the GC prologue use the cached atomic without the +// lock; entries are only erased when their count reaches zero. Unadopted +// entries (skipGCRegistration) are not counted, so their retain reads as a +// native owner: such an object stays rooted in other isolates (a leak, never an +// early release). +std::mutex runtimeRetainsMutex_; +std::unordered_map>> runtimeRetains_; + +std::atomic* AcquireRuntimeRetain(id obj) { + std::lock_guard lock(runtimeRetainsMutex_); + std::unique_ptr>& slot = runtimeRetains_[(__bridge void*)obj]; + if (slot == nullptr) { + slot = std::make_unique>(0); + } + slot->fetch_add(1, std::memory_order_relaxed); + return slot.get(); +} + +void DropRuntimeRetain(id obj, std::atomic* counter) { + if (counter == nullptr) { + return; + } + std::lock_guard lock(runtimeRetainsMutex_); + if (counter->fetch_sub(1, std::memory_order_relaxed) == 1) { + auto it = runtimeRetains_.find((__bridge void*)obj); + if (it != runtimeRetains_.end() && it->second.get() == counter) { + runtimeRetains_.erase(it); + } + } +} + +bool IsTaggedPointer(id obj) { + // Through void*: clang rejects bit tests on ObjC object pointers. + uintptr_t bits = reinterpret_cast(static_cast(obj)); +#if defined(__arm64__) + return (bits >> 63) != 0; +#else + return (bits & 1) != 0; +#endif +} + +bool IsBlock(id obj) { + static Class blockClass = objc_getClass("NSBlock"); + return blockClass != nil && [obj isKindOfClass:blockClass]; +} + +bool IsRootable(id obj) { + if (IsTaggedPointer(obj) || IsBlock(obj)) { + return false; + } + return GetWrapperLifetimeScope() == WrapperLifetimeScope::kAll || + class_conformsToProtocol(object_getClass(obj), @protocol(TNSDerivedClass)); +} + +NSUInteger NativeRetainCount(RootedState* state, id obj) { + Class cls = object_getClass(obj); + IMP& imp = state->retainCountImps[cls]; + if (imp == nullptr) { + imp = ClassBuilder::FindNotOverridenMethod(cls, @selector(retainCount)); + } + return reinterpret_cast(imp)(obj, @selector(retainCount)); +} + +ObjectManager::LifetimeStats* Stats(Isolate* isolate) { + return ObjectManager::GetLifetimeStats(isolate); +} + +void MakeWeak(ObjectWeakCallbackState* state); + +void PromoteState(ObjectWeakCallbackState* state) { + if (state->lifetimeStrong_ || state->target_->IsEmpty()) { + return; + } + state->target_->ClearWeak(); + state->lifetimeStrong_ = true; +} + +void ScheduleDrain(Isolate* isolate, RootedState* state) { + if (state->drainPosted) { + return; + } + Runtime* runtime = Runtime::GetRuntime(isolate); + std::shared_ptr loop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; + if (loop != nullptr && + loop->PostInternal([isolate]() { WrapperLifetime::DrainReleases(isolate); })) { + state->drainPosted = true; + } +} + +// The wrapper's JS object died. Plain C++ only: the native object is released +// later, from DrainReleases. +void PhantomCallback(const WeakCallbackInfo& info) { + ObjectWeakCallbackState* state = info.GetParameter(); + state->target_->Reset(); + state->lifetimeStrong_ = false; + if (state->disposing_) { + // The teardown walk owns this state and finishes it (WrapperLifetime::Retire). + return; + } + Isolate* isolate = info.GetIsolate(); + RootedState* rooted = Caches::StateFor(isolate); + if (rooted == nullptr) { + // Teardown has begun; the registration stays linked for its walk. + return; + } + ObjectManager::Unlink(state); + rooted->tracked.erase(state); + if (rooted->releases.empty()) { + WrapperLifetime::pendingReleaseIsolates_.fetch_add(1, std::memory_order_relaxed); + } + rooted->releases.push_back(state); + if (ObjectManager::LifetimeStats* stats = Stats(isolate)) { + stats->queuedReleases++; + } + ScheduleDrain(isolate, rooted); +} + +void MakeWeak(ObjectWeakCallbackState* state) { + state->target_->SetWeak(state, PhantomCallback, WeakCallbackType::kParameter); + state->lifetimeStrong_ = false; +} + +// Major-collection scan: root what native code owns, let go of the rest. +void PrologueCallback(Isolate* isolate, GCType, GCCallbackFlags) { + RootedState* rooted = Caches::StateFor(isolate); + if (rooted == nullptr) { + return; + } + ObjectManager::LifetimeStats* stats = Stats(isolate); + for (ObjectWeakCallbackState* state : rooted->tracked) { + if (state->target_->IsEmpty()) { + continue; + } + id target = state->lifetimeTarget_; + NSUInteger count = NativeRetainCount(rooted, target); + int runtimeRetains = + state->runtimeRetains_ != nullptr ? state->runtimeRetains_->load(std::memory_order_relaxed) + : 1; + bool owned = count >= (NSUIntegerMax >> 1) || count > static_cast(runtimeRetains); + if (owned && !state->lifetimeStrong_) { + PromoteState(state); + if (stats != nullptr) { + stats->promotions++; + } + } else if (!owned && state->lifetimeStrong_) { + MakeWeak(state); + if (stats != nullptr) { + stats->demotions++; + } + } + } +} + +void ReleaseQueued(const std::shared_ptr& cache, ObjectWeakCallbackState* state) { + id target = state->lifetimeTarget_; + // A wrapper built for the object after this one died owns the entry now. + auto it = cache->Instances.find(target); + if (it != cache->Instances.end() && it->second == state->target_) { + cache->Instances.erase(it); + } + delete state->lifetimeWrapper_; + std::atomic* counter = state->runtimeRetains_; + delete state; + DropRuntimeRetain(target, counter); + [target release]; +} + +void ReleaseAll(Isolate* isolate, RootedState* rooted) { + std::shared_ptr cache = Caches::Get(isolate); + rooted->draining = true; + // A -dealloc can throw an NSException through here. + struct Reset { + RootedState* rooted; + ~Reset() { rooted->draining = false; } + } reset{rooted}; + while (!rooted->releases.empty()) { + std::vector batch; + batch.swap(rooted->releases); + WrapperLifetime::pendingReleaseIsolates_.fetch_sub(1, std::memory_order_relaxed); + for (ObjectWeakCallbackState* state : batch) { + ReleaseQueued(cache, state); + } + } +} + +} // namespace + +const char* WrapperLifetimeModeName(WrapperLifetimeMode mode) { + return mode == WrapperLifetimeMode::kRooted ? "rooted" : "resurrecting"; +} + +const char* WrapperLifetimeScopeName(WrapperLifetimeScope scope) { + return scope == WrapperLifetimeScope::kAll ? "all" : "extended"; +} + +void WrapperLifetime::Init(Isolate* isolate) { + std::call_once(configOnce_, ReadConfig); + if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kRooted) { + return; + } + isolate->AddGCPrologueCallback( + PrologueCallback, + static_cast(kGCTypeMarkSweepCompact | kGCTypeIncrementalMarking)); +} + +void WrapperLifetime::Adopt(Isolate* isolate, ObjCDataWrapper* wrapper, + const std::shared_ptr>& handle, id target) { + if (GetWrapperLifetimeMode() != WrapperLifetimeMode::kRooted || target == nil) { + return; + } + RootedState* rooted = Caches::StateFor(isolate); + if (rooted == nullptr || !handle->IsWeak()) { + // Unregistered handles (skipGCRegistration) keep their own lifetime. + return; + } + ObjectWeakCallbackState* state = handle->ClearWeak(); + if (state == nullptr) { + return; + } + state->lifetimeWrapper_ = wrapper; + state->lifetimeTarget_ = target; + state->runtimeRetains_ = AcquireRuntimeRetain(target); + wrapper->SetLifetimeState(state); + if (IsRootable(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; + state->lifetimeStrong_ = true; + rooted->tracked.insert(state); + } else { + MakeWeak(state); + } +} + +void WrapperLifetime::EscapeSlow(ObjCDataWrapper* wrapper) { + ObjectWeakCallbackState* state = wrapper->LifetimeState(); + if (state == nullptr || !state->lifetimeTracked_ || state->lifetimeStrong_) { + return; + } + PromoteState(state); +} + +void WrapperLifetime::DrainReleases(Isolate* isolate) { + RootedState* rooted = Caches::StateFor(isolate); + if (rooted == nullptr || rooted->releases.empty()) { + return; + } + // JS can run on another thread under the isolate's Locker; objects such as + // views and CA transactions must be released on the runtime's own thread. + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime != nullptr && CFRunLoopGetCurrent() != runtime->RuntimeLoop()) { + ScheduleDrain(isolate, rooted); + return; + } + rooted->drainPosted = false; + if (rooted->draining) { + // A release ran JS that reached a drain point; the outer loop continues. + return; + } + ReleaseAll(isolate, rooted); +} + +void WrapperLifetime::DrainForTeardown(Isolate* isolate) { + RootedState* rooted = Caches::ExistingStateFor(isolate); + if (rooted == nullptr) { + return; + } + rooted->tracked.clear(); + if (!rooted->draining) { + ReleaseAll(isolate, rooted); + } +} + +void WrapperLifetime::Forget(Isolate* isolate, ObjectWeakCallbackState* state) { + if (state == nullptr || state->lifetimeTarget_ == nil) { + return; + } + if (RootedState* rooted = Caches::StateFor(isolate)) { + rooted->tracked.erase(state); + } + DropRuntimeRetain(state->lifetimeTarget_, state->runtimeRetains_); + state->runtimeRetains_ = nullptr; + if (state->lifetimeWrapper_ != nullptr) { + state->lifetimeWrapper_->SetLifetimeState(nullptr); + } +} + +void WrapperLifetime::Retire(Isolate* isolate, ObjectWeakCallbackState* state, bool released) { + if (state->lifetimeTarget_ == nil) { + return; + } + id target = state->lifetimeTarget_; + std::atomic* counter = state->runtimeRetains_; + state->runtimeRetains_ = nullptr; + if (!released) { + // The JS object died during the teardown walk, before DisposeValue could + // release it. + std::shared_ptr cache = Caches::Get(isolate); + if (cache != nullptr) { + auto it = cache->Instances.find(target); + if (it != cache->Instances.end() && it->second == state->target_) { + cache->Instances.erase(it); + } + } + delete state->lifetimeWrapper_; + DropRuntimeRetain(target, counter); + [target release]; + return; + } + DropRuntimeRetain(target, counter); +} + +ObjCDataWrapper::~ObjCDataWrapper() { + if (this->lifetimeState_ != nullptr) { + this->lifetimeState_->lifetimeWrapper_ = nullptr; + } +} + +} // namespace tns diff --git a/TestRunner/app/tests/GCFinalizerTests.js b/TestRunner/app/tests/GCFinalizerTests.js index 4d77e7a59..558233e59 100644 --- a/TestRunner/app/tests/GCFinalizerTests.js +++ b/TestRunner/app/tests/GCFinalizerTests.js @@ -1,4 +1,15 @@ describe("GC finalizer callbacks", function () { + // Under the rooted wrapper lifetime mode, a dead wrapper's object is + // released from a queue drained on entry to native calls, and a natively + // retained object only becomes collectable once its holder's release ran. + // Under the resurrecting mode the extra rounds change nothing. + function collectAndDrain() { + for (var round = 0; round < 3; round++) { + __collect(); + NSObject.new(); + } + } + // A finalizer callback runs from V8's GC epilogue. Since 14.9, // Heap::CollectGarbage holds a DisallowJavascriptExecution across the whole // collection, so entering JS from one is a GRACEFUL_FATAL that aborts the @@ -29,7 +40,7 @@ describe("GC finalizer callbacks", function () { for (var i = 0; i < 200000; i++) { sink += i % 7; } - __collect(); + collectAndDrain(); expect(sink).toBeGreaterThan(0); expect(calls).toBe(1); @@ -243,8 +254,7 @@ describe("GC finalizer callbacks", function () { })(); scrubStack(); - __collect(); - __collect(); + collectAndDrain(); expect(retired).toBeGreaterThan(0); expect(keepAlive.count).toBe(8); diff --git a/TestRunner/app/tests/WrapperLifetimeModesTests.js b/TestRunner/app/tests/WrapperLifetimeModesTests.js new file mode 100644 index 000000000..f92c78992 --- /dev/null +++ b/TestRunner/app/tests/WrapperLifetimeModesTests.js @@ -0,0 +1,240 @@ +// 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". +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 Extended = (function (_super) { + __extends(Extended, _super); + function Extended() { + return (_super !== null && _super.apply(this, arguments)) || this; + } + return Extended; + })(TNSAllocLog); + + function scrub(n) { + return n > 0 ? scrub(n - 1) + n : 0; + } + + function deallocs() { + return (TNSGetOutput().match(/TNSAllocLog dealloc/g) || []).length; + } + + // Collect, then let the release queue drain (it drains on the event loop + // and on entry to native calls). + function settle(rounds, callback) { + scrub(300); + __collect(); + __collect(); + setTimeout(function () { + NSObject.new(); + if (rounds > 1) { + settle(rounds - 1, callback); + } else { + callback(); + } + }, 10); + } + + beforeEach(function () { + TNSClearOutput(); + }); + + it("reports the configured mode and scope", function () { + expect(["resurrecting", "rooted"].indexOf(mode)).not.toBe(-1); + expect(["extended", "all"].indexOf(scope)).not.toBe(-1); + expect(function () { + runtime.setConfig("wrapperLifetimeMode", "rooted"); + }).toThrowError(TypeError, /read-only/); + }); + + it("keeps the JS state of a natively owned JS-extended object and everything it references", function (done) { + var owner = NSMutableArray.new(); + (function () { + var holder = Extended.new(); + holder.state = { item: TNSAllocLog.new() }; + owner.addObject(holder); + })(); + settle(2, function () { + var holder = owner.objectAtIndex(0); + if (rooted) { + expect(holder.state).toBeDefined(); + expect(function () { + holder.state.item.isEqual(null); + }).not.toThrow(); + } + done(); + }); + }); + + it("keeps the JS state of a natively owned plain object when every wrapper is rooted", function (done) { + var owner = NSMutableArray.new(); + (function () { + var obj = TNSAllocLog.new(); + obj.state = { value: 42 }; + owner.addObject(obj); + })(); + settle(2, function () { + var obj = owner.objectAtIndex(0); + if (rooted && scope === "all") { + expect(obj.state && obj.state.value).toBe(42); + } + done(); + }); + }); + + it("keeps WeakMap entries and WeakRef targets of natively owned objects when every wrapper is rooted", function (done) { + var owner = NSMutableArray.new(); + var map = new WeakMap(); + var ref; + (function () { + var obj = TNSAllocLog.new(); + map.set(obj, "value"); + ref = new WeakRef(obj); + owner.addObject(obj); + })(); + settle(2, function () { + var obj = owner.objectAtIndex(0); + if (rooted && scope === "all") { + expect(map.get(obj)).toBe("value"); + expect(ref.deref()).toBe(obj); + } + done(); + }); + }); + + it("collects objects only JS references", function (done) { + var N = 16; + (function () { + for (var i = 0; i < N; i++) { + var obj = i % 2 === 0 ? Extended.new() : TNSAllocLog.new(); + obj.state = { i: i }; + } + })(); + settle(2, function () { + expect(deallocs()).toBeGreaterThan(N / 2); + done(); + }); + }); + + it("frees a natively owned object once native code lets go of it", function (done) { + var owner = NSMutableArray.new(); + (function () { + var obj = Extended.new(); + obj.state = { i: 1 }; + owner.addObject(obj); + })(); + settle(2, function () { + expect(deallocs()).toBe(0); + owner.removeAllObjects(); + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + it("collects a native-retain cycle whose back reference is a WeakRef", function (done) { + (function () { + var owner = NSMutableArray.new(); + var child = Extended.new(); + owner.addObject(child); + child.state = { owner: new WeakRef(owner) }; + })(); + settle(4, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + + // The ListView shape: JS keeps the cell, the cell points at its item view + // through a WeakRef, and the item view owns native objects. After teardown + // the WeakRef must either be cleared or hand back a view whose native + // objects are alive. + it("never hands a WeakRef target back with released native objects", function (done) { + var cell = NSObject.new(); + (function () { + var view = {}; + var nativeView = TNSAllocLog.new(); + nativeView.owner = view; + view.nativeView = nativeView; + view.color = TNSAllocLog.new(); + cell.owner = new WeakRef(view); + })(); + settle(2, function () { + var view = cell.owner.deref(); + if (rooted && view !== undefined) { + expect(function () { + view.color.isEqual(null); + view.nativeView.isEqual(null); + }).not.toThrow(); + } + done(); + }); + }); + + it("tolerates a JS-implemented retainCount while scanning", function (done) { + var Counting = (function (_super) { + __extends(Counting, _super); + function Counting() { + return (_super !== null && _super.apply(this, arguments)) || this; + } + Counting.prototype.retainCount = function () { + return 1; + }; + return Counting; + })(NSObject); + var owner = NSMutableArray.new(); + (function () { + for (var i = 0; i < 4; i++) { + var obj = Counting.new(); + obj.state = i; + owner.addObject(obj); + } + })(); + settle(2, function () { + expect(owner.count).toBe(4); + if (rooted) { + expect(owner.objectAtIndex(0).state).toBe(0); + } + done(); + }); + }); + + it("lets an explicitly released rooted wrapper go", function (done) { + var owner = NSMutableArray.new(); + var obj = Extended.new(); + owner.addObject(obj); + __releaseNativeCounterpart(obj); + expect(function () { + obj.isEqual(null); + }).toThrowError(ReferenceError); + owner.removeAllObjects(); + settle(2, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + + it("counts rooted-mode activity", function (done) { + var before = runtime.getWrapperLifetimeStats(); + (function () { + for (var i = 0; i < 8; i++) { + TNSAllocLog.new().state = i; + } + })(); + settle(2, function () { + var after = runtime.getWrapperLifetimeStats(); + if (rooted) { + expect(after.queuedReleases).toBeGreaterThan(before.queuedReleases); + expect(after.resurrections).toBe(before.resurrections); + } else { + expect(after.queuedReleases).toBe(before.queuedReleases); + } + done(); + }); + }); +}); diff --git a/TestRunner/app/tests/WrapperLifetimeTests.js b/TestRunner/app/tests/WrapperLifetimeTests.js index 0ebeb62f5..5c62bdd98 100644 --- a/TestRunner/app/tests/WrapperLifetimeTests.js +++ b/TestRunner/app/tests/WrapperLifetimeTests.js @@ -111,6 +111,10 @@ describe("Wrapper lifetime", function () { // object reached UIKit as a dictionary. Zero husks is a legal (if unlucky) // outcome, as in GCFinalizerTests. it("names garbage collection as the reason for an object released under a resurrected holder", function () { + if (runtime.getConfig("wrapperLifetimeMode") !== "resurrecting") { + // Nothing is resurrected, so nothing reachable is released. + return; + } var Holder = (function (_super) { __extends(Holder, _super); function Holder() { @@ -155,6 +159,12 @@ describe("Wrapper lifetime", function () { }); it("reports JS state lost on a wrapper whose native object lives on", function (done) { + if (runtime.getConfig("wrapperLifetimeMode") !== "resurrecting") { + // Rooted wrappers keep their state; unrooted ones die in a phantom + // callback, where their properties can no longer be read. + done(); + return; + } var previous = runtime.getConfig("wrapperLifetimeWarnings"); runtime.setConfig("wrapperLifetimeWarnings", true); var N = 16; @@ -220,9 +230,12 @@ describe("Wrapper lifetime", function () { "releasedAccesses", "releasedExplicitly", "resurrections", + "demotions", + "promotions", + "queuedReleases", "stateLostCandidates", "stateLostConfirmed", - ]); + ].sort()); Object.keys(current).forEach(function (key) { expect(typeof current[key]).toBe("number"); }); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 376b16885..37388515a 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -135,6 +135,7 @@ require("./NsUrlTests"); require("./NsInteropTests"); require("./GCFinalizerTests"); require("./WrapperLifetimeTests"); +require("./WrapperLifetimeModesTests"); require("./BlockCacheRaceTests"); require("./WorkerConcurrentStartupTests"); require("./WorkerOptionsTests"); diff --git a/docs/knowledge/v8-resurrecting-finalizers.md b/docs/knowledge/v8-resurrecting-finalizers.md index ddde5c407..4a1511663 100644 --- a/docs/knowledge/v8-resurrecting-finalizers.md +++ b/docs/knowledge/v8-resurrecting-finalizers.md @@ -209,6 +209,20 @@ receivers included) always throws. `ns:runtime`'s `getWrapperLifetimeStats()` co resurrections, collections, explicit releases and released-object accesses. Specs: `WrapperLifetimeTests.js`. +The app config `"wrapperLifetime": { "mode": "rooted" }` (`WrapperLifetime.{h,mm}`) avoids the +hazard instead of reporting it. An adopted wrapper never gets a `kFinalizer` callback: it is a +strong root while something other than the runtime retains its object and a phantom +(`kParameter`) weak handle otherwise. Strength is decided by a `-retainCount` scan in a +major-GC prologue, where flipping weakness is legal, and a wrapper is born strong and made +strong again whenever it crosses the JS/native boundary. A JS-unreachable holder therefore +stays rooted with its whole closure for as long as native code owns it, so nothing it reaches +is queued. Phantom callbacks only queue; the native objects are released later on the +runtime's thread, where JS may run. The extended classes' retain/release swizzle is not +installed in this mode. `"scope": "extended"` (default) roots only JS-extended instances; +`"all"` roots every ObjC wrapper except blocks and tagged pointers, at the cost of keeping +JS-referencing native cycles alive (ARC rules: break them with a `WeakRef`). Specs: +`WrapperLifetimeModesTests.js`, run under each mode. + 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 1efc29b6b..692c8fcd5 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -116,6 +116,8 @@ 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"` | +| `wrapperLifetimeScope` | `"extended"` \| `"all"` | process-wide, read-only | the app config `"wrapperLifetime": { "scope": … }`, or `"extended"` | ```js const { setConfig, getConfig } = require("ns:runtime"); @@ -144,6 +146,7 @@ The `TypeError` messages are part of the contract: | invalid `releasedObjectPolicy` value | `'releasedObjectPolicy' must be 'report' or 'throw'` | | non-string `debug` value | `'debug' must be a comma-separated category string (), or '' to disable tracing` | | non-boolean `wrapperLifetimeWarnings` value | `'wrapperLifetimeWarnings' must be a boolean` | +| `setConfig` of `wrapperLifetimeMode` or `wrapperLifetimeScope` | `'' is read-only; set "wrapperLifetime" in the app config` | ### Wrapper lifetime counters @@ -158,6 +161,9 @@ every build (they only cost an increment on finalizer and error paths): | `releasedAccesses` | times JS passed a released wrapper to native code, used it as a method receiver, or touched a released pointer, reference or struct | | `stateLostCandidates` | collected wrappers carrying JS properties whose object was still retained by something else (only while `wrapperLifetimeWarnings` is on) | | `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 | 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 b60ea136a..24a03e1a8 100644 --- a/types/ns-runtime.d.ts +++ b/types/ns-runtime.d.ts @@ -41,6 +41,26 @@ declare module "ns:runtime" { * Process-wide; main-isolate writes only. */ wrapperLifetimeWarnings: boolean; + /** + * How ObjC object wrappers live, from the app config + * `"wrapperLifetime": { "mode": ... }`. Read-only: `setConfig` throws. + * + * - `"resurrecting"` (default): a collected wrapper whose JS-extended + * object native code still retains is resurrected by its finalizer. + * - `"rooted"`: no resurrection. A wrapper in scope stays a strong root + * 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. + */ + wrapperLifetimeMode: "resurrecting" | "rooted"; + /** + * Which wrappers `"rooted"` roots, from the app config + * `"wrapperLifetime": { "scope": ... }`. Read-only: `setConfig` throws. + * + * - `"extended"` (default): instances of JS-extended classes. + * - `"all"`: every ObjC object wrapper except blocks and tagged pointers. + */ + wrapperLifetimeScope: "extended" | "all"; } /** @@ -60,6 +80,12 @@ declare module "ns:runtime" { stateLostCandidates: number; /** Candidates whose object was still alive a runloop turn later (warnings enabled only). */ stateLostConfirmed: number; + /** `"rooted"` only: weak wrappers made strong because native code retained their object. */ + 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. */ + queuedReleases: number; } /** diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 74d96f196..ce12226f1 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -311,6 +311,8 @@ C2DDEBAD229EAC8300345BFE /* Caches.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB84229EAC8300345BFE /* Caches.h */; }; C2DDEBAE229EAC8300345BFE /* DictionaryAdapter.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB85229EAC8300345BFE /* DictionaryAdapter.h */; }; 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 */; }; 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 */; }; @@ -841,6 +843,8 @@ C2DDEB84229EAC8300345BFE /* Caches.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Caches.h; sourceTree = ""; }; C2DDEB85229EAC8300345BFE /* DictionaryAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DictionaryAdapter.h; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1524,6 +1528,8 @@ E9A2699467FF325411FC11C4 /* NSExceptionSupport.mm */, C2DDEB83229EAC8300345BFE /* ObjectManager.h */, C2DDEB86229EAC8300345BFE /* ObjectManager.mm */, + 9E6F52B357F2776D77D71D8A /* WrapperLifetime.h */, + 13A9D13202DBEA9487A90B03 /* WrapperLifetime.mm */, C20AB5E526E1015200E2B41D /* OneByteStringResource.h */, C20AB5E426E1015200E2B41D /* OneByteStringResource.cpp */, 4A5C201A2E2B000300000002 /* Performance.h */, @@ -1681,6 +1687,7 @@ 6573B9D0291FE29F00B0ED7C /* JSIV8ValueConverter.h in Headers */, C20AB5E726E1015300E2B41D /* OneByteStringResource.h in Headers */, C2DDEBAC229EAC8300345BFE /* ObjectManager.h in Headers */, + DD6B0F1ACE8AE363C72245E9 /* WrapperLifetime.h in Headers */, 6573B9E7291FE2A700B0ED7C /* jsi.h in Headers */, 6573B9E2291FE2A700B0ED7C /* jsi-inl.h in Headers */, C2D21601248AAAD900EDC646 /* Constants.h in Headers */, @@ -2365,6 +2372,7 @@ C2A5F86A2359AEB600074AFA /* ExtVector.cpp in Sources */, AA8C47B22E27114300649BF5 /* ModuleInternalCallbacks.mm in Sources */, C2DDEBAF229EAC8300345BFE /* ObjectManager.mm in Sources */, + 1DE238EF3BDD0EF221E13C44 /* WrapperLifetime.mm in Sources */, F6191AB029C0FCE8003F588F /* JsV8InspectorClient.mm in Sources */, A1B2C3D40000000000000004 /* WorkerInspectorClient.mm in Sources */, C2DDEB9C229EAC8300345BFE /* ClassBuilder.cpp in Sources */, From 7e9edca73b49cfd4535ca4066c77eba92a7ac6c5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 19:38:02 -0300 Subject: [PATCH 2/2] test(runtime): name the exercised wrapper lifetime mode in the modes spec --- TestRunner/app/tests/WrapperLifetimeModesTests.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TestRunner/app/tests/WrapperLifetimeModesTests.js b/TestRunner/app/tests/WrapperLifetimeModesTests.js index f92c78992..8dfa862c3 100644 --- a/TestRunner/app/tests/WrapperLifetimeModesTests.js +++ b/TestRunner/app/tests/WrapperLifetimeModesTests.js @@ -43,7 +43,8 @@ describe("Wrapper lifetime modes", function () { TNSClearOutput(); }); - it("reports the configured mode and scope", 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(["extended", "all"].indexOf(scope)).not.toBe(-1); expect(function () {