Skip to content

feat(runtime): weak collections key native objects by lifetime in canary mode - #563

Draft
edusperoni wants to merge 3 commits into
feat/wrapper-lifetime-canaryfrom
feat/wrapper-lifetime-weak-collections
Draft

edusperoni wants to merge 3 commits into
feat/wrapper-lifetime-canaryfrom
feat/wrapper-lifetime-weak-collections

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #561, the canary wrapper lifetime mode. With #561 alone, a native object's canary dies with its JS references while the object lives on. A later wrapper for the same object is a new JS object, so WeakMap/WeakSet entries, WeakRef targets and FinalizationRegistry registrations keyed on the old wrapper are lost with it.

Weak collections

In canary mode a builtin (js/weak-collections.js, run from WeakRef.cpp) replaces the four globals with subclasses:

  • A native object in canary scope is keyed by an opaque per-object key: frozen, null-prototype, created on first use. The object's state holds the key, so it lives exactly until the native object is deallocated. The key does not keep the native object alive.
  • WeakRef.prototype.deref (and the legacy get alias) returns the live wrapper, a new one for the same object, or undefined once the object is gone.
  • Values that are not canaries pass through unchanged. Constructors with iterables work, because the built-in constructors call the overridden set/add. Other modes keep V8's built-ins.

The key and its reverse lookup are internal to the builtin and not exported from ns:interop. The reverse lookup is a private on the key naming the object, checked against the canary registry, then an ObjC weak load (objc_loadWeakRetained), so a deallocating object is never retained. Exposing them would commit every runtime to the API, so that waits for a concrete use case.

Symbol.iterator on NSArray

Arrays returned from native methods used to get a per-instance Symbol.iterator function. Under canary that own property was state: one state object, plus an associated token or weak reference, per array. It's now installed once on NSArray's prototype template. Every array wrapper inherits it, whichever path built it, and none carries an own property. Measured in a Release simulator build, 5 alternating runs per mode:

  • NSArray.arrayWithArray([{a: 1}]) went from 4369 to 2622 ns in resurrecting (about 2450 ns in canary).
  • A full GC in canary/all went from 4176 to 2362 ns per JS-only wrapper, now below resurrecting's 2793.

Testing

  • New WrapperLifetimeWeakCollectionsTests.js, which holds in every mode:
    • WeakMap and WeakSet across a wrapper change;
    • WeakRef deref while the object lives, including the get alias;
    • WeakRef cleared after native death;
    • FinalizationRegistry callback after native death;
    • other keys unaffected;
    • NSArray iteration through the prototype.
  • On the v8-14.9.207.39-10 prebuilts, full suite, 0 failures (1905 specs), in each of canary/all, canary/extended, resurrecting/extended and rooted/all; ASan, canary/all: 0 failures, no reports. Each run's log was checked for the mode it exercised.
  • Weak-collection operations on native keys pay one native call per operation in canary mode. A lab measurement of this shape was about 13 → 41 ns per WeakMap.get.

…otype

Arrays returned from native methods got a per-instance Symbol.iterator
function. Installed once on NSArray's prototype template instead, every
array wrapper inherits it, whichever path built it, and none carries an
own property: under the canary wrapper lifetime mode that property was
state, one state object and associated token per array.
…ary mode

In canary mode a native object's wrapper dies with its JS references
while the object lives on, so WeakMap/WeakSet entries, WeakRef targets
and FinalizationRegistry registrations keyed on the wrapper were lost
with it. A canary-only builtin replaces the four globals with subclasses
that key a native object by an opaque per-object key (frozen,
null-prototype), held by the object's state and so alive exactly until
the object is deallocated; WeakRef.prototype.deref (and the legacy get)
return its live wrapper, a new one, or undefined once the object is
gone. Values that are not canaries pass through unchanged, and other
modes keep the built-ins.

The key and its reverse lookup (a private on the key naming the object,
checked against the registry, then an ObjC weak load so a deallocating
object is never retained) are internal to the builtin.
@coderabbitai

coderabbitai Bot commented Nov 13, 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

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