Skip to content

fix(runtime): charge the native memory of +0 objects once JS holds the last reference - #515

Draft
edusperoni wants to merge 1 commit into
mainfrom
fix/external-memory-sole-owner
Draft

edusperoni wants to merge 1 commit into
mainfrom
fix/external-memory-sole-owner

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

The gap

#507 charges an estimated native size (NSData length, UIImage/CGImage bitmap, CVPixelBuffer) only for objects JS likely owns: JS constructors, initializers, +1 returns and class factory methods. A wrapper retains its object until V8 collects the wrapper, though, so a +0 object that crosses into JS is shared with native only while the call runs: a block or callback argument, a JS-implemented override's parameters, a +0 return. Once the native caller lets go, the wrapper is the sole owner until GC, and V8 still sees a few dozen bytes.

interop.bufferFromData does not close the gap. V8 counts the ArrayBuffer's backing store, but the NSData wrapper keeps the bytes alive after the buffer dies, and in practice V8 never schedules the collection that would free the wrapper.

Measured case

A worker receives a 205 KB NSData at 60 Hz as a +0 block argument from a Swift serial queue (a Network.framework loopback socket server), then calls interop.bufferFromData. On an iOS 26.3 simulator with runtime 9.1.0-next (#507 included), over 2 minutes:

No charge With interop.setExternalSize(data, data.length) per frame
Socket path grows ~20 MB/s, 149 MB → 2.56 GB, no GC at all settles at 61–62 MB after the first GC at ~10 s
Scheme-handler POST path grows to 1.34 GB before one GC at 108 s settles at 71–91 MB

On a device, that growth is a jetsam risk. This PR makes the right-hand column happen without the manual call.

Design

Candidates. In ArgConverter::CreateJsWrapper, when an object first gets a registered wrapper and its class has an estimator, the object is recorded in a bounded per-isolate list: object, class, the registry handle (shared_ptr<Persistent>, which does not keep the object alive) and a due time. Classes without an estimator cost one cached class lookup. Wrappers that skip GC registration are never recorded. #507's owned paths charge immediately and drop their entry again, so they don't use up the list. Once the list holds 4096 entries, further objects go uncharged until the next check.

Deferred check point. The check runs in a kCFRunLoopBeforeWaiting observer on the isolate's home run loop, which is the main thread or the worker thread. I chose this point because:

  • At callback exit the native caller still holds the object: it releases after the block returns, possibly through an autorelease pool. A microtask runs at the end of the callback's JS turn, so it is too early as well.
  • The observer is ordered LONG_MAX, after UIKit's pool pop/push observer (order 0x7fffffff). By the time the check runs, the main thread's pool for the turn has drained. A next-turn CFRunLoopPerformBlock would run before that drain.
  • A pass that handled a source0 skips BeforeWaiting, but the loop always passes through it before it sleeps.

Callbacks often enter the isolate from a foreign thread: a block invoked on a dispatch queue takes the isolate's Locker on that queue's thread. The measured case is one. The native caller runs on that other thread, so the home loop has no point it can observe. Candidates recorded off the home thread are therefore due 16 ms later. A no-op EventLoop::PostInternalDelayed makes sure an otherwise idle home loop runs the pass that checks them. Wakes coalesce within 4 ms, and a wake that is kept always fires at or after the due time of the candidate it covers.

Sole-owner test. At the check:

  1. The registry handle must still be non-empty.
  2. The JS object must still carry an ObjCDataWrapper with the same Data() and Klass(), and no charge yet.
  3. CFGetRetainCount must equal JS's own retains: the wrapper's one, plus one per live interop.bufferFromData buffer.

Only when all three hold is the estimate charged through #507's ExternalMemory::SetSize. Otherwise the candidate is dropped.

  • bufferFromData takes its own +1 for the buffer's lifetime. It now counts that retain on the wrapper (a shared atomic, decremented by the backing-store deleter on whatever thread frees it). The check reads the buffer count before the retain count, and the deleter drops the count before its retain, so a racing deleter can only make the check miss.
  • The object is touched only after an attached wrapper proves its retain. The due list is moved out before any charge, because an increase can run a GC whose finalizers record new candidates or collect other ones.
  • Constants, singletons and tagged pointers report a retain count far above these values, and empty data estimates to 0 bytes.

Threading, gate and disposal. Increases only happen in ChargeSoleOwners. It runs on the home thread, and the observer takes the steps in this order: isolate pin, Locker, a second cache validity check (because ~Runtime invalidates the cache before it takes the Locker), isolate scope and context scope. Recording never charges. The decrease path from #507 is unchanged and still safe from any thread. The observer is invalidated first in ~Runtime, together with the rejection observer. A terminating isolate is not charged. The candidate list holds only shared_ptrs to registry handles that teardown has already reset, so destroying it with Caches makes no V8 call.

Limits

  • Each candidate is checked exactly once. An object native still holds at the check is never charged, even after native lets go later. Two examples are a natively kept NSData released a few turns later, and a main-thread candidate checked from a nested run loop while its caller is still on the stack.
  • The 16 ms settle time for foreign-thread candidates is a heuristic. A foreign caller that holds the object longer (for example, its pool drains later) is missed.
  • Worker threads drain no autorelease pool per run loop pass. The worker runs CFRunLoopRun() inside one operation's pool, so on a worker only objects their native caller releases outright can qualify. The measured case and the fixture tests are in that category. Anything autoreleased on a worker stays in that pool until the worker exits; that leak predates this PR. Installing pool push/pop observers on the worker loop would be a separate follow-up.
  • The estimate counts the object, not the bytes. A sole-owned NSData whose bytes another owner shares is charged its full length; examples are __NSSwiftData over a Data that Swift also keeps, a dispatch_data subrange, or NoCopy over a native buffer. feat(runtime): report native memory held by wrappers to V8, forward memory pressure #507's owned paths have the same imprecision.
  • Bytes behind a live bufferFromData buffer are weighed twice, by V8 and by this charge, until the buffer dies. This is deliberate: in the measured case, V8 counting the buffer alone never brought the collection that frees the wrapper.
  • Only the first wrapper creation records a candidate. An existing uncharged wrapper that crosses again is not re-checked. That keeps the per-crossing cost at zero.

Tests

New specs in ExternalMemoryTests.js. The native helpers are a new TNSExternalMemory fixture (MRC) plus a napitestmodule.externalMemory() reader of the isolate's external total.

  • A fresh NSData passed to a block at +0 is not charged inside the callback or right after it, and is charged after the deferred point. The same holds for an autoreleased argument, a fresh +0 return (subdataWithRange), and a block argument JS also reads through interop.bufferFromData.
  • Not charged: an NSData native keeps in an NSMutableArray, a process-lifetime shared NSData, and [NSData data].
  • Block arguments delivered from a native serial queue are charged.
  • In a worker isolate, both a home-thread argument and arguments from a native serial queue are charged.
  • The charges come back when the wrappers are collected: the isolate's external total drops by at least the charged 16 MB after __collect().
  • A burst larger than the list charges the first entries and skips the overflow.

Negative control. With candidate recording disabled, exactly the 8 positive specs fail and the "does not charge" spec passes.

Suite. ./run_tests.sh on a dedicated simulator (iPhone 16 Pro, iOS 26.3): 1844 specs, 0 failures, 11 skipped. The AddressSanitizer lane (-a): 1844 specs, 0 failures, 16 skipped.

Numbers

TestRunner repro of the measured case: a native serial queue delivers a fresh 205 KB NSData at 60 Hz as a +0 block argument into a worker, the worker calls interop.bufferFromData on each frame, and the main isolate samples phys_footprint every second for 120 s. One build was used for all three runs, with a temporary switch for the variants (not part of this PR):

Footprint over 120 s Peak Steady state
Without this PR grows ~12 MB/s, 139 MB → 1.44 GB, no GC 1.52 GB —
Manual interop.setExternalSize(data, data.length) per frame first GC between 30 and 40 s, then flat 377 MB 40–43 MB
This PR first GC between 30 and 40 s, then flat 376 MB 32–34 MB

This PR matches the manual charge. The later first GC in this harness compared with the app (30–40 s against ~10 s) shows in both the manual and the automatic runs, so it comes from V8's initial external-memory limit with little other allocation, not from the deferral.

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

coderabbitai Bot commented Oct 17, 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