Skip to content

feat(runtime): report native memory held by wrappers to V8, forward memory pressure - #507

Draft
edusperoni wants to merge 1 commit into
mainfrom
feat/external-memory
Draft

edusperoni wants to merge 1 commit into
mainfrom
feat/external-memory

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

What

The runtime never told V8 about native memory kept alive by JS wrappers. V8 only weighs its own heap (plus ArrayBuffer backing stores, which it counts itself) when scheduling GCs. So a wrapper holding a multi-MB UIImage looks like ~50 bytes and can sit uncollected until iOS jetsams the app. This PR adds reporting at three levels of confidence and forwards OS memory pressure.

Accounting primitive

  • ExternalMemoryCharge (wrapping v8::ExternalMemoryAccounter, the non-deprecated replacement for AdjustAmountOfExternalAllocatedMemory) hangs off BaseDataWrapper. Whichever path deletes the wrapper (finalizer, __releaseNativeCounterpart, teardown, an adapter's -dealloc on another thread) returns the charge.
  • Release is thread-safe: V8's decrease is an atomic counter update that V8 itself calls from GC worker threads. The isolate's gate is pinned for the call. Once the gate is closed the decrease is skipped, because disposal drops the isolate's total anyway.

Tier 1: exact sizes the runtime allocates

  • interop.alloc(n) charges n bytes to the adopted pointer.

Tier 2: estimated sizes for objects JS likely owns

  • Estimators: NSData → length; UIImage / CGImage → bytesPerRow * height; CVPixelBuffer → CVPixelBufferGetDataSize. They are resolved once per class and cached in Caches.
  • CoreGraphics and CoreVideo functions are resolved with dlsym, so the runtime gains no link dependencies.
  • NSDataAdapter is excluded, since V8 already counts its JS buffer.
  • Symbol images are skipped, since -CGImage rasterizes them.
  • Applied only when JS most likely holds the last reference:
    • new X(...)
    • initializers
    • +1 returns (copy / new / Create)
    • class factory methods
  • Instance getters (view.image) are not charged, because the receiver usually keeps the object alive and the GCs triggered by charging it could not free anything.

Tier 3: library-declared sizes

  • interop.setExternalSize(obj, bytes) replaces the charge on a native object, pointer, reference, struct, block or function reference; 0 clears it. interop.getExternalSize(obj) reads it back.
  • Class, protocol and type objects throw TypeError, because they live as long as the isolate and the charge could never be returned.
  • Byte counts must be in [0, 2^34], otherwise RangeError. V8 aborts the process, rather than throwing, on a single adjustment above its sanity limit.
  • This is the hook for @nativescript/core ImageSource, canvas and plugins, which know their real footprint.

Memory pressure

  • A process-wide DISPATCH_SOURCE_TYPE_MEMORYPRESSURE source maps normal/warn/critical to MemoryPressureLevel::kNone/kModerate/kCritical and calls Isolate::MemoryPressureNotification on every live isolate.
  • The notification runs outside the isolate registry lock, with each isolate's gate pinned. Off-thread, V8 requests a GC interrupt and posts a task to the isolate's runner.

Known limitations

  • Estimates are taken once, at wrap time; later NSMutableData growth is not tracked.
  • A decoded-bitmap estimate for an image loaded from a file overstates its memory until it is drawn.
  • interop.bufferFromData on a charged NSData counts the bytes twice: once for the NSData wrapper, once for the ArrayBuffer.
  • interop.setExternalSize / getExternalSize typings belong in @nativescript/types and are a follow-up.

Tests

TestRunner/app/tests/ExternalMemoryTests.js (12 specs) covers:

  • setting, replacing and clearing charges
  • rejecting invalid targets and byte counts
  • release through GC and explicit release
  • interop.alloc
  • the factory, constructor, +1 and getter paths
  • CGImage/UIImage bitmap estimates

Full suite on a dedicated simulator: 1794 specs, 0 failures. The memory-pressure path has no automated test because the simulator has no reliable way to raise a memory-pressure event.

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

coderabbitai Bot commented Oct 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • 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

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
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