From 2f28a5937b68ff5247121ff907c4451f9c81e03d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 17 Oct 2026 19:30:11 -0300 Subject: [PATCH] fix(runtime): charge the native memory of +0 objects once JS holds the last reference A wrapper retains its object until V8 collects the wrapper, so a +0 object that reaches JS (a block or override argument, an instance method's return) is solely owned by its wrapper once the native caller lets go. V8 still saw a few dozen bytes for it: a worker receiving a 205 KB NSData per frame at 60 Hz grew past 1.4 GB without a single collection. - When a registered wrapper is first created for an object whose class has a size estimator, the object becomes a candidate in a bounded per-isolate list (4096); classes without an estimator cost one cached class lookup. Owned paths that charge right away drop their entry again. - Candidates are checked once, from a kCFRunLoopBeforeWaiting observer on the isolate's home run loop, ordered after UIKit's autorelease pool pop so the main thread's pool for the turn has drained. Candidates recorded on another thread (callbacks that enter the isolate from a dispatch queue) wait 16 ms for their native caller to let go, and a no-op EventLoop post makes an idle home loop run the pass that checks them. - A candidate is charged its estimate if its wrapper is still attached and the object's retain count is JS's own: the wrapper's retain plus one per live interop.bufferFromData buffer, which bufferFromData now counts on the wrapper. Collected or released wrappers, objects native still retains and immortal objects (constants, tagged pointers) are skipped. - Charging happens under the Locker on the home thread, behind the isolate pin and a cache validity check, and stops when execution is terminating; the observer is torn down first in ~Runtime. --- NativeScript/runtime/ArgConverter.mm | 6 + NativeScript/runtime/Caches.h | 2 + NativeScript/runtime/DataWrapper.h | 18 +++ NativeScript/runtime/ExternalMemory.h | 72 +++++++++ NativeScript/runtime/ExternalMemory.mm | 150 +++++++++++++++-- NativeScript/runtime/InteropTypes.mm | 19 ++- NativeScript/runtime/Runtime.h | 5 + NativeScript/runtime/Runtime.mm | 45 ++++++ TestFixtures/Api/TNSExternalMemory.h | 29 ++++ TestFixtures/Api/TNSExternalMemory.m | 87 ++++++++++ TestFixtures/NapiTestModule.mm | 11 ++ TestFixtures/TestFixtures.h | 7 +- TestRunner/app/tests/ExternalMemoryTests.js | 153 ++++++++++++++++++ .../tests/externalMemorySoleOwnerWorker.js | 40 +++++ v8ios.xcodeproj/project.pbxproj | 6 + 15 files changed, 631 insertions(+), 19 deletions(-) create mode 100644 TestFixtures/Api/TNSExternalMemory.h create mode 100644 TestFixtures/Api/TNSExternalMemory.m create mode 100644 TestRunner/app/tests/externalMemorySoleOwnerWorker.js diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index d5464db87..5bd28a9b6 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 666d7e631..5d307531e 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 a98655a54..64d544a05 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 1590d5d3c..6f09c6fd6 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 ec7e3aed1..52b881f64 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 568d90766..51e0ff45f 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 88169bc42..381f64abd 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 3e15883fa..994af32fa 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 000000000..3c2434ab6 --- /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 000000000..698af8ae7 --- /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 3b2145d1d..3c6bc6cf7 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 87cc28d1f..9bf99b5b5 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 5953cd8b5..6af9a6a11 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 000000000..3a5e2a3d1 --- /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 40102693c..5886aad39 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; };