From caaee302186abab1f335f78e008af4e84b87a2b7 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 9 Oct 2026 22:40:46 -0300 Subject: [PATCH] feat(runtime): report native memory held by wrappers to V8, forward memory pressure V8 only weighs its own heap (plus ArrayBuffer backing stores) when scheduling collections, so a JS wrapper holding megabytes of native memory looks like a few dozen bytes and can sit uncollected until the process is jetsammed. - Wrappers can carry an ExternalMemoryCharge (a v8::ExternalMemoryAccounter) that is returned whenever the wrapper is deleted, from any thread; once the isolate's gate is closed the decrease is skipped since disposal drops it. - interop.alloc(n) charges n bytes to the returned pointer. - Objects JS likely owns (JS constructors, initializers, +1 returns, class factory methods) get an estimated size: NSData length, UIImage/CGImage bitmap size, CVPixelBuffer data size. CG/CV are resolved at runtime, so no new link dependencies. NSDataAdapter is excluded (V8 already counts it). - interop.setExternalSize(obj, bytes) / interop.getExternalSize(obj) let libraries declare the native footprint of what they wrap. - A process-wide dispatch memory-pressure source forwards normal/warn/critical to every live isolate via Isolate::MemoryPressureNotification. --- NativeScript/runtime/ArgConverter.mm | 2 + NativeScript/runtime/Caches.h | 2 + NativeScript/runtime/DataWrapper.h | 8 + NativeScript/runtime/ExternalMemory.h | 63 ++++++ NativeScript/runtime/ExternalMemory.mm | 214 ++++++++++++++++++++ NativeScript/runtime/Interop.h | 2 + NativeScript/runtime/Interop.mm | 10 + NativeScript/runtime/InteropTypes.mm | 79 ++++++++ NativeScript/runtime/Runtime.h | 4 + NativeScript/runtime/Runtime.mm | 21 ++ TestRunner/app/tests/ExternalMemoryTests.js | 110 ++++++++++ TestRunner/app/tests/index.js | 3 + v8ios.xcodeproj/project.pbxproj | 8 + 13 files changed, 526 insertions(+) create mode 100644 NativeScript/runtime/ExternalMemory.h create mode 100644 NativeScript/runtime/ExternalMemory.mm create mode 100644 TestRunner/app/tests/ExternalMemoryTests.js diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index 80263ab9..f95b1d82 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -2,6 +2,7 @@ #include #include #include "DictionaryAdapter.h" +#include "ExternalMemory.h" #include "Helpers.h" #include "Interop.h" #include "NSExceptionSupport.h" @@ -638,6 +639,7 @@ if (!resultIsOwned) { [result retain]; } + ExternalMemory::ChargeEstimatedSize(isolate, thiz); } } diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 01d0b642..6726e1f0 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -178,6 +178,8 @@ class Caches { robin_hood::unordered_map>> PointerInstances; + // ExternalMemory's size estimator per class, resolved on first sight. + robin_hood::unordered_map ExternalSizeEstimators; // 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 4059e2e6..5c4f3d54 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -10,6 +10,7 @@ #include "Common.h" #include "ConcurrentQueue.h" +#include "ExternalMemory.h" #include "Metadata.h" #include "libffi.h" @@ -163,8 +164,15 @@ class BaseDataWrapper { void GcUnprotect() { this->gcProtected_ = false; } + ExternalMemoryCharge* ExternalCharge() { return this->externalCharge_.get(); } + + void SetExternalCharge(std::unique_ptr charge) { + this->externalCharge_ = std::move(charge); + } + private: bool gcProtected_; + std::unique_ptr externalCharge_; }; class EnumDataWrapper : public BaseDataWrapper { diff --git a/NativeScript/runtime/ExternalMemory.h b/NativeScript/runtime/ExternalMemory.h new file mode 100644 index 00000000..1590d5d3 --- /dev/null +++ b/NativeScript/runtime/ExternalMemory.h @@ -0,0 +1,63 @@ +#ifndef ExternalMemory_h +#define ExternalMemory_h + +#include + +#include "v8-external-memory-accounter.h" +#include "v8.h" + +namespace tns { + +class BaseDataWrapper; + +// 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 +// returned whenever the wrapper is deleted, from whichever path deletes it. +// +// Releasing is safe from any thread and after the isolate is gone: it pins +// the isolate's gate instead of taking its Locker (the decrease is a single +// atomic in V8 and never collects), and once the gate is closed the isolate +// is being disposed and the bytes are dropped with it. +class ExternalMemoryCharge { + public: + ExternalMemoryCharge(v8::Isolate* isolate, int gateId) + : isolate_(isolate), gateId_(gateId) {} + ~ExternalMemoryCharge(); + ExternalMemoryCharge(const ExternalMemoryCharge&) = delete; + ExternalMemoryCharge& operator=(const ExternalMemoryCharge&) = delete; + + size_t Bytes() const { return bytes_; } + v8::Isolate* Isolate() const { return isolate_; } + + // Isolate thread only: an increase can run a collection before returning. + void Set(size_t bytes); + + private: + v8::Isolate* isolate_; + int gateId_; + size_t bytes_ = 0; + v8::ExternalMemoryAccounter accounter_; +}; + +namespace ExternalMemory { + +// Replaces the bytes `wrapper` reports for `isolate`; 0 drops the charge. +// Isolate thread only. A wrapper charged for another isolate keeps its charge. +void SetSize(v8::Isolate* isolate, BaseDataWrapper* wrapper, size_t bytes); + +// Charges an estimate of the native footprint of the ObjC or CF object that +// `value` wraps, unless it already carries a charge or its class has no +// estimator. Only call it for objects JS plausibly holds the last reference +// to: collecting the wrapper of an object that native code also retains frees +// nothing, so charging it only buys collections that cannot pay off. +void ChargeEstimatedSize(v8::Isolate* isolate, v8::Local value); + +// Forwards the system's memory pressure events to every live isolate. +// Process-wide and idempotent. +void StartMemoryPressureMonitoring(); + +} // namespace ExternalMemory + +} // namespace tns + +#endif /* ExternalMemory_h */ diff --git a/NativeScript/runtime/ExternalMemory.mm b/NativeScript/runtime/ExternalMemory.mm new file mode 100644 index 00000000..ec7e3aed --- /dev/null +++ b/NativeScript/runtime/ExternalMemory.mm @@ -0,0 +1,214 @@ +#include "ExternalMemory.h" + +#include +#include +#include +#include +#include +#include + +#include "Caches.h" +#include "DataWrapper.h" +#include "Helpers.h" +#include "IsolateWrapper.h" +#include "NSDataAdapter.h" +#include "Runtime.h" + +using namespace v8; + +namespace tns { + +ExternalMemoryCharge::~ExternalMemoryCharge() { + if (bytes_ == 0) { + return; + } + // A closed gate means the isolate is being disposed, which drops its + // external memory total with it. + if (IsolateGates::TryPin(gateId_)) { + accounter_.Decrease(isolate_, bytes_); + IsolateGates::Unpin(gateId_); + } +} + +void ExternalMemoryCharge::Set(size_t bytes) { + if (bytes > bytes_) { + accounter_.Increase(isolate_, bytes - bytes_); + } else if (bytes < bytes_) { + accounter_.Decrease(isolate_, bytes_ - bytes); + } + bytes_ = bytes; +} + +namespace ExternalMemory { + +namespace { + +// Values of Caches::ExternalSizeEstimators. +enum Estimator : uint8_t { + kNone, + kNSData, + kUIImage, + // Toll-free bridged CF instances all share one class, so the estimator is + // picked per object from its CFTypeID. + kCFType, +}; + +typedef CFTypeID (*TypeIDFn)(void); +typedef size_t (*SizeOfFn)(CFTypeRef); + +// Resolved at runtime so the runtime does not link CoreGraphics or CoreVideo +// itself; apps that never load them simply get no estimate. +struct CFEstimators { + TypeIDFn cgImageTypeID = nullptr; + SizeOfFn cgImageBytesPerRow = nullptr; + SizeOfFn cgImageHeight = nullptr; + TypeIDFn pixelBufferTypeID = nullptr; + SizeOfFn pixelBufferDataSize = nullptr; +}; + +const CFEstimators& GetCFEstimators() { + static CFEstimators estimators = [] { + CFEstimators e; + e.cgImageTypeID = (TypeIDFn)dlsym(RTLD_DEFAULT, "CGImageGetTypeID"); + e.cgImageBytesPerRow = (SizeOfFn)dlsym(RTLD_DEFAULT, "CGImageGetBytesPerRow"); + e.cgImageHeight = (SizeOfFn)dlsym(RTLD_DEFAULT, "CGImageGetHeight"); + e.pixelBufferTypeID = (TypeIDFn)dlsym(RTLD_DEFAULT, "CVPixelBufferGetTypeID"); + e.pixelBufferDataSize = (SizeOfFn)dlsym(RTLD_DEFAULT, "CVPixelBufferGetDataSize"); + return e; + }(); + return estimators; +} + +size_t CGImageSize(CFTypeRef image) { + const CFEstimators& e = GetCFEstimators(); + if (image == nullptr || e.cgImageBytesPerRow == nullptr || e.cgImageHeight == nullptr) { + return 0; + } + return e.cgImageBytesPerRow(image) * e.cgImageHeight(image); +} + +Estimator ResolveEstimator(Class klass) { + // NSDataAdapter exposes bytes of a JS buffer that V8 already accounts for. + if ([klass isSubclassOfClass:[NSDataAdapter class]]) { + return kNone; + } + if ([klass isSubclassOfClass:[NSData class]]) { + return kNSData; + } + static Class uiImageClass = objc_getClass("UIImage"); + if (uiImageClass != nil && [klass isSubclassOfClass:uiImageClass]) { + return kUIImage; + } + static Class cfTypeClass = objc_getClass("__NSCFType"); + if (cfTypeClass != nil && klass == cfTypeClass) { + return kCFType; + } + return kNone; +} + +size_t Estimate(Estimator estimator, id obj) { + switch (estimator) { + case kNSData: + return [(NSData*)obj length]; + case kUIImage: { + // UIKit is not linked by the runtime, so its selectors are registered + // rather than declared. A symbol image rasterizes on -CGImage, and one + // backed by a CIImage has no bitmap until it is rendered (NULL here). + static SEL isSymbolImage = sel_registerName("isSymbolImage"); + static SEL cgImage = sel_registerName("CGImage"); + if ([obj respondsToSelector:isSymbolImage] && + ((BOOL (*)(id, SEL))objc_msgSend)(obj, isSymbolImage)) { + return 0; + } + return CGImageSize(((CFTypeRef (*)(id, SEL))objc_msgSend)(obj, cgImage)); + } + case kCFType: { + const CFEstimators& e = GetCFEstimators(); + CFTypeID type = CFGetTypeID((CFTypeRef)obj); + if (e.cgImageTypeID != nullptr && type == e.cgImageTypeID()) { + return CGImageSize((CFTypeRef)obj); + } + if (e.pixelBufferTypeID != nullptr && e.pixelBufferDataSize != nullptr && + type == e.pixelBufferTypeID()) { + return e.pixelBufferDataSize((CFTypeRef)obj); + } + return 0; + } + case kNone: + return 0; + } + return 0; +} + +} // namespace + +void SetSize(Isolate* isolate, BaseDataWrapper* wrapper, size_t bytes) { + ExternalMemoryCharge* charge = wrapper->ExternalCharge(); + if (charge == nullptr) { + if (bytes == 0) { + return; + } + auto created = + std::make_unique(isolate, Caches::Get(isolate)->getGateId()); + charge = created.get(); + wrapper->SetExternalCharge(std::move(created)); + } else if (charge->Isolate() != isolate) { + return; + } + charge->Set(bytes); +} + +void ChargeEstimatedSize(Isolate* isolate, Local value) { + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper == nullptr || wrapper->Type() != WrapperType::ObjCObject || + wrapper->ExternalCharge() != nullptr) { + return; + } + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + id obj = objcWrapper->Data(); + if (obj == nil) { + 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); + } + if (estimator == kNone) { + return; + } + + SetSize(isolate, wrapper, Estimate(estimator, obj)); +} + +void StartMemoryPressureMonitoring() { + static dispatch_source_t source; + static dispatch_once_t once; + dispatch_once(&once, ^{ + source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MEMORYPRESSURE, 0, + DISPATCH_MEMORYPRESSURE_NORMAL | DISPATCH_MEMORYPRESSURE_WARN | + DISPATCH_MEMORYPRESSURE_CRITICAL, + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0)); + dispatch_source_set_event_handler(source, ^{ + unsigned long status = dispatch_source_get_data(source); + MemoryPressureLevel level = MemoryPressureLevel::kNone; + if (status & DISPATCH_MEMORYPRESSURE_CRITICAL) { + level = MemoryPressureLevel::kCritical; + } else if (status & DISPATCH_MEMORYPRESSURE_WARN) { + level = MemoryPressureLevel::kModerate; + } + Runtime::NotifyMemoryPressure(level); + }); + dispatch_resume(source); + }); +} + +} // namespace ExternalMemory + +} // namespace tns diff --git a/NativeScript/runtime/Interop.h b/NativeScript/runtime/Interop.h index b35cb5a0..be83034f 100644 --- a/NativeScript/runtime/Interop.h +++ b/NativeScript/runtime/Interop.h @@ -149,6 +149,8 @@ class Interop { v8::Local interop); static void RegisterAdoptFunction(v8::Local context, v8::Local interop); + static void RegisterExternalSizeFunctions(v8::Local context, + v8::Local interop); static void RegisterSizeOfFunction(v8::Local context, v8::Local interop); static void RegisterEscapeExceptionFunction(v8::Local context, diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index 998742e9..51848caa 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -7,6 +7,7 @@ #include "Constants.h" #include "DictionaryAdapter.h" #include "ExtVector.h" +#include "ExternalMemory.h" #include "Helpers.h" #include "NSDataAdapter.h" #include "NativeScriptException.h" @@ -1841,6 +1842,15 @@ inline bool isBool() { methodCall.context_, methodCall.typeEncoding_, &call, marshalToPrimitive, nullptr, false, methodCall.ownsReturnedObject_, methodCall.returnsUnmanaged_, methodCall.isInitializer_); + // Owned returns, initializers and class factory methods hand out objects JS + // most likely holds the last reference to. Instance getters do not: their + // results usually stay retained by the receiver. + bool returnsFreshObject = methodCall.ownsReturnedObject_ || methodCall.isInitializer_ || + (!methodCall.isPrimitiveFunction_ && !isInstanceMethod); + if (returnsFreshObject && !result.IsEmpty() && result->IsObject()) { + ExternalMemory::ChargeEstimatedSize(v8::Isolate::GetCurrent(), result); + } + return result; } diff --git a/NativeScript/runtime/InteropTypes.mm b/NativeScript/runtime/InteropTypes.mm index a149d055..e65ab6bc 100644 --- a/NativeScript/runtime/InteropTypes.mm +++ b/NativeScript/runtime/InteropTypes.mm @@ -2,6 +2,7 @@ #include "ArgConverter.h" #include "Caches.h" #include "Constants.h" +#include "ExternalMemory.h" #include "FunctionReference.h" #include "Helpers.h" #include "Interop.h" @@ -31,6 +32,7 @@ RegisterAllocFunction(context, interop); RegisterFreeFunction(context, interop); RegisterAdoptFunction(context, interop); + RegisterExternalSizeFunctions(context, interop); RegisterSizeOfFunction(context, interop); RegisterEscapeExceptionFunction(context, interop); @@ -324,6 +326,7 @@ new PrimitiveDataWrapper(sizeof(void*), pointerInstance.As()->GetInternalField(0).As()->Value( v8::kExternalPointerTypeTagDefault)); wrapper->SetAdopted(true); + ExternalMemory::SetSize(isolate, wrapper, size); info.GetReturnValue().Set(pointerInstance); }).ToLocal(&func); @@ -334,6 +337,82 @@ new PrimitiveDataWrapper(sizeof(void*), tns::Assert(success, isolate); } +// Wrappers that die with their JS object. Class, protocol and type wrappers +// live as long as the isolate, so a charge on them could never be returned. +static BaseDataWrapper* GetChargeableWrapper(Isolate* isolate, Local value) { + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper == nullptr) { + return nullptr; + } + switch (wrapper->Type()) { + case WrapperType::ObjCObject: + case WrapperType::Pointer: + case WrapperType::Reference: + case WrapperType::Struct: + case WrapperType::Block: + case WrapperType::FunctionReference: + case WrapperType::ExtVector: + return wrapper; + default: + return nullptr; + } +} + +void Interop::RegisterExternalSizeFunctions(Local context, Local interop) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + Local setFunc; + bool success = + v8::Function::New( + context, + [](const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + BaseDataWrapper* wrapper = GetChargeableWrapper(isolate, info[0]); + if (wrapper == nullptr) { + isolate->ThrowException(Exception::TypeError(tns::ToV8String( + isolate, "interop.setExternalSize expects a native object, pointer, " + "reference, struct or block instance"))); + return; + } + // Below V8's sanity limit on a single adjustment, which aborts the + // process rather than throwing. + constexpr double kMaxBytes = static_cast(1ull << 34); + double bytes = info[1]->IsNumber() ? info[1].As()->Value() : -1; + if (!(bytes >= 0 && bytes <= kMaxBytes)) { + isolate->ThrowException(Exception::RangeError(tns::ToV8String( + isolate, "interop.setExternalSize expects a byte count between 0 and 2^34"))); + return; + } + ExternalMemory::SetSize(isolate, wrapper, static_cast(bytes)); + }, + Local(), 2) + .ToLocal(&setFunc); + tns::Assert(success, isolate); + success = + interop->Set(context, tns::ToV8String(isolate, "setExternalSize"), setFunc).FromMaybe(false); + tns::Assert(success, isolate); + + Local getFunc; + success = v8::Function::New( + context, + [](const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + BaseDataWrapper* wrapper = tns::GetValue(isolate, info[0]); + ExternalMemoryCharge* charge = + wrapper != nullptr ? wrapper->ExternalCharge() : nullptr; + double bytes = charge != nullptr && charge->Isolate() == isolate + ? static_cast(charge->Bytes()) + : 0; + info.GetReturnValue().Set(bytes); + }, + Local(), 1) + .ToLocal(&getFunc); + tns::Assert(success, isolate); + success = + interop->Set(context, tns::ToV8String(isolate, "getExternalSize"), getFunc).FromMaybe(false); + tns::Assert(success, isolate); +} + // Returns the wrapped NSException value carried by `value` — either `value` // itself when it wraps an NSException, or its `.nativeException` when `value` is // an Error carrying a wrapped NSException. Empty handle otherwise. diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index d1d408e7..88169bc4 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -122,6 +122,10 @@ class Runtime { static bool IsAlive(const v8::Isolate* isolate); + // Forwards a system memory pressure level to every live isolate. Safe from + // any thread. + static void NotifyMemoryPressure(v8::MemoryPressureLevel level); + // Resolves the env while holding the registry lock, so a possibly-stale // pointer (e.g. a thread-local left behind when a Runtime was destroyed on // another thread) is never dereferenced outside it. The returned env's diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index a358a46f..60a400a2 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -8,6 +8,7 @@ #include "Constants.h" #include "ErrorEvents.h" #include "Events.h" +#include "ExternalMemory.h" #include "Helpers.h" #include "InlineFunctions.h" #include "Interop.h" @@ -215,6 +216,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { // Before anything worth tracing runs, so NS_DEBUG covers boot itself. tns::InitializeLogCategoriesFromEnvironment(); MetaFile::setInstance(RuntimeConfig.MetadataPtr); + ExternalMemory::StartMemoryPressureMonitoring(); } Runtime::Runtime() { @@ -787,6 +789,25 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { Runtime::isolates_.end(); } +void Runtime::NotifyMemoryPressure(MemoryPressureLevel level) { + std::vector> targets; + { + SpinLock lock(isolatesMutex_); + targets.reserve(Runtime::isolates_.size()); + for (Isolate* isolate : Runtime::isolates_) { + targets.emplace_back(isolate, Caches::Get(isolate)->getGateId()); + } + } + // Notified outside the registry lock: off its own thread V8 posts a task to + // the isolate's runner. The pin keeps the isolate from being disposed. + for (auto& [isolate, gateId] : targets) { + if (IsolateGates::TryPin(gateId)) { + isolate->MemoryPressureNotification(level); + IsolateGates::Unpin(gateId); + } + } +} + napi_env Runtime::GetNapiEnvIfAlive(const Runtime* runtime) { if (runtime == nullptr) { return nullptr; diff --git a/TestRunner/app/tests/ExternalMemoryTests.js b/TestRunner/app/tests/ExternalMemoryTests.js new file mode 100644 index 00000000..eeeb6421 --- /dev/null +++ b/TestRunner/app/tests/ExternalMemoryTests.js @@ -0,0 +1,110 @@ +describe("External memory accounting", function () { + describe("interop.setExternalSize", function () { + it("charges, replaces and clears the size of a native object", function () { + var obj = NSObject.alloc().init(); + expect(interop.getExternalSize(obj)).toBe(0); + + interop.setExternalSize(obj, 1024 * 1024); + expect(interop.getExternalSize(obj)).toBe(1024 * 1024); + + interop.setExternalSize(obj, 4096); + expect(interop.getExternalSize(obj)).toBe(4096); + + interop.setExternalSize(obj, 0); + expect(interop.getExternalSize(obj)).toBe(0); + }); + + it("accepts pointers and references", function () { + var ptr = new interop.Pointer(0x1000); + interop.setExternalSize(ptr, 100); + expect(interop.getExternalSize(ptr)).toBe(100); + + var ref = new interop.Reference(interop.types.int32, 5); + interop.setExternalSize(ref, 200); + expect(interop.getExternalSize(ref)).toBe(200); + }); + + it("rejects values that do not die with their JS object", function () { + expect(function () { interop.setExternalSize({}, 10); }).toThrowError(TypeError); + expect(function () { interop.setExternalSize(NSObject, 10); }).toThrowError(TypeError); + expect(function () { interop.setExternalSize(interop.types.int32, 10); }).toThrowError(TypeError); + }); + + it("rejects byte counts outside the accepted range", function () { + var obj = NSObject.alloc().init(); + expect(function () { interop.setExternalSize(obj, -1); }).toThrowError(RangeError); + expect(function () { interop.setExternalSize(obj, NaN); }).toThrowError(RangeError); + expect(function () { interop.setExternalSize(obj, "10"); }).toThrowError(RangeError); + expect(function () { interop.setExternalSize(obj, Math.pow(2, 40)); }).toThrowError(RangeError); + expect(interop.getExternalSize(obj)).toBe(0); + }); + + it("returns charges to V8 when the wrappers are collected", function () { + // A leaked charge only shows up as V8 scheduling ever more + // collections, so this guards the release paths against crashing: + // finalizers, explicit release, and both after a re-charge. + for (var i = 0; i < 200; i++) { + var obj = NSObject.alloc().init(); + interop.setExternalSize(obj, 256 * 1024); + interop.setExternalSize(obj, 512 * 1024); + if (i % 2 === 0) { + __releaseNativeCounterpart(obj); + } + } + __collect(); + __collect(); + expect(true).toBe(true); + }); + }); + + describe("interop.alloc", function () { + it("charges the allocated size to the returned pointer", function () { + var ptr = interop.alloc(64 * 1024); + expect(interop.getExternalSize(ptr)).toBe(64 * 1024); + }); + }); + + describe("estimated sizes", function () { + it("charges NSData created by a class factory", function () { + var data = NSMutableData.dataWithLength(8192); + expect(interop.getExternalSize(data)).toBe(8192); + }); + + it("charges NSData created by a JS constructor", function () { + var data = new NSMutableData({ length: 4096 }); + expect(interop.getExternalSize(data)).toBe(4096); + }); + + it("charges NSData returned at +1", function () { + var data = NSMutableData.dataWithLength(1000); + var copy = data.mutableCopy(); + expect(interop.getExternalSize(copy)).toBe(1000); + }); + + it("does not charge objects returned by instance getters", function () { + var data = NSMutableData.dataWithLength(1000); + var sub = data.subdataWithRange({ location: 0, length: 500 }); + expect(interop.getExternalSize(sub)).toBe(0); + }); + + it("charges a native copy of a JS buffer", function () { + var data = NSData.dataWithData(new ArrayBuffer(2048)); + expect(interop.getExternalSize(data)).toBe(2048); + }); + + it("charges the bitmap of images", function () { + var width = 64; + var height = 32; + var colorSpace = CGColorSpaceCreateDeviceRGB(); + var context = CGBitmapContextCreate(null, width, height, 8, width * 4, colorSpace, + CGImageAlphaInfo.kCGImageAlphaPremultipliedLast); + var cgImage = CGBitmapContextCreateImage(context); + var bytes = CGImageGetBytesPerRow(cgImage) * CGImageGetHeight(cgImage); + expect(bytes).toBeGreaterThan(0); + expect(interop.getExternalSize(cgImage)).toBe(bytes); + + var image = UIImage.imageWithCGImage(cgImage); + expect(interop.getExternalSize(image)).toBe(bytes); + }); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 4a10ad7e..d08419e4 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -205,6 +205,9 @@ require("./WorkerLifetimeTests"); // terminate() landing inside a worker's entry script require("./WorkerTerminateTests"); +// interop.setExternalSize and the runtime's own external memory charges +require("./ExternalMemoryTests"); + // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 8d4bac3a..40102693 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ 3CAE10132F900001002ACC81 /* Base64.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10032F900001002ACC81 /* Base64.cpp */; }; 3CAE20112F900002002ACC81 /* Messaging.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE20012F900002002ACC81 /* Messaging.cpp */; }; 3CAE20122F900002002ACC81 /* Messaging.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE20022F900002002ACC81 /* Messaging.h */; }; + 3CAE30112F900003002ACC81 /* ExternalMemory.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE30012F900003002ACC81 /* ExternalMemory.mm */; }; + 3CAE30122F900003002ACC81 /* ExternalMemory.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE30022F900003002ACC81 /* ExternalMemory.h */; }; 3CAE10142F900001002ACC81 /* Base64.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10042F900001002ACC81 /* Base64.h */; }; 3CAE10152F900001002ACC81 /* LazyGlobals.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */; }; 3CAE10162F900001002ACC81 /* LazyGlobals.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10062F900001002ACC81 /* LazyGlobals.h */; }; @@ -480,6 +482,8 @@ 3CAE10032F900001002ACC81 /* Base64.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Base64.cpp; sourceTree = ""; }; 3CAE20012F900002002ACC81 /* Messaging.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Messaging.cpp; sourceTree = ""; }; 3CAE20022F900002002ACC81 /* Messaging.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Messaging.h; sourceTree = ""; }; + 3CAE30012F900003002ACC81 /* ExternalMemory.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ExternalMemory.mm; sourceTree = ""; }; + 3CAE30022F900003002ACC81 /* ExternalMemory.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExternalMemory.h; sourceTree = ""; }; 3CAE10042F900001002ACC81 /* Base64.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Base64.h; sourceTree = ""; }; 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = LazyGlobals.cpp; sourceTree = ""; }; 3CAE10062F900001002ACC81 /* LazyGlobals.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = LazyGlobals.h; sourceTree = ""; }; @@ -1583,6 +1587,8 @@ 3CAE10032F900001002ACC81 /* Base64.cpp */, 3CAE20012F900002002ACC81 /* Messaging.cpp */, 3CAE20022F900002002ACC81 /* Messaging.h */, + 3CAE30012F900003002ACC81 /* ExternalMemory.mm */, + 3CAE30022F900003002ACC81 /* ExternalMemory.h */, 3CAE10042F900001002ACC81 /* Base64.h */, 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */, 3CAE10062F900001002ACC81 /* LazyGlobals.h */, @@ -1686,6 +1692,7 @@ 3CAE10122F900001002ACC81 /* TextEncoding.h in Headers */, 3CAE10142F900001002ACC81 /* Base64.h in Headers */, 3CAE20122F900002002ACC81 /* Messaging.h in Headers */, + 3CAE30122F900003002ACC81 /* ExternalMemory.h in Headers */, 3CAE10162F900001002ACC81 /* LazyGlobals.h in Headers */, 3CFCA0042E5A0001002ACC81 /* AnimationFrame.hpp in Headers */, C2C8EE7222CE323C001F8CEC /* ConcurrentMap.h in Headers */, @@ -2334,6 +2341,7 @@ 3CAE10112F900001002ACC81 /* TextEncoding.cpp in Sources */, 3CAE10132F900001002ACC81 /* Base64.cpp in Sources */, 3CAE20112F900002002ACC81 /* Messaging.cpp in Sources */, + 3CAE30112F900003002ACC81 /* ExternalMemory.mm in Sources */, 3CAE10152F900001002ACC81 /* LazyGlobals.cpp in Sources */, 3CFCA0032E5A0001002ACC81 /* AnimationFrame.mm in Sources */, C298C027233C9AEA000DDF54 /* TSHelpers.cpp in Sources */,