Skip to content

feat(runtime): wrapper lifetime diagnostics and released-object arguments - #558

Draft
edusperoni wants to merge 2 commits into
mainfrom
feat/wrapper-lifetime-diagnostics
Draft

edusperoni wants to merge 2 commits into
mainfrom
feat/wrapper-lifetime-diagnostics

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

First of a stacked series that moves ObjC wrapper lifetime off resurrecting finalizers, adopted in tiers behind a mode flag. This PR changes no lifetime behaviour. It makes released wrappers safe to touch where they used to reach native code as the wrong object, and adds counters and warnings that show where an app relies on resurrection today.

Current behavior

  • A JS-extended object that native code still retains is resurrected by its finalizer, but anything reachable only through it is queued in the same GC and released. JS can still reach those released wrappers.
  • Passed to native code where an object is expected, such a wrapper has no External left, so it falls through to a DictionaryAdapter standing in for it. A production app hit this as -[DictionaryAdapter _resolvedMaterialWithTraitCollection:]: unrecognized selector, when a released UIColor reached UIKit.
  • Calling a method on a released wrapper throws in Debug and hits tns::Assert in Release.
  • When a plain (non-extended) wrapper is collected while native code still owns its object, its JS properties are lost silently.

New behavior

Released wrappers are marked.

  • DisposeValue and __releaseNativeCounterpart leave a ReleasedReason Smi in internal field 0 of an ObjC object's wrapper: collected, released explicitly, or teardown. Proxies are unwrapped first.
  • tns::GetValue only reads Externals, so every existing caller still sees "no wrapper". Writing the Smi allocates nothing inside the finalizer drain.
  • Wrappers kept in the metadata private are still only cleared.

Released wrappers never become a DictionaryAdapter. They go through the existing releasedObjectPolicy:

Where "report" (default) "throw"
Argument (Interop::WriteValue) nil, plus the releasednativeaccess event ReferenceError
Collection element (Interop::ToObject, from adapter callbacks) NSNull, plus the event NSNull, plus the event (nothing can be thrown there)
JS-implemented method return value (ArgConverter::SetValue) nil, plus the event nil, plus the event
Method or property call on it (ArgConverter::Invoke, Proxy receivers included) ReferenceError ReferenceError
  • The message names the reason, for example "The native object was released when its wrapper was garbage collected (native argument)".
  • Behaviour change: code that used to pass a released wrapper and survive with a dictionary now gets nil. A method call on a released wrapper now throws a catchable ReferenceError in Release instead of asserting.

Counters.

  • ns:runtime gains getWrapperLifetimeStats(), with per-isolate counts of resurrections, collections, explicit releases, released-object accesses, and state-lost candidates and confirmations.
  • They're counted on cold paths in every build.
  • The function is an iOS-only export, like setExternalSize/getExternalSize.

State-lost warnings.

  • A collected wrapper is a candidate when it carried JS properties while something else still retained its object.
  • Candidates are checked again a runloop turn later through a weak reference. If the object is still alive, the runtime logs once per class that its JS properties were lost.
  • On by default in debug builds. The app config "wrapperLifetime": { "warnings": bool } and the ns:runtime key wrapperLifetimeWarnings override it.
  • Tagged pointers, immortal objects and classes that refuse weak references are skipped.

Documentation: docs/ns-builtin-modules.md, docs/knowledge/v8-resurrecting-finalizers.md, types/ns-runtime.d.ts.

Performance

The only additions on non-error paths:

  • a counter, an atomic flag load and the Smi write on each ObjC wrapper disposal;
  • a field check on the plain-object DictionaryAdapter fallback.

The state-lost check runs only while warnings are on, which is off by default in Release.

Release A/B against main (95b52af), simulator, alternating runs, 5 per side, existing bench specs plus a new wrapper churn spec. Medians are in the table; run-to-run spread overlaps on every case:

Case main branch
NSObject.alloc().init() (wrapper churn) 1112 ns 1141 ns
dict.setValueForKey({a: 1}, "k") (dictionary fallback) 736 ns 759 ns
NSArray.arrayWithArray([{a: 1}]) (collection fallback) 4397 ns 4378 ns
Full GC disposing JS-only wrappers, per wrapper 2789 ns 2845 ns

All other bench cases (native calls, properties, structs, NSData, blocks) are within ±3% with no direction.

Tests

  • New WrapperLifetimeTests.js covers:
    • arguments, collection elements, receivers and properties under both policies;
    • a Proxy release;
    • the counters;
    • the GC-released shape under a resurrected holder;
    • state-lost confirmation, positive and negative;
    • config validation.
  • NsRuntimeTests.js has the new export.
  • Suite: 1868 passed, 0 failed (main: 1856/0). ASan suite: 1863 passed, 0 failed, no sanitizer reports.

Next in the stack

  1. Config wrapperLifetime { mode, scope } (resurrecting default; scope extended default) and a lifetime strategy interface around Instances/ObjectManager, with resurrecting the only implementation.
  2. Phantom weak callbacks, a release queue drained where JS may run, a dealloc association, and the rooted mode: wrappers rooted while native code owns their object, with no resurrection.
  3. The canary mode, the portable model shared with the Android and Node-API runtimes.

…ents

A JS object whose ObjC wrapper was released (a resurrected holder's graph
can keep it reachable) used to reach native code as a DictionaryAdapter
standing in for the object, so UIKit received a dictionary where it
expected, for example, a UIColor. A method call on it asserted in Release.

Released ObjC wrappers now carry a ReleasedReason in internal field 0
(collected, released through __releaseNativeCounterpart, or teardown).
GetValue only reads Externals, so existing callers are unaffected. Where
an object is expected, the marker routes the value through
releasedObjectPolicy: an argument becomes nil (report) or throws a
ReferenceError (throw), a collection element becomes NSNull, a
JS-implemented method's return value becomes nil, and a method or
property call on it always throws a ReferenceError, also through a Proxy.

ns:runtime gains getWrapperLifetimeStats() (resurrections, collections,
explicit releases, released-object accesses, state-lost candidates and
confirmations, counted on cold paths in every build) and the
wrapperLifetimeWarnings key. With warnings on (default in debug builds,
or "wrapperLifetime": { "warnings": bool } in the app config), a
collected wrapper that carried JS properties while its object was still
retained elsewhere is checked again a runloop turn later through a weak
reference, and a still-alive object is reported once per class.
@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

clang rejects bit tests on ObjC object pointers under -Werror
(-Wdeprecated-objc-pointer-introspection), which broke the x86_64
simulator archive.

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