diff --git a/NativeScript/runtime/ArgConverter.h b/NativeScript/runtime/ArgConverter.h index 9fa21ee4..21c1a61f 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 ef568b42..ce3c0375 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 666d7e63..2b5b3586 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 8ab4a9f6..0dd0a9fa 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 ce792f4a..999a69e6 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 a98655a5..6d4ae4bd 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 d140dba7..05b65de3 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 2cc3e9b5..e7131fc8 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 200afb3c..09155926 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 046eaff1..d75c9e61 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 5d5c79ae..6d3c3b54 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 00000000..54b4a917 --- /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 00000000..8a63976c --- /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 4d77e7a5..558233e5 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 00000000..8dfa862c --- /dev/null +++ b/TestRunner/app/tests/WrapperLifetimeModesTests.js @@ -0,0 +1,241 @@ +// 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(); + }); + + // 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 () { + 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 0ebeb62f..5c62bdd9 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 376b1688..37388515 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 ddde5c40..4a151166 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 1efc29b6..692c8fcd 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 b60ea136..24a03e1a 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 74d96f19..ce12226f 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 */,