Skip to content

fix: image memory leaks (Android load retention, V8 image accounting, Android fd leak) - #192

Merged
triniwiz merged 3 commits into
masterfrom
fix/image-memory-leaks
Nov 8, 2026
Merged

triniwiz merged 3 commits into
masterfrom
fix/image-memory-leaks

Conversation

@triniwiz

@triniwiz triniwiz commented Nov 8, 2026

Copy link
Copy Markdown
Member

Three fixes for image memory that grows without bound when images are loaded repeatedly, for example a texture reloaded every 500 ms with the previous one disposed.

1. Android loads pinned their asset until a Java GC

Each async ImageAsset load on Android handed Kotlin a JS-implemented Callback whose closure reached the asset. The runtime keeps such an object alive until Java collects its Java peer, and the Java heap rarely fills, so every loaded image stayed reachable. A JS GC freed nothing; only a Java GC followed by a JS GC did.

The callback now captures only an id. The completion, and with it the asset, sits in a map until the load finishes. The loaders strong-ref map is removed: it was redundant on the C++ and iOS paths, and save() pinned its asset forever because saveCb is not registered.

2. V8 could not see decoded image memory

An ImageAsset wrapper is a few bytes on the JS heap but holds the whole decoded image (4 MB for 1366×768), so V8 had no reason to collect dropped ones. ImageAsset and ImageBitmap wrappers now report their native bytes through v8::ExternalMemoryAccounter and release them in the finalizer. Every NativeScript 9.1+ runtime exports the API (checked: iOS and visionOS 9.1.0, all four Android 9.1.0 ABIs).

3. Android leaked a file descriptor per async native call

JSICallback, AsyncCallback, PromiseCallback and JSIReadFileCallback closed only the read end of their pipe, so every async image load, createImageBitmap, TextDecoder.decodeAsync and WebGPU promise leaked one fd.

Measured

Android emulator (API 37, arm64): a three.js texture reloaded 121 times at 500 ms, native heap in MB:

build startup after 121 reloads after JS GC
master 65 527 528 (65 only after a Java GC)
callback fix alone 65 127 65
this PR 65 69 65

Both the callback fix and the V8 accounting are needed: without the accounting, collectable images still piled up between GCs.

Not tested

  • iOS: the accounting is in the shared C++ binding, but was not run on iOS.
  • The Node-API hosts (Windows, Linux) don't report image memory yet.

The stress app the commit messages mention (apps/demo-leak) is not part of this PR.

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.
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.
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.
@coderabbitai

coderabbitai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9aa602a2-b921-4759-966c-5f12d8cfd075

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@triniwiz
triniwiz merged commit 24dbaa3 into master Nov 8, 2026
22 of 25 checks passed
@triniwiz
triniwiz deleted the fix/image-memory-leaks branch November 8, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant