diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index d5464db8..5bd28a9b 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -909,6 +909,12 @@ tns::SetValue(isolate, receiver, wrapper); cache->Instances.emplace(target, poValue); [target retain]; + // A wrapper that skips registration is never collected, so charging it + // could not pay off. + if (!skipGCRegistration) { + ExternalMemory::RecordSoleOwnerCandidate(isolate, cache.get(), + static_cast(wrapper), poValue); + } } } else { tns::SetValue(isolate, receiver, wrapper); diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 666d7e63..5d307531 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -8,6 +8,7 @@ #include "Common.h" #include "ConcurrentMap.h" +#include "ExternalMemory.h" #include "Metadata.h" #include "robin_hood.h" @@ -169,6 +170,7 @@ class Caches { PointerInstances; // ExternalMemory's size estimator per class, resolved on first sight. robin_hood::unordered_map ExternalSizeEstimators; + ExternalMemory::SoleOwnerCandidates SoleOwnerCandidates; // Live IsolateTracked instances (URL, URLSearchParams, URLPattern). Their // weak-callback finalizers never fire at isolate disposal, so teardown diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index a98655a5..64d544a0 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -416,8 +416,26 @@ class ObjCDataWrapper : public BaseDataWrapper { // anything keyed on the raw pointer needs this to detect a recycled slot. Class Klass() { return this->klass_; } + // Retains on Data() held by interop.bufferFromData buffers. Those buffers + // are JS's as much as the wrapper is, so ExternalMemory does not count them + // as native owners. Each buffer's deleter decrements the count from + // whichever thread frees it, possibly after this wrapper is gone, hence the + // shared counter. + std::shared_ptr> BufferRetains() { + if (this->bufferRetains_ == nullptr) { + this->bufferRetains_ = std::make_shared>(0); + } + return this->bufferRetains_; + } + uint32_t BufferRetainCount() const { + return this->bufferRetains_ != nullptr + ? this->bufferRetains_->load(std::memory_order_acquire) + : 0; + } + private: bool adapterClaim_ = false; + std::shared_ptr> bufferRetains_; id data_; const tns::TypeEncoding* typeEncoding_; Class klass_; diff --git a/NativeScript/runtime/ExternalMemory.h b/NativeScript/runtime/ExternalMemory.h index 1590d5d3..6f09c6fd 100644 --- a/NativeScript/runtime/ExternalMemory.h +++ b/NativeScript/runtime/ExternalMemory.h @@ -1,7 +1,13 @@ #ifndef ExternalMemory_h #define ExternalMemory_h +#include +#include + +#include #include +#include +#include #include "v8-external-memory-accounter.h" #include "v8.h" @@ -9,6 +15,8 @@ namespace tns { class BaseDataWrapper; +class Caches; +class ObjCDataWrapper; // Native bytes a wrapper keeps alive, reported to V8 so the collector weighs // them when scheduling collections. The wrapper owns it, so the bytes are @@ -52,6 +60,70 @@ void SetSize(v8::Isolate* isolate, BaseDataWrapper* wrapper, size_t bytes); // nothing, so charging it only buys collections that cannot pay off. void ChargeEstimatedSize(v8::Isolate* isolate, v8::Local value); +// Objects that reached JS at +0 (a block or override argument, an instance +// method's return) whose class has an estimator. Native code still holds such +// an object while the call that handed it over runs, and lets go once that +// call returns or its autorelease pool drains. Each candidate is checked +// exactly once after that point, from the home thread's run loop, and charged +// if no retain other than JS's own is left; one native still holds then is +// never charged, even after native lets go. Worker threads drain no +// autorelease pool per run loop pass, so on a worker only objects their native +// caller releases outright can qualify. +// +// Guarded by the isolate's Locker, except NextDueMs, which the run loop +// observer reads before deciding whether to take the Locker at all. +class SoleOwnerCandidates { + public: + // The thread whose run loop runs the checks. Runtime::Init, on that thread. + void BindToCurrentThread() { homeThread_ = pthread_self(); } + + // When the earliest candidate becomes checkable (NowMs units), or infinity. + double NextDueMs() const { + return nextDueMs_.load(std::memory_order_acquire); + } + + private: + struct Candidate { + id object; + Class klass; + uint8_t estimator; + // The registry handle of the wrapper's JS object. Collection or an + // explicit release empties it, and holding it never keeps the object. + std::shared_ptr> handle; + double dueMs; + }; + + friend void RecordSoleOwnerCandidate( + v8::Isolate*, Caches*, ObjCDataWrapper*, + const std::shared_ptr>&); + friend void ChargeSoleOwners(v8::Isolate*, Caches*); + friend void ChargeEstimatedSize(v8::Isolate*, v8::Local); + + // The checks only run when the home run loop is about to sleep. A loop idling + // between foreign callbacks would never get there, so a no-op internal post + // makes it run one more pass no earlier than `dueMs`. + void EnsureWake(v8::Isolate* isolate, double dueMs, double now); + + pthread_t homeThread_ = nullptr; + std::vector candidates_; + std::atomic nextDueMs_{__builtin_inf()}; + // Fire time of the last wake posted for candidates recorded off the home + // thread. Another is posted for a candidate this one fires before, or too + // long after. + double wakeAtMs_ = -1; +}; + +// Records the object of a wrapper just created for a +0 object as a candidate +// for a deferred charge (see SoleOwnerCandidates). Does nothing for classes +// without an estimator or once the bounded list is full. Isolate entered. +void RecordSoleOwnerCandidate( + v8::Isolate* isolate, Caches* cache, ObjCDataWrapper* wrapper, + const std::shared_ptr>& handle); + +// Checks the candidates that are due and charges those the wrapper alone +// still retains. Home thread, isolate entered, before teardown starts. +void ChargeSoleOwners(v8::Isolate* isolate, Caches* cache); + // Forwards the system's memory pressure events to every live isolate. // Process-wide and idempotent. void StartMemoryPressureMonitoring(); diff --git a/NativeScript/runtime/ExternalMemory.mm b/NativeScript/runtime/ExternalMemory.mm index ec7e3aed..52b881f6 100644 --- a/NativeScript/runtime/ExternalMemory.mm +++ b/NativeScript/runtime/ExternalMemory.mm @@ -7,8 +7,11 @@ #include #include +#include + #include "Caches.h" #include "DataWrapper.h" +#include "EventLoop.h" #include "Helpers.h" #include "IsolateWrapper.h" #include "NSDataAdapter.h" @@ -140,6 +143,17 @@ size_t Estimate(Estimator estimator, id obj) { return 0; } +Estimator EstimatorFor(Caches* cache, Class klass) { + auto& estimators = cache->ExternalSizeEstimators; + auto it = estimators.find(klass); + if (it != estimators.end()) { + return static_cast(it->second); + } + Estimator estimator = ResolveEstimator(klass); + estimators.emplace(klass, estimator); + return estimator; +} + } // namespace void SetSize(Isolate* isolate, BaseDataWrapper* wrapper, size_t bytes) { @@ -170,23 +184,139 @@ void ChargeEstimatedSize(Isolate* isolate, Local value) { return; } - auto& estimators = Caches::Get(isolate)->ExternalSizeEstimators; - Class klass = objcWrapper->Klass(); - auto it = estimators.find(klass); - Estimator estimator; - if (it != estimators.end()) { - estimator = static_cast(it->second); - } else { - estimator = ResolveEstimator(klass); - estimators.emplace(klass, estimator); - } + Caches* cache = Caches::Get(isolate).get(); + Estimator estimator = EstimatorFor(cache, objcWrapper->Klass()); if (estimator == kNone) { return; } + // Wrapping the object just recorded it as a +0 candidate; drop that entry + // so owned objects do not use up the bounded list. + auto& candidates = cache->SoleOwnerCandidates.candidates_; + if (!candidates.empty() && candidates.back().object == obj) { + candidates.pop_back(); + } + SetSize(isolate, wrapper, Estimate(estimator, obj)); } +namespace { + +// Bounds the list between two checks. A burst beyond it goes uncharged. +constexpr size_t kMaxSoleOwnerCandidates = 4096; + +// How long a candidate recorded off the home thread waits before its check. +// Its native caller runs on that other thread and lets go on its own +// schedule, after the callback returns or once that thread's pool drains, so +// there is no point the home thread could observe; a frame is long enough for +// both in practice. +constexpr double kForeignSettleMs = 16; + +// How late a wake may fire for a candidate, so that callbacks arriving within +// this window of each other share one wake. +constexpr double kWakeCoalesceMs = 4; +} // namespace + +void RecordSoleOwnerCandidate(Isolate* isolate, Caches* cache, ObjCDataWrapper* wrapper, + const std::shared_ptr>& handle) { + Estimator estimator = EstimatorFor(cache, wrapper->Klass()); + if (estimator == kNone) { + return; + } + SoleOwnerCandidates& candidates = cache->SoleOwnerCandidates; + if (candidates.candidates_.size() >= kMaxSoleOwnerCandidates) { + return; + } + + bool onHomeThread = pthread_equal(pthread_self(), candidates.homeThread_); + double now = onHomeThread ? 0 : EventLoop::NowMs(); + double dueMs = onHomeThread ? 0 : now + kForeignSettleMs; + candidates.candidates_.push_back({wrapper->Data(), wrapper->Klass(), estimator, handle, dueMs}); + if (dueMs < candidates.NextDueMs()) { + candidates.nextDueMs_.store(dueMs, std::memory_order_release); + } + if (!onHomeThread) { + candidates.EnsureWake(isolate, dueMs, now); + } +} + +void ChargeSoleOwners(Isolate* isolate, Caches* cache) { + SoleOwnerCandidates& candidates = cache->SoleOwnerCandidates; + if (candidates.candidates_.empty()) { + return; + } + + double now = EventLoop::NowMs(); + if (candidates.NextDueMs() > now) { + return; + } + // Charging below can collect, and finalizers can run code that records new + // candidates, so the due ones leave the list before any is charged. + auto firstWaiting = + std::partition(candidates.candidates_.begin(), candidates.candidates_.end(), + [now](const SoleOwnerCandidates::Candidate& c) { return c.dueMs <= now; }); + std::vector due( + std::make_move_iterator(candidates.candidates_.begin()), + std::make_move_iterator(firstWaiting)); + candidates.candidates_.erase(candidates.candidates_.begin(), firstWaiting); + double nextDueMs = __builtin_inf(); + for (const auto& c : candidates.candidates_) { + nextDueMs = std::min(nextDueMs, c.dueMs); + } + candidates.nextDueMs_.store(nextDueMs, std::memory_order_release); + if (nextDueMs != __builtin_inf()) { + candidates.EnsureWake(isolate, nextDueMs, now); + } + + for (SoleOwnerCandidates::Candidate& c : due) { + if (isolate->IsExecutionTerminating()) { + return; + } + if (c.handle->IsEmpty()) { + continue; + } + // Keeps the object alive while charging: an increase can collect. + HandleScope handleScope(isolate); + Local value = c.handle->Get(isolate); + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper == nullptr || wrapper->Type() != WrapperType::ObjCObject || + wrapper->ExternalCharge() != nullptr) { + continue; + } + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + // A wrapper still attached to a live object owns its retain, so the object + // can be read from here on. + if (objcWrapper->Data() != c.object || objcWrapper->Klass() != c.klass) { + continue; + } + // JS's retains are the wrapper's own plus one per interop.bufferFromData + // buffer. Such a buffer's bytes are then weighed twice, by V8 and by this + // charge, until it dies; V8 counting the buffer alone never brings the + // collection that frees the wrapper. The buffer count is read before the + // retain count, and a deleter racing on another thread drops its count + // before its retain, so the race can only make the check miss. Immortal + // objects (constants, tagged pointers) report a count far above these. + uint32_t jsRetains = 1 + objcWrapper->BufferRetainCount(); + if (static_cast(CFGetRetainCount((CFTypeRef)c.object)) != jsRetains) { + continue; + } + SetSize(isolate, wrapper, Estimate(static_cast(c.estimator), c.object)); + } +} + +void SoleOwnerCandidates::EnsureWake(Isolate* isolate, double dueMs, double now) { + if (wakeAtMs_ >= dueMs && wakeAtMs_ <= dueMs + kWakeCoalesceMs) { + return; + } + Runtime* runtime = Runtime::GetRuntime(isolate); + std::shared_ptr loop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; + if (loop == nullptr) { + return; + } + wakeAtMs_ = dueMs + kWakeCoalesceMs; + loop->PostInternalDelayed([] {}, std::max(0.0, wakeAtMs_ - now)); +} + void StartMemoryPressureMonitoring() { static dispatch_source_t source; static dispatch_once_t once; diff --git a/NativeScript/runtime/InteropTypes.mm b/NativeScript/runtime/InteropTypes.mm index 568d9076..51e0ff45 100644 --- a/NativeScript/runtime/InteropTypes.mm +++ b/NativeScript/runtime/InteropTypes.mm @@ -201,16 +201,27 @@ new PrimitiveDataWrapper(sizeof(void*), // Take a +1 retain so the NSData outlives autorelease pool drains while // the ArrayBuffer is alive. The deleter below releases this retain when // V8 finalises the BackingStore (i.e. the ArrayBuffer is GC'd / detached). + // The wrapper counts it too, so the retain is not mistaken for a native + // owner (ExternalMemory::ChargeSoleOwners); the deleter drops the count + // before the retain, which can only make that check miss, never pass + // wrongly. + struct BufferRetain { + id obj; + std::shared_ptr> count; + }; + auto retain = new BufferRetain{obj, wrapper->BufferRetains()}; + retain->count->fetch_add(1, std::memory_order_acq_rel); [obj retain]; std::unique_ptr backingStore = ArrayBuffer::NewBackingStore( data, length, [](void* /*data*/, size_t /*length*/, void* deleter_data) { - if (deleter_data != nullptr) { - [(id)deleter_data release]; - } + auto retain = static_cast(deleter_data); + retain->count->fetch_sub(1, std::memory_order_acq_rel); + [retain->obj release]; + delete retain; }, - obj); + retain); Local result = ArrayBuffer::New(isolate, std::move(backingStore)); info.GetReturnValue().Set(result); diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index 88169bc4..381f64ab 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -174,6 +174,8 @@ class Runtime { static void DrainRejectionsObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void* info); + static void ChargeSoleOwnersObserver(CFRunLoopObserverRef observer, + CFRunLoopActivity activity, void* info); v8::Isolate* isolate_ = nullptr; std::atomic terminationRequested_{false}; napi_env napiEnv_ = nullptr; @@ -184,6 +186,9 @@ class Runtime { // Drains unhandled promise rejections once per runloop turn // (kCFRunLoopBeforeWaiting). Torn down before isolate disposal in ~Runtime. CFRunLoopObserverRef rejectionObserver_ = nullptr; + // Charges +0 objects JS ended up owning (ExternalMemory::ChargeSoleOwners). + // Torn down with rejectionObserver_. + CFRunLoopObserverRef soleOwnerObserver_ = nullptr; double timeOriginMonotonic_; double timeOriginRealtimeMs_; // Keeps Caches::Workers alive for as long as this runtime exists: ~Runtime diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 3e15883f..994af32f 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -1,5 +1,6 @@ #include "Runtime.h" #include +#include #include #include "ArgConverter.h" #include "BuiltinLoader.h" @@ -233,6 +234,11 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { CFRelease(rejectionObserver_); rejectionObserver_ = nullptr; } + if (soleOwnerObserver_ != nullptr) { + CFRunLoopObserverInvalidate(soleOwnerObserver_); + CFRelease(soleOwnerObserver_); + soleOwnerObserver_ = nullptr; + } // Drop any pending uncaughtErrorPolicy "throw" deposit for this isolate so a // stale entry can't outlive the isolate it keys on. @@ -397,6 +403,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { Caches::Init(isolate, nextIsolateId.fetch_add(1, std::memory_order_relaxed)); // Before anything can build an IsolateWrapper for this isolate. IsolateGates::Open(cache->getGateId()); + cache->SoleOwnerCandidates.BindToCurrentThread(); cache->isWorker = isWorker; cache->ObjectCtorInitializer = MetadataBuilder::GetOrCreateConstructorFunctionTemplate; cache->StructInstanceFactory = MetadataBuilder::NewStructInstance; @@ -458,6 +465,15 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { /*order*/ 0, Runtime::DrainRejectionsObserver, &observerContext); CFRunLoopAddObserver(runtimeLoop_, rejectionObserver_, kCFRunLoopCommonModes); + // Ordered after every other observer, UIKit's autorelease pool pop among + // them (order 0x7fffffff): by then the main thread's pool for this turn has + // drained, so an object it held is released before the check reads its + // retain count. + soleOwnerObserver_ = CFRunLoopObserverCreate( + kCFAllocatorDefault, kCFRunLoopBeforeWaiting, /*repeats*/ true, + /*order*/ LONG_MAX, Runtime::ChargeSoleOwnersObserver, &observerContext); + CFRunLoopAddObserver(runtimeLoop_, soleOwnerObserver_, kCFRunLoopCommonModes); + DefineGlobalObject(context, isWorker); DefineCollectFunction(context); PromiseProxy::Init(context); @@ -774,6 +790,35 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { cache->PromiseRejections->Drain(context); } +void Runtime::ChargeSoleOwnersObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, + void* info) { + Isolate* isolate = static_cast(info); + if (!Runtime::IsAlive(isolate)) { + return; + } + auto cache = Caches::Get(isolate); + // NextDueMs is the one member readable without the Locker, so an idle turn + // never contends for it. + if (!cache->IsValid() || cache->SoleOwnerCandidates.NextDueMs() > EventLoop::NowMs()) { + return; + } + + IsolatePin pin(cache->getGateId(), Isolate::TryGetCurrent() == isolate); + if (!pin) { + return; + } + v8::Locker locker(isolate); + // ~Runtime invalidates the cache before it takes the Locker, so this second + // look rules out charging once teardown has begun. + if (!cache->IsValid() || !cache->HasContext()) { + return; + } + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + Context::Scope context_scope(cache->GetContext()); + ExternalMemory::ChargeSoleOwners(isolate, cache.get()); +} + bool Runtime::IsAlive(const Isolate* isolate) { // speedup lookup by avoiding locking if thread locals match // note: this can be a problem when the Runtime is deleted in a different thread that it was diff --git a/TestFixtures/Api/TNSExternalMemory.h b/TestFixtures/Api/TNSExternalMemory.h new file mode 100644 index 00000000..3c2434ab --- /dev/null +++ b/TestFixtures/Api/TNSExternalMemory.h @@ -0,0 +1,29 @@ +#import + +typedef void (^TNSDataCallback)(NSData* data); + +// Hands JS NSData at +0 the way native APIs do, for the external memory +// accounting tests. +@interface TNSExternalMemory : NSObject +// Calls `callback` with a new NSData, released as soon as it returns. ++ (void)passFreshDataOfLength:(NSUInteger)length to:(TNSDataCallback)callback; +// Calls `callback` with a new autoreleased NSData. ++ (void)passAutoreleasedDataOfLength:(NSUInteger)length to:(TNSDataCallback)callback; +// Calls `callback` with a new NSData that native keeps until -releaseKeptData. ++ (void)passKeptDataOfLength:(NSUInteger)length to:(TNSDataCallback)callback; ++ (void)releaseKeptData; +// Calls `callback` with a process-lifetime NSData. ++ (void)passSharedDataTo:(TNSDataCallback)callback; +// Calls `callback` with an empty NSData. ++ (void)passEmptyDataTo:(TNSDataCallback)callback; +// Calls `callback` `count` times every `intervalMs` from a private serial +// queue, each with a new NSData released when the call returns, then calls +// `completion` from that queue. ++ (void)streamDataOfLength:(NSUInteger)length + count:(NSUInteger)count + intervalMs:(double)intervalMs + to:(TNSDataCallback)callback + completion:(void (^)(void))completion; +// The process's physical footprint, as jetsam measures it. ++ (double)physicalFootprint; +@end diff --git a/TestFixtures/Api/TNSExternalMemory.m b/TestFixtures/Api/TNSExternalMemory.m new file mode 100644 index 00000000..698af8ae --- /dev/null +++ b/TestFixtures/Api/TNSExternalMemory.m @@ -0,0 +1,87 @@ +#import "TNSExternalMemory.h" + +#include + +static NSMutableArray* keptData; + +static NSData* NewData(NSUInteger length) { + void* bytes = calloc(length, 1); + return [[NSData alloc] initWithBytesNoCopy:bytes length:length freeWhenDone:YES]; +} + +@implementation TNSExternalMemory ++ (void)passFreshDataOfLength:(NSUInteger)length to:(TNSDataCallback)callback { + NSData* data = NewData(length); + callback(data); + [data release]; +} + ++ (void)passAutoreleasedDataOfLength:(NSUInteger)length to:(TNSDataCallback)callback { + callback([NewData(length) autorelease]); +} + ++ (void)passKeptDataOfLength:(NSUInteger)length to:(TNSDataCallback)callback { + if (keptData == nil) { + keptData = [[NSMutableArray alloc] init]; + } + NSData* data = NewData(length); + [keptData addObject:data]; + callback(data); + [data release]; +} + ++ (void)releaseKeptData { + [keptData removeAllObjects]; +} + ++ (void)passSharedDataTo:(TNSDataCallback)callback { + static NSData* shared; + static dispatch_once_t once; + dispatch_once(&once, ^{ + shared = NewData(64 * 1024); + }); + callback(shared); +} + ++ (void)passEmptyDataTo:(TNSDataCallback)callback { + callback([NSData data]); +} + ++ (void)streamDataOfLength:(NSUInteger)length + count:(NSUInteger)count + intervalMs:(double)intervalMs + to:(TNSDataCallback)callback + completion:(void (^)(void))completion { + dispatch_queue_t queue = dispatch_queue_create("org.nativescript.TNSExternalMemory", NULL); + TNSDataCallback heldCallback = [callback copy]; + void (^heldCompletion)(void) = [completion copy]; + __block NSUInteger sent = 0; + __block void (^next)(void) = nil; + next = [^{ + if (sent == count) { + heldCompletion(); + [heldCallback release]; + [heldCompletion release]; + [next release]; + dispatch_release(queue); + return; + } + sent++; + NSData* data = NewData(length); + heldCallback(data); + [data release]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(intervalMs * NSEC_PER_MSEC)), queue, + next); + } copy]; + dispatch_async(queue, next); +} + ++ (double)physicalFootprint { + task_vm_info_data_t info; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + if (task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count) != KERN_SUCCESS) { + return -1; + } + return (double)info.phys_footprint; +} +@end diff --git a/TestFixtures/NapiTestModule.mm b/TestFixtures/NapiTestModule.mm index 3b2145d1..3c6bc6cf 100644 --- a/TestFixtures/NapiTestModule.mm +++ b/TestFixtures/NapiTestModule.mm @@ -243,6 +243,16 @@ static napi_value GetWrapCount(napi_env env, napi_callback_info info) { return result; } +// The isolate's external memory total; a zero adjustment only reads it. +static napi_value GetExternalMemory(napi_env env, napi_callback_info info) { + (void)info; + int64_t total = 0; + napi_value result = NULL; + NAPI_CALL(env, napi_adjust_external_memory(env, 0, &total)); + NAPI_CALL(env, napi_create_double(env, (double)total, &result)); + return result; +} + //=== Async work =========================================================== typedef struct { @@ -607,6 +617,7 @@ static napi_value InitNapiTestModule(napi_env env, napi_value exports) { NAPI_METHOD("invokeViaMakeCallback", InvokeViaMakeCallback), NAPI_METHOD("exerciseCleanupHooks", ExerciseCleanupHooks), NAPI_GETTER("wrapCount", GetWrapCount), + NAPI_METHOD("externalMemory", GetExternalMemory), NAPI_VALUE("moduleName", moduleName), }; diff --git a/TestFixtures/TestFixtures.h b/TestFixtures/TestFixtures.h index 87cc28d1..9bf99b5b 100644 --- a/TestFixtures/TestFixtures.h +++ b/TestFixtures/TestFixtures.h @@ -1,28 +1,25 @@ #import #import "Api/TNSApi.h" -#import "Api/TNSPseudoDataTypes.h" #import "Api/TNSBridgedTypes.h" #import "Api/TNSDeclarationConflicts.h" +#import "Api/TNSExternalMemory.h" +#import "Api/TNSPseudoDataTypes.h" #import "Api/TNSReturnsRetained.h" #import "Api/TNSReturnsUnmanaged.h" #import "Api/TNSVersions.h" - #import "Functions/TNSFunctions.h" - #import "Interfaces/TNSClassWithPlaceholder.h" #import "Interfaces/TNSConstructorResolution.h" #import "Interfaces/TNSInheritance.h" #import "Interfaces/TNSMethodCalls.h" #import "Interfaces/TNSPointCollection.h" #import "Interfaces/TNSSwiftLike.h" - #import "Marshalling/TNSAllocLog.h" #import "Marshalling/TNSFunctionPointers.h" #import "Marshalling/TNSObjCTypes.h" #import "Marshalling/TNSPrimitivePointers.h" #import "Marshalling/TNSPrimitives.h" #import "Marshalling/TNSRecords.h" - #import "TNSTestCommon.h" #import "TNSTestNativeCallbacks.h" diff --git a/TestRunner/app/tests/ExternalMemoryTests.js b/TestRunner/app/tests/ExternalMemoryTests.js index 5953cd8b..6af9a6a1 100644 --- a/TestRunner/app/tests/ExternalMemoryTests.js +++ b/TestRunner/app/tests/ExternalMemoryTests.js @@ -108,4 +108,157 @@ describe("External memory accounting", function () { expect(interop.getExternalSize(image)).toBe(bytes); }); }); + + describe("+0 objects JS ends up owning", function () { + var napi = require("napitestmodule"); + + function poll(predicate, timeoutMs, done) { + var deadline = Date.now() + timeoutMs; + (function check() { + if (predicate() || Date.now() > deadline) { + done(); + } else { + setTimeout(check, 10); + } + })(); + } + + function receive(pass, length) { + var received; + pass(length, function (data) { + received = data; + expect(interop.getExternalSize(data)).toBe(0); + }); + return received; + } + + it("charges a block argument once native lets go of it, not before", function (done) { + var data = receive(TNSExternalMemory.passFreshDataOfLengthTo, 256 * 1024); + expect(interop.getExternalSize(data)).toBe(0); + poll(function () { return interop.getExternalSize(data) > 0; }, 2000, function () { + expect(interop.getExternalSize(data)).toBe(256 * 1024); + done(); + }); + }); + + it("charges an autoreleased block argument once the pool drained", function (done) { + var data = receive(TNSExternalMemory.passAutoreleasedDataOfLengthTo, 128 * 1024); + poll(function () { return interop.getExternalSize(data) > 0; }, 2000, function () { + expect(interop.getExternalSize(data)).toBe(128 * 1024); + done(); + }); + }); + + it("charges a block argument whose bytes JS also reads through a buffer", function (done) { + var buffer; + var data = receive(function (length, cb) { + TNSExternalMemory.passFreshDataOfLengthTo(length, function (d) { + buffer = interop.bufferFromData(d); + cb(d); + }); + }, 96 * 1024); + poll(function () { return interop.getExternalSize(data) > 0; }, 2000, function () { + expect(buffer.byteLength).toBe(96 * 1024); + expect(interop.getExternalSize(data)).toBe(96 * 1024); + done(); + }); + }); + + it("charges a fresh +0 return once the pool drained", function (done) { + var sub = NSMutableData.dataWithLength(1000).subdataWithRange({ location: 0, length: 500 }); + expect(interop.getExternalSize(sub)).toBe(0); + poll(function () { return interop.getExternalSize(sub) > 0; }, 2000, function () { + expect(interop.getExternalSize(sub)).toBe(500); + done(); + }); + }); + + it("does not charge objects native still retains", function (done) { + var kept = receive(TNSExternalMemory.passKeptDataOfLengthTo, 64 * 1024); + var shared = receive(function (length, cb) { TNSExternalMemory.passSharedDataTo(cb); }); + var empty = receive(function (length, cb) { TNSExternalMemory.passEmptyDataTo(cb); }); + setTimeout(function () { + setTimeout(function () { + expect(interop.getExternalSize(kept)).toBe(0); + expect(interop.getExternalSize(shared)).toBe(0); + expect(interop.getExternalSize(empty)).toBe(0); + TNSExternalMemory.releaseKeptData(); + done(); + }, 100); + }, 100); + }); + + it("charges block arguments delivered on another thread", function (done) { + var received = []; + var finished = false; + TNSExternalMemory.streamDataOfLengthCountIntervalMsToCompletion(48 * 1024, 5, 5, function (data) { + received.push(data); + }, function () { + finished = true; + }); + function allCharged() { + return finished && received.length === 5 && + received.every(function (d) { return interop.getExternalSize(d) > 0; }); + } + poll(allCharged, 3000, function () { + expect(received.length).toBe(5); + received.forEach(function (d) { + expect(interop.getExternalSize(d)).toBe(48 * 1024); + }); + done(); + }); + }); + + it("charges in a worker isolate", function (done) { + var worker = new Worker("./externalMemorySoleOwnerWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.localBefore).toBe(0); + expect(msg.data.local).toBe(128 * 1024); + expect(msg.data.streamed).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 32 * 1024]); + worker.terminate(); + done(); + }; + worker.onerror = function (e) { + expect(String(e && e.message ? e.message : e)).toBe(""); + worker.terminate(); + done(); + }; + worker.postMessage(0); + }); + + it("returns the charges when the wrappers are collected", function (done) { + var count = 16; + var length = 1024 * 1024; + var held = []; + for (var i = 0; i < count; i++) { + held.push(receive(TNSExternalMemory.passFreshDataOfLengthTo, length)); + } + poll(function () { + return held.every(function (d) { return interop.getExternalSize(d) > 0; }); + }, 2000, function () { + held.forEach(function (d) { expect(interop.getExternalSize(d)).toBe(length); }); + var charged = napi.externalMemory(); + held = null; + __collect(); + __collect(); + expect(charged - napi.externalMemory()).not.toBeLessThan(count * length); + done(); + }); + }); + + it("survives a burst larger than it tracks", function (done) { + // A turn first, so the burst starts from an empty list. + setTimeout(function () { + var all = []; + for (var i = 0; i < 6000; i++) { + all.push(receive(TNSExternalMemory.passFreshDataOfLengthTo, 16)); + } + poll(function () { return interop.getExternalSize(all[0]) > 0; }, 2000, function () { + expect(interop.getExternalSize(all[0])).toBe(16); + expect(interop.getExternalSize(all[all.length - 1])).toBe(0); + done(); + }); + }, 0); + }); + }); }); diff --git a/TestRunner/app/tests/externalMemorySoleOwnerWorker.js b/TestRunner/app/tests/externalMemorySoleOwnerWorker.js new file mode 100644 index 00000000..3a5e2a3d --- /dev/null +++ b/TestRunner/app/tests/externalMemorySoleOwnerWorker.js @@ -0,0 +1,40 @@ +// Reports whether +0 NSData reaching this worker gets charged, both from a +// call on this thread and from callbacks on a native serial queue. +function poll(predicate, timeoutMs, done) { + var deadline = Date.now() + timeoutMs; + (function check() { + if (predicate() || Date.now() > deadline) { + done(); + } else { + setTimeout(check, 10); + } + })(); +} + +onmessage = function () { + var local; + TNSExternalMemory.passFreshDataOfLengthTo(128 * 1024, function (data) { + local = data; + }); + var localBefore = interop.getExternalSize(local); + + var streamed = []; + var finished = false; + TNSExternalMemory.streamDataOfLengthCountIntervalMsToCompletion(32 * 1024, 4, 5, function (data) { + streamed.push(data); + }, function () { + finished = true; + }); + + function allCharged() { + return interop.getExternalSize(local) > 0 && finished && streamed.length === 4 && + streamed.every(function (d) { return interop.getExternalSize(d) > 0; }); + } + poll(allCharged, 3000, function () { + postMessage({ + localBefore: localBefore, + local: interop.getExternalSize(local), + streamed: streamed.map(function (d) { return interop.getExternalSize(d); }) + }); + }); +}; diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 40102693..5886aad3 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -133,6 +133,7 @@ C2937520229FC1600075CB16 /* TNSVersions.m in Sources */ = {isa = PBXBuildFile; fileRef = C29374FF229FC1600075CB16 /* TNSVersions.m */; }; C2937521229FC1600075CB16 /* TNSApi.m in Sources */ = {isa = PBXBuildFile; fileRef = C2937501229FC1600075CB16 /* TNSApi.m */; }; C2937522229FC1600075CB16 /* TNSReturnsRetained.m in Sources */ = {isa = PBXBuildFile; fileRef = C2937502229FC1600075CB16 /* TNSReturnsRetained.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + A1E5E0000000000000000003 /* TNSExternalMemory.m in Sources */ = {isa = PBXBuildFile; fileRef = A1E5E0000000000000000002 /* TNSExternalMemory.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; C2937523229FC1600075CB16 /* TNSReturnsUnmanaged.m in Sources */ = {isa = PBXBuildFile; fileRef = C2937504229FC1600075CB16 /* TNSReturnsUnmanaged.m */; }; C2937524229FC1600075CB16 /* TNSAllocLog.m in Sources */ = {isa = PBXBuildFile; fileRef = C293750B229FC1600075CB16 /* TNSAllocLog.m */; }; C2937525229FC1600075CB16 /* TNSFunctionPointers.m in Sources */ = {isa = PBXBuildFile; fileRef = C293750C229FC1600075CB16 /* TNSFunctionPointers.m */; }; @@ -599,12 +600,14 @@ C29374F9229FC1600075CB16 /* TestFixtures.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TestFixtures.h; sourceTree = ""; }; C29374FB229FC1600075CB16 /* TNSDeclarationConflicts.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSDeclarationConflicts.m; sourceTree = ""; }; C29374FC229FC1600075CB16 /* TNSReturnsRetained.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TNSReturnsRetained.h; sourceTree = ""; }; + A1E5E0000000000000000001 /* TNSExternalMemory.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TNSExternalMemory.h; sourceTree = ""; }; C29374FD229FC1600075CB16 /* TNSApi.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TNSApi.h; sourceTree = ""; }; C29374FE229FC1600075CB16 /* TNSBridgedTypes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSBridgedTypes.m; sourceTree = ""; }; C29374FF229FC1600075CB16 /* TNSVersions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSVersions.m; sourceTree = ""; }; C2937500229FC1600075CB16 /* TNSReturnsUnmanaged.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TNSReturnsUnmanaged.h; sourceTree = ""; }; C2937501229FC1600075CB16 /* TNSApi.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSApi.m; sourceTree = ""; }; C2937502229FC1600075CB16 /* TNSReturnsRetained.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSReturnsRetained.m; sourceTree = ""; }; + A1E5E0000000000000000002 /* TNSExternalMemory.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSExternalMemory.m; sourceTree = ""; }; C2937503229FC1600075CB16 /* TNSDeclarationConflicts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TNSDeclarationConflicts.h; sourceTree = ""; }; C2937504229FC1600075CB16 /* TNSReturnsUnmanaged.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TNSReturnsUnmanaged.m; sourceTree = ""; }; C2937505229FC1600075CB16 /* TNSVersions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TNSVersions.h; sourceTree = ""; }; @@ -1220,12 +1223,14 @@ children = ( C29374FB229FC1600075CB16 /* TNSDeclarationConflicts.m */, C29374FC229FC1600075CB16 /* TNSReturnsRetained.h */, + A1E5E0000000000000000001 /* TNSExternalMemory.h */, C29374FD229FC1600075CB16 /* TNSApi.h */, C29374FE229FC1600075CB16 /* TNSBridgedTypes.m */, C29374FF229FC1600075CB16 /* TNSVersions.m */, C2937500229FC1600075CB16 /* TNSReturnsUnmanaged.h */, C2937501229FC1600075CB16 /* TNSApi.m */, C2937502229FC1600075CB16 /* TNSReturnsRetained.m */, + A1E5E0000000000000000002 /* TNSExternalMemory.m */, C2937503229FC1600075CB16 /* TNSDeclarationConflicts.h */, C2937504229FC1600075CB16 /* TNSReturnsUnmanaged.m */, C2937505229FC1600075CB16 /* TNSVersions.h */, @@ -2288,6 +2293,7 @@ C2937528229FC1600075CB16 /* TNSObjCTypes.m in Sources */, C2937526229FC1600075CB16 /* TNSRecords.m in Sources */, C2937522229FC1600075CB16 /* TNSReturnsRetained.m in Sources */, + A1E5E0000000000000000003 /* TNSExternalMemory.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };