GeezerVison is showing github.com — feat(runtime): throw on calls to released ObjC objects and name what released them by edusperoni · Pull Request #508 · NativeScript/ios
Conversation
edusperoni
commented
Oct 10, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
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).
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
…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.
Important
Draft PR not reviewed
Draft PRs are not automatically reviewed by default.
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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
…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
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
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
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.
|