Repository navigation
feat(runtime): canary wrapper lifetime mode - #561
Draft
edusperoni wants to merge 3 commits into
Draft
edusperoni wants to merge 3 commits into
edusperoni wants to merge 3 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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. |
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
force-pushed
the
feat/wrapper-lifetime-canary
branch
from
November 12, 2026 22:56
c936676 to
683780a
Compare
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).
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 staysresurrecting.V8: canary relies on a fix for own-property lookups through a
kNonMaskinginterceptor whose prototype has a named interceptor (crbug 585053531). It shipped asv8_nonmasking_own_lookup.patchin v8-buildscriptsv8-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
resurrectingandrootedkeep a single wrapper whose JS properties live on the wrapper itself.rootedkeeps it alive by reading-retainCount, which only iOS can do.New behavior
With
"wrapperLifetime": { "mode": "canary" }:rooted. No retain counts are read.kNonMaskingnamed 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.-deallocreports. A canary built later for the same object forwards to the same state."extended"(default) makes canaries of JS-extended instances only."all"covers every ObjC wrapper except blocks and tagged pointers, and is experimental: attaching the token aborts for any class the ObjC runtime marks as forbidding associated objects, and there is no public query for that.The contract.
this, aWeakMapvalue naming it) keeps that object alive. The iOS-only alternative, unrooting at major-GC start when nothing native owns the object (asrooteddoes), is a marked switch point inWrapperLifetimeCanary.hand is not implemented.===with the old wrapper fails, andWeakMapkeys andWeakRefs of the old canary do not carry over. A follow-up (PR3b) replaces the weak collections in canary mode so they key native objects by their state. Native#privatefields are brand-checked on the canary and do not carry over either.Object.freeze,sealandpreventExtensionsthrow on a canary.this.Robustness.
objc_removeAssociatedObjectson a live object therefore leaves its canary with no state, rather than a dangling record; the next write starts a new one.getWrapperLifetimeStats()gainscanaryStatesandcanaryNativeDeaths. Docs:docs/ns-builtin-modules.md,docs/knowledge/v8-resurrecting-finalizers.md,types/ns-runtime.d.ts.Testing
WrapperLifetimeModesTests.jsgains canary expectations:in,hasOwn, descriptors,delete, non-enumerable and accessor properties);freeze.The existing rooted expectations also run under canary where the contract matches.
On the
v8-14.9.207.39-10prebuilts: full suite, 0 failures (1897 specs), in each ofcanary/all,canary/extended,resurrecting/extendedandrooted/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/allruns found a runtime-made self-reference:SymbolIterator::Setgave every NSArray wrapper aSymbol.iteratorfunction 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:
Ext.new().tag = 1)NSArray.arrayWithArray([{a: 1}])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 ownSymbol.iterator, which shows in the array-return and GC rows. Moving that iterator to the NSArray prototype is the obvious follow-up.