Skip to content

feat(runtime): interop.keepAliveWhileRetained for natively retained instances - #509

Draft
edusperoni wants to merge 1 commit into
mainfrom
feat/keep-alive-while-retained
Draft

edusperoni wants to merge 1 commit into
mainfrom
feat/keep-alive-while-retained

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

What

A natively retained instance of a class built by __extends is kept alive by refusing its finalizer: GcProtect() is a flag on a still-weak kFinalizer handle. As the "Known hazard" section of docs/knowledge/v8-resurrecting-finalizers.md explains, everything reachable only through that instance's JS properties is still queued and disposed in the same GC. The instance comes back holding a husk.

That refusal is deliberate. It is what lets JS↔ObjC cycles collect, and the doc rules out turning protection into a strong root by default.

This PR adds an explicit, per-instance opt-in for the cases where that default is wrong:

interop.keepAliveWhileRetained(obj);

While obj is protected (native code holds a retain), its registered handle is strong, so V8 traces its properties like any other root. When native drops back to only the runtime's own reference, the handle is weak again and the instance is collectable as before.

Motivation

A Blackout console app registers a Firebase App Check provider: a JS-derived NSObject that Firebase retains natively. It holds a GACAppAttestProvider, which only JS owns, in a property. After a GC the provider survived (refused finalizer), but its GACAppAttestProvider had been released and freed. Single-use token requests never completed, and calls on it threw "disposed native object". The app works around it with a module-level array. This API is that workaround with the right lifetime: the root goes away when native releases the instance, instead of living forever.

How

  • ObjectManager::SetGcProtected is now the only way the swizzled retain/release (ClassBuilder) change protection. It calls ObjectManager::SyncKeepAliveRoot.
  • SyncKeepAliveRoot clears the handle's weakness on protect and re-arms it (SetWeak(state, FinalizerCallback, kFinalizer)) on unprotect. In between, the ObjectWeakCallbackState is parked on the ObjCDataWrapper, mirroring WorkerWrapper::RootWorkerObject.
    • Calling it while already protected roots immediately.
    • It never roots from inside the handle's own finalizer frame (disposing_).
    • It leaves handles that were never registered (not weak) alone.
  • __releaseNativeCounterpart takes the parked state back before resetting the handle, so retiring a kept-alive instance can't leave a strong root behind.
  • Teardown (DisposeAllRegistered) needed no change: it resets strong and weak handles alike, and deletes every registered state.
  • Only instances of __extends classes are accepted, tracked in Caches::RetainTrackedClasses when the swizzles are installed. Anything else, including plain .extend() classes (never protected), throws a TypeError.

Trade-off

The opt-in reintroduces exactly what the refusal avoids: a cycle from the native retain back to the instance through JS is never collected. It is meant for delegates and providers that a native framework retains and that nothing they reference retains back. This is documented in the API comment and next to the Known hazard.

The structural fix is still the planned move to tracing (RESURRECTION_TO_REACHABILITY.md / cppgc). That doc defers the ObjC gcProtected_ case to the Phase 2 membrane, and this opt-in doesn't change that plan.

Independent of the husk-diagnostics PR (feat/disposed-wrapper-diagnostics). The only file both touch is Caches.h, where each adds a separate member.

Tests

TestRunner/app/tests/KeepAliveWhileRetainedTests.js (4 specs):

  • an opted-in, natively retained instance keeps a JS-only native child usable across forced GCs, opting in both before and after the native retain;
  • the instance becomes collectable again once native releases it (WeakRef cleared);
  • TypeError for plain native objects, .extend() instances and non-objects;
  • __releaseNativeCounterpart on a kept-alive, protected instance frees its handle (no leaked strong root).

Full suite on a dedicated simulator: 1775 specs, 0 failures. ASan run (-a): 1770 specs, 0 failures, no sanitizer reports.

interop.keepAliveWhileRetained typings belong in @nativescript/types and are a follow-up.

…nstances

* A natively retained instance of a class built by __extends is kept alive by refusing its finalizer (GcProtect is a flag on a weak kFinalizer handle). Everything reachable only through its JS properties is still queued and disposed in the same GC, so the instance comes back holding a husk. interop.keepAliveWhileRetained(obj) opts one instance into a strong handle for exactly as long as it is protected, so V8 traces its properties like any other root.
* ObjectManager::SetGcProtected is now the only way the swizzled retain/release change protection; ObjectManager::SyncKeepAliveRoot clears the handle's weakness on protect and re-arms it on unprotect, parking the weak callback's ObjectWeakCallbackState on the ObjCDataWrapper in between. __releaseNativeCounterpart takes that parked state back before resetting the handle, so retiring a kept-alive instance never leaves a strong root behind.
* The opt-in is per instance because the strong handle is exactly what the refusal exists to avoid: a cycle from the native retain back to the instance through JS is never collected. Only instances of __extends classes (Caches::RetainTrackedClasses) are accepted; anything else throws a TypeError.
* The trade-off is documented next to the known hazard in docs/knowledge/v8-resurrecting-finalizers.md.
@coderabbitai

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