From b401b147cc34f5afe070ee650bd107d5f2eb4d78 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 7 Nov 2026 19:26:06 -0400 Subject: [PATCH 1/3] fix(android): close the write end of each async callback's pipe JSICallback, AsyncCallback, PromiseCallback and JSIReadFileCallback closed only the read end of the pipe that wakes the JS looper, so every async call (image loads, createImageBitmap, TextDecoder.decodeAsync, WebGPU promises) leaked a file descriptor. Each writer is done with the pipe before the reader runs, so the destructor can close both ends. --- packages/canvas/platforms/ios/src/cpp/AsyncCallback.h | 2 ++ packages/canvas/platforms/ios/src/cpp/JSICallback.h | 1 + packages/canvas/platforms/ios/src/cpp/JSIReadFileCallback.h | 1 + packages/canvas/platforms/ios/src/cpp/PromiseCallback.h | 2 ++ 4 files changed, 6 insertions(+) diff --git a/packages/canvas/platforms/ios/src/cpp/AsyncCallback.h b/packages/canvas/platforms/ios/src/cpp/AsyncCallback.h index 76cc530fd..e4eb75e53 100644 --- a/packages/canvas/platforms/ios/src/cpp/AsyncCallback.h +++ b/packages/canvas/platforms/ios/src/cpp/AsyncCallback.h @@ -132,6 +132,7 @@ struct AsyncCallback { if (isPrepared_) { ALooper_removeFd(looper_, fd_[0]); close(fd_[0]); + close(fd_[1]); fd_[0] = -1; fd_[1] = -1; ALooper_release(looper_); @@ -160,6 +161,7 @@ struct AsyncCallback { } ALooper_removeFd(looper_, fd_[0]); close(fd_[0]); + close(fd_[1]); ALooper_release(looper_); callback_.Reset(); } diff --git a/packages/canvas/platforms/ios/src/cpp/JSICallback.h b/packages/canvas/platforms/ios/src/cpp/JSICallback.h index 95ca3c689..424ada905 100644 --- a/packages/canvas/platforms/ios/src/cpp/JSICallback.h +++ b/packages/canvas/platforms/ios/src/cpp/JSICallback.h @@ -77,6 +77,7 @@ struct JSICallback { ~JSICallback() { ALooper_removeFd(looper_, fd_[0]); close(fd_[0]); + close(fd_[1]); ALooper_release(looper_); auto callback = callback_.get(); if(callback != nullptr){ diff --git a/packages/canvas/platforms/ios/src/cpp/JSIReadFileCallback.h b/packages/canvas/platforms/ios/src/cpp/JSIReadFileCallback.h index 5847797bd..008e11552 100644 --- a/packages/canvas/platforms/ios/src/cpp/JSIReadFileCallback.h +++ b/packages/canvas/platforms/ios/src/cpp/JSIReadFileCallback.h @@ -84,6 +84,7 @@ struct JSIReadFileCallback { ~JSIReadFileCallback() { ALooper_removeFd(looper_, fd_[0]); close(fd_[0]); + close(fd_[1]); ALooper_release(looper_); callback_.Reset(); canvas_native_u8_buffer_release(data_); diff --git a/packages/canvas/platforms/ios/src/cpp/PromiseCallback.h b/packages/canvas/platforms/ios/src/cpp/PromiseCallback.h index 5324e4416..f920b2bd1 100644 --- a/packages/canvas/platforms/ios/src/cpp/PromiseCallback.h +++ b/packages/canvas/platforms/ios/src/cpp/PromiseCallback.h @@ -137,6 +137,7 @@ struct PromiseCallback { if (isPrepared_) { ALooper_removeFd(looper_, fd_[0]); close(fd_[0]); + close(fd_[1]); fd_[0] = -1; fd_[1] = -1; ALooper_release(looper_); @@ -178,6 +179,7 @@ struct PromiseCallback { } ALooper_removeFd(looper_, fd_[0]); close(fd_[0]); + close(fd_[1]); ALooper_release(looper_); callback_.Reset(); } From 0ecaa318fab813d4fdb229b0aca3548f7af71973 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 7 Nov 2026 19:26:06 -0400 Subject: [PATCH 2/3] fix: report decoded image memory to V8 so dropped images are collected ImageAsset and ImageBitmap keep their decoded pixels in native memory that V8 cannot see. A wrapper is a few bytes on the JS heap but holds the whole RGBA buffer (4 MB for a 1366x768 image), so V8 sees no pressure to collect dropped images, and their pixels stay allocated until an unrelated GC happens to run. ObjectWrapperImpl now reports a wrapper's native bytes through v8::ExternalMemoryAccounter, and releases them in its finalizer. ImageAsset updates the figure after every load: sync loads in C++, async loads from the TS layer's completion through __syncExternalMemory. ImageBitmap reports its size when wrapped. Both runtimes (NativeScript 9.1 Android, iOS) export the accounter. --- packages/canvas/ImageAsset/index.ts | 2 ++ .../platforms/ios/src/cpp/ImageAssetImpl.cpp | 22 +++++++++++++++++++ .../platforms/ios/src/cpp/ImageAssetImpl.h | 4 ++++ .../platforms/ios/src/cpp/ImageBitmapImpl.h | 4 ++++ .../platforms/ios/src/cpp/ObjectWrapperImpl.h | 14 ++++++++++++ 5 files changed, 46 insertions(+) diff --git a/packages/canvas/ImageAsset/index.ts b/packages/canvas/ImageAsset/index.ts index 1ff3dec2e..c70f4d54b 100644 --- a/packages/canvas/ImageAsset/index.ts +++ b/packages/canvas/ImageAsset/index.ts @@ -149,6 +149,8 @@ export class ImageAsset extends Observable { } private emitComplete(success: boolean, error) { + // Not on the Node-API host. + this.native.__syncExternalMemory?.(); this.notify({ eventName: 'complete', object: this, complete: success, error }); } diff --git a/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.cpp b/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.cpp index c9d659fa4..2affa607a 100644 --- a/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.cpp +++ b/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.cpp @@ -70,6 +70,10 @@ v8::Local ImageAssetImpl::GetCtor(v8::Isolate *isolate) { tmpl->Set( ConvertToV8String(isolate, "__getRef"), v8::FunctionTemplate::New(isolate, GetReference)); + + tmpl->Set( + ConvertToV8String(isolate, "__syncExternalMemory"), + v8::FunctionTemplate::New(isolate, SyncExternalMemory)); @@ -199,6 +203,20 @@ ImageAssetImpl::GetReference(const v8::FunctionCallbackInfo &args) { args.GetReturnValue().SetEmptyString(); } +void ImageAssetImpl::UpdateExternalMemory(v8::Isolate *isolate) { + auto asset = this->GetImageAsset(); + auto bytes = (int64_t) canvas_native_image_asset_width(asset) * + (int64_t) canvas_native_image_asset_height(asset) * 4; + SetExternalMemory(isolate, bytes); +} + +void ImageAssetImpl::SyncExternalMemory(const v8::FunctionCallbackInfo &args) { + auto ptr = GetPointer(args.This()); + if (ptr != nullptr) { + ptr->UpdateExternalMemory(args.GetIsolate()); + } +} + void ImageAssetImpl::GetError(v8::Local name, const v8::PropertyCallbackInfo &info) { @@ -223,6 +241,7 @@ void ImageAssetImpl::FromUrlSync(const v8::FunctionCallbackInfo &args auto url = ConvertFromV8String(isolate, args[0]); auto done = canvas_native_image_asset_load_from_url(ptr->GetImageAsset(), url.c_str()); + ptr->UpdateExternalMemory(isolate); args.GetReturnValue().Set(done); } @@ -393,6 +412,7 @@ void ImageAssetImpl::FromFileSync(const v8::FunctionCallbackInfo &arg auto path = ConvertFromV8String(isolate, args[0]); auto done = canvas_native_image_asset_load_from_path(ptr->GetImageAsset(), path.c_str()); + ptr->UpdateExternalMemory(isolate); args.GetReturnValue().Set(done); } @@ -526,6 +546,7 @@ void ImageAssetImpl::FromBytesSync(const v8::FunctionCallbackInfo &ar done = canvas_native_image_asset_load_from_raw(ptr->GetImageAsset(), width, height, bytes.data, bytes.size); } } + ptr->UpdateExternalMemory(args.GetIsolate()); args.GetReturnValue().Set(done); return; @@ -664,6 +685,7 @@ void ImageAssetImpl::FromEncodedBytesSync(const v8::FunctionCallbackInfoGetImageAsset(), bytes.data, bytes.size); + ptr->UpdateExternalMemory(args.GetIsolate()); args.GetReturnValue().Set(done); return; diff --git a/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.h b/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.h index ebce3a5a6..a80a49104 100644 --- a/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.h +++ b/packages/canvas/platforms/ios/src/cpp/ImageAssetImpl.h @@ -44,6 +44,10 @@ class ImageAssetImpl : ObjectWrapperImpl { static void GetReference(const v8::FunctionCallbackInfo &args); + static void SyncExternalMemory(const v8::FunctionCallbackInfo &args); + + void UpdateExternalMemory(v8::Isolate *isolate); + static void GetError(v8::Local name, const v8::PropertyCallbackInfo &info); diff --git a/packages/canvas/platforms/ios/src/cpp/ImageBitmapImpl.h b/packages/canvas/platforms/ios/src/cpp/ImageBitmapImpl.h index ee66cf420..e757a9f65 100644 --- a/packages/canvas/platforms/ios/src/cpp/ImageBitmapImpl.h +++ b/packages/canvas/platforms/ios/src/cpp/ImageBitmapImpl.h @@ -41,6 +41,10 @@ class ImageBitmapImpl : public ObjectWrapperImpl { impl->BindFinalizer(isolate, object); + auto bitmap = static_cast(impl)->GetImageAsset(); + impl->SetExternalMemory(isolate, (int64_t) canvas_native_image_asset_width(bitmap) * + (int64_t) canvas_native_image_asset_height(bitmap) * 4); + return scope.Escape(object); } diff --git a/packages/canvas/platforms/ios/src/cpp/ObjectWrapperImpl.h b/packages/canvas/platforms/ios/src/cpp/ObjectWrapperImpl.h index 5d95ae34e..918f86709 100644 --- a/packages/canvas/platforms/ios/src/cpp/ObjectWrapperImpl.h +++ b/packages/canvas/platforms/ios/src/cpp/ObjectWrapperImpl.h @@ -10,6 +10,7 @@ #include "Common.h" #include "NativeType.h" +#include "include/v8-external-memory-accounter.h" class ObjectWrapperImpl { public: @@ -26,9 +27,20 @@ class ObjectWrapperImpl { static void Finalizer(const v8::WeakCallbackInfo &data) { auto *pThis = data.GetParameter(); pThis->weakHandle_.Reset(); + pThis->SetExternalMemory(data.GetIsolate(), 0); delete pThis; } + // Native bytes V8 should count against this wrapper. + void SetExternalMemory(v8::Isolate *isolate, int64_t bytes) { + auto delta = bytes - externalBytes_; + if (delta == 0) { + return; + } + externalMemory_.Update(isolate, delta); + externalBytes_ = bytes; + } + void BindFinalizer(v8::Isolate *isolate, const v8::Local &object) { v8::HandleScope scopedHandle(isolate); weakHandle_.Reset(isolate, object); @@ -85,6 +97,8 @@ class ObjectWrapperImpl { private: v8::Global weakHandle_; + v8::ExternalMemoryAccounter externalMemory_; + int64_t externalBytes_ = 0; }; From ca306ca861b8b6bdce3248ddfffe1f16bb07f6b3 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 7 Nov 2026 19:29:17 -0400 Subject: [PATCH 3/3] fix(ImageAsset): Android loads release their asset when they complete Each Android load handed Kotlin a JS-implemented Callback whose closure reached the asset. The runtime keeps such a JS object alive until Java collects its Java peer, and the Java heap rarely fills, so every loaded image stayed pinned. Reloading a three.js texture every 500 ms (apps/demo-leak) held 527 MB of native memory after a minute, unchanged by a JS GC and freed only by a Java GC. It now stays at about 69 MB. The Callback now captures only an id; the completion, and with it the asset, sits in a map until the load finishes and is then dropped. The loaders map is gone: on the C++ and iOS paths the native callback already holds a JS closure over the asset, and save() incremented it and then threw (saveCb is not registered), pinning that asset for good. --- packages/canvas/ImageAsset/index.ts | 276 +++++----------------------- 1 file changed, 44 insertions(+), 232 deletions(-) diff --git a/packages/canvas/ImageAsset/index.ts b/packages/canvas/ImageAsset/index.ts index c70f4d54b..7b4979dc5 100644 --- a/packages/canvas/ImageAsset/index.ts +++ b/packages/canvas/ImageAsset/index.ts @@ -4,8 +4,6 @@ import { NAPI_HOST } from '../platform'; import { loadNativeImage } from '../platform/index'; let ctor; -// store ref if loading -const loaders = new Map(); export interface SvgLoadOptions { /** CSS pixels; one alone keeps the aspect ratio. Defaults to the SVG's size. */ @@ -91,6 +89,21 @@ export function fromSvgSource(value: T): T | ImageAsset { return svg ? (svgToImageAsset(svg) ?? value) : value; } +let nextAndroidLoad = 0; +const androidLoads = new Map void>(); + +function androidLoadCallback(complete: (success: boolean) => void) { + const id = nextAndroidLoad++; + androidLoads.set(id, complete); + return new (org).nativescript.canvas.NSCImageAsset.Callback({ + onComplete(success: boolean) { + const done = androidLoads.get(id); + androidLoads.delete(id); + done?.(success); + }, + }); +} + export class ImageAsset extends Observable { static { Helpers.initialize(); @@ -127,25 +140,17 @@ export class ImageAsset extends Observable { return this.native.fromUrlSync(url); } - private _incrementStrongRef() { - let count = 0; - if (loaders.has(this)) { - count = loaders.get(this); - } - count++; - loaders.set(this, count); - } - - private _decrementStrongRefAndRemove() { - // Parenthesised: `??` binds looser than `-`, so the old form returned the - // count undecremented and never released the strong ref. - const count = (loaders.get(this) ?? 0) - 1; - - if (count <= 0) { - loaders.delete(this); - } else { - loaders.set(this, count); - } + private _androidCallback(asset: number, resolve: (value: boolean) => void, reject: (reason?: any) => void, fallbackError?: string) { + return androidLoadCallback((success) => { + if (success) { + this.emitComplete(success, undefined); + resolve(success); + } else { + const error = (org).nativescript.canvas.NSCImageAsset.getError(asset) || fallbackError; + this.emitComplete(success, error); + reject(error); + } + }); } private emitComplete(success: boolean, error) { @@ -158,32 +163,10 @@ export class ImageAsset extends Observable { return new Promise((resolve, reject) => { if (__ANDROID__) { const asset = this._android.getAsset(); - const ref = new WeakRef(this); - (org).nativescript.canvas.NSCImageAsset.loadImageFromUrlAsync( - asset, - url, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success: boolean) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromUrlAsync(asset, url, this._androidCallback(asset, resolve, reject)); return; } - this._incrementStrongRef(); this.native.fromUrlCb(url, (success, error) => { this.emitComplete(success, error); if (error) { @@ -191,7 +174,6 @@ export class ImageAsset extends Observable { } else { resolve(success); } - this._decrementStrongRefAndRemove(); }); }); } @@ -219,57 +201,14 @@ export class ImageAsset extends Observable { if (__ANDROID__) { const asset = this._android.getAsset(); - const ref = new WeakRef(this); if (typeof path === 'string' && path.indexOf('.webp') > -1) { - (org).nativescript.canvas.NSCImageAsset.loadWebPAsync( - asset, - path, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadWebPAsync(asset, path, this._androidCallback(asset, resolve, reject)); } else { - (org).nativescript.canvas.NSCImageAsset.loadImageFromPathAsync( - asset, - path, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromPathAsync(asset, path, this._androidCallback(asset, resolve, reject)); } return; } - this._incrementStrongRef(); - this.native.fromFileCb(path.toString(), (success, error) => { this.emitComplete(success, error); if (error) { @@ -277,8 +216,6 @@ export class ImageAsset extends Observable { } else { resolve(success); } - - this._decrementStrongRefAndRemove(); }); }); } @@ -317,30 +254,9 @@ export class ImageAsset extends Observable { return; } if (__ANDROID__) { - const ref = new WeakRef(this); const asset = long(this.native.__getRef()); try { - (org).nativescript.canvas.NSCImageAsset.loadImageFromBitmapAsync( - asset, - image, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset) || 'Failed to load image from native source'; - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromBitmapAsync(asset, image, this._androidCallback(asset, resolve, reject, 'Failed to load image from native source')); } catch (e) { reject(e); } @@ -348,23 +264,19 @@ export class ImageAsset extends Observable { } if (__IOS__) { - this._incrementStrongRef(); const asset = NSString.stringWithString(this.native.__getRef()); try { NSCImageAsset.loadImageFromImage(asset.longLongValue, image, (done) => { if (!done) { const error = this.error || 'Failed to load image from native source'; this.emitComplete(done, error); - this._decrementStrongRefAndRemove(); reject(error); } else { this.emitComplete(done, undefined); - this._decrementStrongRefAndRemove(); resolve(done); } }); } catch (e) { - this._decrementStrongRefAndRemove(); reject(e); } return; @@ -381,58 +293,16 @@ export class ImageAsset extends Observable { loadFromEncodedBytes(bytes: Uint8Array | Uint8ClampedArray) { return new Promise((resolve, reject) => { if (__ANDROID__) { - const ref = new WeakRef(this); const asset = this._android.getAsset(); if (!ArrayBuffer.isView(bytes)) { - (org).nativescript.canvas.NSCImageAsset.loadImageFromEncodedBytesAsync( - asset, - bytes, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromEncodedBytesAsync(asset, bytes, this._androidCallback(asset, resolve, reject)); } else { - (org).nativescript.canvas.NSCImageAsset.loadImageFromEncodedBufferAsync( - asset, - bytes, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromEncodedBufferAsync(asset, bytes, this._androidCallback(asset, resolve, reject)); } return; } - this._incrementStrongRef(); this.native.fromEncodedBytesCb(bytes, (success, error) => { this.emitComplete(success, error); if (error) { @@ -440,8 +310,6 @@ export class ImageAsset extends Observable { } else { resolve(success); } - - this._decrementStrongRefAndRemove(); }); }); } @@ -464,52 +332,25 @@ export class ImageAsset extends Observable { } catch (error) { return Promise.reject(error); } - this._incrementStrongRef(); - return provider - .load(source, this, options) - .then( - (success) => { - this.emitComplete(success, success ? undefined : this.error); - return success; - }, - (error) => { - this.emitComplete(false, error); - throw error; - }, - ) - .finally(() => this._decrementStrongRefAndRemove()); + return provider.load(source, this, options).then( + (success) => { + this.emitComplete(success, success ? undefined : this.error); + return success; + }, + (error) => { + this.emitComplete(false, error); + throw error; + }, + ); } loadFromBytes(width: number, height: number, bytes: Uint8Array | Uint8ClampedArray) { return new Promise((resolve, reject) => { if (__ANDROID__) { - const ref = new WeakRef(this); const asset = this._android.getAsset(); if (Array.isArray(bytes)) { - (org).nativescript.canvas.NSCImageAsset.loadImageFromBytesAsync( - asset, - width, - height, - bytes, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromBytesAsync(asset, width, height, bytes, this._androidCallback(asset, resolve, reject)); } else { let buffer: ArrayBuffer = bytes as never; if (ArrayBuffer.isView(bytes)) { @@ -518,34 +359,11 @@ export class ImageAsset extends Observable { buffer = buffer.nativeObject as never; } - (org).nativescript.canvas.NSCImageAsset.loadImageFromBufferAsync( - asset, - width, - height, - buffer, - new (org).nativescript.canvas.NSCImageAsset.Callback({ - onComplete(success) { - const owner = ref.get(); - if (!success) { - const error = (org).nativescript.canvas.NSCImageAsset.getError(asset); - if (owner) { - owner.emitComplete(success, error); - } - reject(error); - } else { - if (owner) { - owner.emitComplete(success, undefined); - } - resolve(success); - } - }, - }), - ); + (org).nativescript.canvas.NSCImageAsset.loadImageFromBufferAsync(asset, width, height, buffer, this._androidCallback(asset, resolve, reject)); } return; } - this._incrementStrongRef(); this.native.fromBytesCb(width, height, bytes, (success, error) => { this.emitComplete(success, error); if (error) { @@ -553,8 +371,6 @@ export class ImageAsset extends Observable { } else { resolve(success); } - - this._decrementStrongRefAndRemove(); }); }); } @@ -569,16 +385,12 @@ export class ImageAsset extends Observable { save(path: string, format: ImageAssetSaveFormat): Promise { return new Promise((resolve, reject) => { - this._incrementStrongRef(); - this.native.saveCb(path, format, (success, error) => { if (error) { reject(error); } else { resolve(success); } - - this._decrementStrongRefAndRemove(); }); }); }