Skip to content

feat(runtime): throw on calls to released ObjC objects and name what released them - #508

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

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

Conversation

@edusperoni

@edusperoni edusperoni commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Makes ObjC calls on objects the GC already released fail in a way an app can trace, and in debug builds names what released them. Builds on the "Known hazard" section of docs/knowledge/v8-resurrecting-finalizers.md.

The problem

When native code retains an instance of a JS-derived class, the runtime keeps it alive by refusing its finalizer (GcProtect), not by holding a strong handle. So a GC still finalizes everything reachable only through that instance's JS properties. An ObjC object owned only by JS and stored in such a property gets released, and the instance survives holding a husk.

Real case: a Firebase App Check provider written in JS. Firebase retains the provider, and the provider held its GACAppAttestProvider in a property. After a GC, every token request hung.

A call on that husk reached ArgConverter::Invoke's wrapper == nullptr branch:

  • Debug builds threw Cannot call method '…' on a disposed native object (class: <unknown>, …). This can happen during HMR or fast view churn., which points away from the cause.
  • Release builds hit tns::Assert(false), so the process aborted with no JS context.

What changes

Calls on a released ObjC object always throw a ReferenceError. This covers methods and properties, in every build, whatever releasedObjectPolicy says:

  • It's an ordinary JS exception, routed through NativeScriptException and ReThrowToV8. Callers can catch it, and uncaught it reaches the app's error handlers and crash reporting with a JS stack.
  • Calls deliberately ignore "report", unlike the struct, Pointer and Reference touches, which keep using the policy. Skipping a call breaks its caller's contract: a completion handler that never runs is exactly the hang above. With no listener and no console in release builds, a skipped call would also leave no trace at all.

The message names what was released (tns::ReleasedObjectMessage, now shared with GetValueOrReport). Example:

The native NSMutableArray object has been released (method 'count' (count)). It was released by the garbage collector while still referenced from 'OwnerOfChild.child': 'OwnerOfChild' is kept alive only because native code retains it, which does not keep the objects its JS properties reference alive. Keep the owner or the property's value reachable from a JS root

The class and property in that message come from debug-only tracking in the finalizer drain:

  • When FinalizerCallback keeps a protected ObjC object, it walks that object's own enumerable data properties one level deep. Accessors are skipped, so no JS runs inside the drain.
  • If a property is already a husk (its finalizer ran first in the drain), it is labelled immediately with its class (taken from its constructor) and Owner.property.
  • If a property is still a releasable ObjC object, it is recorded in Caches::KeptObjectProperties, so its own finalizer labels it if it runs later in the same drain.
  • Each husk is reported once, together with a logged warning.
  • A GC prologue callback (installed in debug only) clears the record, and DisposeAllRegistered releases it while the isolate is still alive.

Cost

  • Release builds: FinalizerCallback runs exactly the previous DisposeValue call after one RuntimeConfig.IsDebug check, and no GC prologue callback is installed. The message is built and the error thrown only on the husk path, never on a normal call.
  • Debug builds: finalized objects aren't tagged or recorded. A release costs one empty-vector check, unless a kept owner recorded that object in the same drain. The real work is the one-level property walk of natively retained objects the GC keeps, which scales with how many such objects are no longer reachable from JS. If that ever shows up in profiles, it can go behind an ns:runtime setting.

The default stays as it is. Making GcProtect a strong root would turn every JS↔ObjC cycle into a leak, and from the JS heap a cycle looks exactly like this case (see the doc). This PR makes the trade-off traceable instead of hanging or aborting. #509 proposes an explicit per-instance opt-in for "strong while natively retained".

Tests

  • New spec in GCFinalizerTests.js: names the property an ObjC object was released through when its natively retained owner was kept. An __extends owner is retained only from an NSMutableArray and holds an NSMutableArray child only in a property; then two forced GCs. Under the default "report" policy the spec checks:
    • a call on the husk throws a ReferenceError naming the class and '…​.child';
    • a second call (addObject) throws too;
    • no releasednativeaccess event fires.
  • Full suite on a simulator: 1772 passed, 0 failed. The debug warning fired for the OwnerOfChild.child husks.
  • ASan (-a): 1767 passed, 0 failed, no sanitizer reports.
  • Docs: docs/knowledge/v8-resurrecting-finalizers.md (calls always throw, the debug naming, and the existing regression spec's corrected name) and docs/ns-builtin-modules.md (the releasedObjectPolicy exception for calls, and the debug messages).

…leased them

* An ObjC method or property call whose receiver lost its native half (a husk the finalizer drain leaves behind, see the "Known hazard" in docs/knowledge/v8-resurrecting-finalizers.md) now follows releasedObjectPolicy like every other released-object touch: it is skipped and reported, or it throws a ReferenceError. It used to throw a debug-only error blaming HMR, and to assert in release builds.
* In debug builds the drain names the hazard when it creates one. FinalizerCallback tags each ObjC husk with its native class, and when it keeps a natively retained object it records that object's own enumerable data properties one level deep; whichever of the pair finalizes second tags the husk with "Owner.property" and logs a warning. GetValueOrReport puts both tags in its message, so the error says what was released and through which property.
* The per-collection bookkeeping lives in Caches, is reset by a GC prologue and is released in DisposeAllRegistered while the isolate is still alive.
@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.

…he drain tracking cheap

* A method or property call on a released ObjC receiver now always throws a ReferenceError, whatever releasedObjectPolicy says. Skipping it under "report" broke the caller's contract (a completion handler that never runs) and, with no listener and no release-build console, left no trace at all; the thrown error reaches the app's error handlers in every build. The message comes from the new ReleasedObjectMessage, shared with GetValueOrReport.
* FinalizerCallback runs exactly the previous DisposeValue call unless RuntimeConfig.IsDebug, and the debug tracking no longer tags or records every released object. Only a kept, natively retained object walks its own data properties: a property that is already a husk is labelled on the spot (class from its constructor), and a releasable one is recorded in Caches::KeptObjectProperties for its own finalizer to label. A drain that keeps nothing records nothing, and a husk is reported once.
@edusperoni edusperoni changed the title feat(runtime): report calls on released ObjC objects and name what released them feat(runtime): throw on calls to released ObjC objects and name what released them Oct 10, 2026

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