Skip to content

feat(runtime): canary wrapper lifetime mode - #561

Draft
edusperoni wants to merge 6 commits into
feat/wrapper-lifetime-rootedfrom
feat/wrapper-lifetime-canary
Draft

edusperoni wants to merge 6 commits into
feat/wrapper-lifetime-rootedfrom
feat/wrapper-lifetime-canary

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Third of the stacked wrapper-lifetime series, on top of #559. Adds "mode": "canary", the portable resurrection-free model. It is the same shape the Node-API runtimes use (a host object plus an expando table) and the one Android can implement. The default stays resurrecting.

V8: canary relies on a fix for own-property lookups through a kNonMasking interceptor whose prototype has a named interceptor (crbug 585053531). It shipped as v8_nonmasking_own_lookup.patch in v8-buildscripts v8-14.9.207.39-10 (NativeScript/v8-buildscripts#11), and this PR pins that release. Main is on -7, so the pin also brings -8 (Apple TV variants) and -9 (Mac Catalyst with its JIT tiers). Merge after #533, which carries the runtime side of the Catalyst JIT.

Current behavior

resurrecting and rooted keep a single wrapper whose JS properties live on the wrapper itself. rooted keeps it alive by reading -retainCount, which only iOS can do.

New behavior

With "wrapperLifetime": { "mode": "canary" }:

  • The wrapper is a canary. A wrapper in scope is a phantom-weak JS object that dies with its JS references, and its native object is released through the same queue as rooted. No retain counts are read.
  • State lives on a separate object. kNonMasking named interceptors on the three ObjC instance templates (empty instance, native constructor, JS-extended constructor) forward JS properties to a null-prototype state object: get, set, query, delete, enumerate, define and descriptor. Prototype members (native methods and properties, JS-extended class methods) are found first and never reach the interceptors, so native calls cost the same.
  • The state outlives the canary. It is rooted from its first write until the native object is deallocated. A canary built later for the same object forwards to the same state.
  • Noticing the deallocation.
    • JS-extended classes: in canary mode each one gets a -dealloc that reports the instance to every isolate's registry, then calls the superclass's. They need no association.
    • Other objects: a build-time choice, NS_CANARY_DEATH_SIGNAL.
      • The default is an associated token, which reports immediately from its -dealloc on any thread. The ObjC runtime aborts for a class that forbids associated objects, and there is no public query for that.
      • The alternative is an ObjC weak reference per record (objc_initWeakOrNil, which declines instead of aborting), with a sweep posted after every major GC. State lingers until that sweep, and a class refusing weak references keeps its properties on the canary.
    • Both pass the full suite.
  • Scope: "extended" (default) makes canaries of JS-extended instances only and attaches no associations. "all" covers every ObjC wrapper except blocks and tagged pointers; with the default signal it is experimental, for the reason above.

The contract.

  • Self-references leak. The state cannot be rooted only once the canary dies, because a state reachable only through the canary is garbage in the same collection. So state that references its own object (a closure over this, a WeakMap value naming it) keeps that object alive. The iOS-only alternative, unrooting at major-GC start when nothing native owns the object (as rooted does), is a marked switch point in WrapperLifetimeCanary.h and is not implemented.
  • A canary built after a death is a new object, which === cannot observe. Only one canary lives at a time, and anything still referencing the old one would have kept it alive. What does notice is whatever keys on the canary without keeping it: WeakMap keys and WeakRefs of the old canary, until feat(runtime): weak collections key native objects by lifetime in canary mode #563 replaces the weak collections in canary mode so they key native objects by their state, and native #private fields, which are branded on the canary.
  • Interceptor limits.
    • Object.freeze, seal and preventExtensions throw on a canary.
    • Index-keyed properties are not forwarded.
    • A write through an object whose prototype is a canary lands in the canary's state, because interceptors see the holder, not the receiver.
    • An accessor defined on a canary runs with the canary as this.

Robustness.

  • Records are shared between the registry and the canary and carry a died flag. objc_removeAssociatedObjects on a live object therefore leaves its canary with no state, rather than a dangling record; the next write starts a new one.
  • Teardown empties every state handle while the isolate is alive. Later token deallocs, on any thread, find the registry closed.

getWrapperLifetimeStats() gains canaryStates and canaryNativeDeaths. Docs: docs/ns-builtin-modules.md, docs/knowledge/v8-resurrecting-finalizers.md, types/ns-runtime.d.ts.

Testing

  • WrapperLifetimeModesTests.js gains canary expectations:

    • own-property semantics (keys, in, hasOwn, descriptors, delete, non-enumerable and accessor properties);
    • state handed back on a new canary;
    • no forwarding outside the extended scope;
    • state let go at native death;
    • the self-reference leak;
    • removed associations;
    • freeze.

    The existing rooted expectations also run under canary where the contract matches.

  • Both native-death signals: full suite, 0 failures (1898 specs), in canary/all and canary/extended with each, plus ASan canary/all with each.

  • On the v8-14.9.207.39-10 prebuilts: full suite, 0 failures (1897 specs), in each of canary/all, canary/extended, resurrecting/extended and rooted/all. ASan, canary/all, on the CI artifacts of v8-buildscripts#11 (same patch, before the release was cut): 0 failures, no reports. Each run's log was checked for the mode it exercised: an earlier round had set the config under the wrong key and silently ran the default mode.

  • The real canary/all runs found a runtime-made self-reference: SymbolIterator::Set gave every NSArray wrapper a Symbol.iterator function bound to that wrapper. Under canary that property lives on the state, so arrays were never released. The function now iterates its receiver (second commit). Every NSArray returned from native code under "all" still gets a state object for that property; moving the iterator to the NSArray prototype would avoid it, and is left as a follow-up.

  • Release, simulator, patched V8, 5 alternating runs per mode (each run's mode checked from inside the app), medians in ns:

Case resurrecting canary/extended canary/all
Prototype hit: JS method / native method / native property 26 / 123 / 92 26 / 124 / 90 27 / 123 / 92
Extended instance: expando read / write 18 / 17 64 / 64 64 / 66
Extended instance: property miss 20 48 48
Plain object: expando read 18 19 64
Plain object: property miss 20 34 49
Create, set one expando, drop (Ext.new().tag = 1) 690 1273 1538
Full GC disposing JS-only wrappers, per wrapper 2665 2403 4176
NSArray.arrayWithArray([{a: 1}]) 4369 4078 4834

Prototype members cost the same. Expandos and misses go through the interceptors. The handlers are installed per template, so under "extended" a miss on a plain object also pays their decline (+14 ns). Under "all", every NSArray returned from native code gets a state object for the runtime's own Symbol.iterator, which shows in the array-return and GC rows. Moving that iterator to the NSArray prototype is the obvious follow-up.

@coderabbitai

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

Adds "wrapperLifetime": { "mode": "canary" }, the portable resurrection-
free model: the one the Node-API runtimes and Android can implement too.

- A wrapper in scope is a canary: a phantom-weak JS object that dies
  with its JS references and releases the runtime's retain through the
  same queue as "rooted". No retain counts are read.
- kNonMasking named interceptors on the three ObjC instance templates
  forward JS properties to a null-prototype state object. Prototype
  members (native methods and properties, JS-extended class methods)
  are found before the interceptors and never reach them.
- The state object is rooted from its first write until the native
  object is deallocated, reported by an associated token's -dealloc. A
  canary built later for the same object forwards to the same state.

The state cannot be rooted only once the canary dies: a state reachable
only through the canary is garbage in the same collection. So state
that references its own object (a closure over `this`) keeps it alive;
the iOS-only alternative, unrooting at major-GC start when nothing
native owns the object, is left as a marked switch point in
WrapperLifetimeCanary.h.

Records are shared between the registry and the canary and carry a
died flag, so objc_removeAssociatedObjects on a live object leaves its
canary with no state instead of a dangling record. Teardown empties
every state handle while the isolate is alive and later token deallocs
find the registry closed.

Requires V8's own-property lookups through kNonMasking interceptors to
skip a prototype's named interceptor (crbug 585053531), which the V8
prebuilts get from v8_nonmasking_own_lookup.patch.
SymbolIterator::Set gave each NSArray wrapper its own Symbol.iterator
function bound to the wrapper. Under the canary wrapper lifetime mode
with "scope": "all" that property lives on the array's state object, so
the state referenced its own canary and the array was never released.
The function now iterates its receiver, which is what `for...of` and
`arr[Symbol.iterator]()` pass anyway.
@edusperoni
edusperoni force-pushed the feat/wrapper-lifetime-canary branch from c936676 to 683780a Compare November 12, 2026 22:56
The canary wrapper lifetime mode needs v8_nonmasking_own_lookup.patch
(crbug 585053531), first shipped in -10. Moving from -7 also brings
-8 (Apple TV variants) and -9 (Mac Catalyst with its JIT tiers, whose
runtime support is #533).
@edusperoni
edusperoni added this pull request to stack #562 November 13, 2026 00:11
Only one canary lives at a time, and anything referencing the old one
keeps it alive, so no comparison can see the new one. Only weak
references to the old canary and native #private brands notice.
…time

NS_CANARY_DEATH_SIGNAL selects between two ways of learning that a
native object with state was deallocated:

- NS_CANARY_DEATH_ASSOCIATION (default): an associated token's -dealloc
  reports it immediately, on any thread. The ObjC runtime aborts for a
  class that forbids associated objects, and there is no public query.
- NS_CANARY_DEATH_WEAK_SWEEP: each record keeps an ObjC weak reference
  (objc_initWeakOrNil, which declines instead of aborting), and a sweep
  posted after every major collection lets dead objects' state go. A
  class refusing weak references keeps its properties on the canary.

Both share the records; a re-wrap or write that finds a dead record not
yet swept starts over, so a recycled address never inherits state.
…anary mode

In canary mode every JS-extended class gets a -dealloc that reports the
instance to every isolate's canary registry (an instance can have state
in an isolate other than the one that defined its class), then calls the
superclass's. Their records need no associated token or weak reference,
so the default "extended" scope attaches no associations at all.

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