Skip to content

fix: delete self-owned URL wrappers at isolate teardown - #438

Merged
NathanWalker merged 1 commit into
mainfrom
fix/self-owned-wrapper-leaks
Aug 12, 2026
Merged

fix: delete self-owned URL wrappers at isolate teardown#438
NathanWalker merged 1 commit into
mainfrom
fix/self-owned-wrapper-leaks

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

URLImpl, URLSearchParamsImpl and URLPatternImpl free themselves from a SetWeak(kParameter) finalizer. Weak callbacks never fire at isolate disposal, and these objects are not in any of the Caches structures that ObjectManager::DisposeAllRegistered walks — so every instance still alive when a Runtime is destroyed (e.g. a worker isolate shutting down) leaked its ada state, and for URLPattern also its compiled v8::Global<RegExp> handles.

Fix

  • New IsolateTracked base class absorbs the three identical copy-pasted weak-handle/finalizer blocks and registers each instance in a per-isolate robin_hood::unordered_set on Caches.
  • Deletion happens in exactly two places, and both own the registry bookkeeping: the GC finalizer (erase + delete, same idiom as ObjectManager::FinalizerCallback), and IsolateTracked::SweepAll in ~Runtime (detach-the-set-first then delete survivors, same idiom as DisposeAllRegistered). The sweep runs under the Locker while the isolate is alive, so destructors may reset their v8::Global handles.
  • Net-negative diff in the three headers; call sites are unchanged.

The base deliberately mirrors the cppgc contract (registered at bind, destructor is the sole cleanup point, never deleted directly), so a later cppgc conversion is a base-class swap (v8::Object::Wrappable + Object::Wrap<tag>) plus deleting the registry.

Not included: ExtVector — its instances are already enrolled via ObjectManager::Register in Interop::GetResult, so its disposal path is live and there is nothing to fix.

Testing

Full simulator suite green (zero failures across all suites, including the URL/URLSearchParams/URLPattern suites).

Summary by CodeRabbit

  • Bug Fixes
    • Improved cleanup of URL-related objects when a JavaScript runtime is shut down.
    • Added consistent lifecycle tracking for URLs, URL patterns, and URL search parameters.
    • Reduced the risk of lingering resources or cleanup errors during runtime teardown.

URLImpl, URLSearchParamsImpl and URLPatternImpl free themselves from a
SetWeak(kParameter) finalizer, but weak callbacks never fire at isolate
disposal, so every instance still alive when a Runtime is destroyed leaked
its ada state (and, for URLPattern, its compiled RegExp globals).

Consolidate the three copy-pasted weak-handle/finalizer blocks into an
IsolateTracked base that also registers each instance in a per-isolate
robin_hood set; ~Runtime drains the survivors right after
ObjectManager::DisposeAllRegistered, while the isolate is still alive under
the Locker so destructors may reset their v8::Global handles.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e448b10-c44f-4cc5-afb2-ef0ac192d63f

📥 Commits

Reviewing files that changed from the base of the PR and between 4af8d21 and 6a087f6.

📒 Files selected for processing (6)
  • NativeScript/runtime/Caches.h
  • NativeScript/runtime/IsolateTracked.h
  • NativeScript/runtime/Runtime.mm
  • NativeScript/runtime/URLImpl.h
  • NativeScript/runtime/URLPatternImpl.h
  • NativeScript/runtime/URLSearchParamsImpl.h

📝 Walkthrough

Walkthrough

The change adds shared isolate tracking for C++ objects backed by weak V8 handles. Runtime teardown sweeps remaining tracked objects, and URL-related implementations use the shared lifecycle instead of local finalizers.

Changes

Isolate tracking lifecycle

Layer / File(s) Summary
Tracking registry and lifecycle
NativeScript/runtime/Caches.h, NativeScript/runtime/IsolateTracked.h
Adds the tracked-instance cache and shared registration, weak finalization, deregistration, and teardown-sweep behavior.
Runtime teardown sweep
NativeScript/runtime/Runtime.mm
Calls IsolateTracked::SweepAll(isolate_) during runtime destruction after registered object disposal.
URL object lifecycle migration
NativeScript/runtime/URLImpl.h, NativeScript/runtime/URLPatternImpl.h, NativeScript/runtime/URLSearchParamsImpl.h
Makes the URL implementations inherit from IsolateTracked and removes their local weak-handle members and finalizers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Runtime
  participant IsolateTracked
  participant Caches
  participant V8
  Runtime->>IsolateTracked: SweepAll(isolate_)
  IsolateTracked->>Caches: detach tracked instances
  IsolateTracked->>IsolateTracked: delete remaining instances
  V8->>IsolateTracked: invoke weak finalizer
  IsolateTracked->>Caches: deregister instance
Loading

Suggested reviewers: nathanwalker

Poem

A rabbit hops through handles light,
Tracking each object through the night.
Weak callbacks clear the way,
Teardown sweeps what chose to stay.
URL paths now rest secure.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: deleting URL wrappers during isolate teardown to prevent leaks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@edusperoni
edusperoni marked this pull request as ready for review August 11, 2026 21:44
@NathanWalker
NathanWalker merged commit 73e22b5 into main Aug 12, 2026
9 checks passed
@NathanWalker
NathanWalker deleted the fix/self-owned-wrapper-leaks branch August 12, 2026 21:36
edusperoni added a commit to NativeScript/android that referenced this pull request Aug 14, 2026
Follows the same invariant as the per-runtime state change: anything holding
v8 handles has to be released on the runtime's own thread, before the caller
disposes the isolate.

Use-after-free, and a crash rather than a leak: ~Runtime ran
CallbackHandlers::RemoveIsolateEntries and FrameCallbacks::RemoveIsolateEntries,
whose entry destructors call v8::Global::Reset(). ~Runtime runs after
isolate->Dispose(), so those writes land in a freed handle table. Nothing
dropped the entries earlier either, so between DestroyRuntime and ~Runtime the
main thread could still pick up a queued __runOnMainThread entry and take a
v8::Locker on an isolate that had already been disposed. Both calls move into
DestroyRuntime, which fixes the write-after-free and closes the window, since
the removal now happens under the worker's own Locker before disposal.

URL, URLSearchParams and URLPattern each carried a copy of the same
weak-handle/finalizer block and freed themselves only from the GC finalizer.
V8 does not run weak callbacks when an isolate is disposed, so every instance
still alive when a runtime went away leaked its ada state, and URLPattern its
compiled v8::Global regexps with it. They now share an IsolateTracked base
that registers each instance per runtime; instances die either in the GC
finalizer or in SweepAll at teardown. Mirrors NativeScript/ios#438, with the
registry in RuntimeState rather than Caches.

Also released in DestroyRuntime, none of which had any cleanup at all:
PerIsolateV8Constants (19 handles per runtime, and its destructor was missing
DISCARDED_ERROR_PERSISTENT and only Reset the handles rather than freeing
them), m_context and m_gcFunc.

The com.tns.Runtime JNI global ref is deleted in ~Runtime. It was never
released, which pinned the Java runtime object and every Java object the
runtime had strongly registered through it for the life of the process. It has
to happen there, after ObjectManager's teardown, which calls Java through that
same object, and before the worker thread detaches.

Five subsystems had each grown a private copy of "read the isolate slot
because Runtime::GetRuntime throws" -- three identical GetRuntimeOrNull
helpers plus two inline reads. They share Runtime::TryGetRuntime now, which
also gives RuntimeState a lookup safe to call from a GC weak callback.

Verified on an arm64 emulator with the SIGSEGV handler temporarily disabled so
faults are fatal and tombstoned; suite 879/0.
edusperoni added a commit to NativeScript/android that referenced this pull request Aug 14, 2026
…er-free, and leaks (#2006)

* fix: own isolate-bound state per runtime instead of in shared maps

Workers bootstrap on detached threads and are not serialized, so several
runtimes are inside PrepareV8Runtime while another is in disposeIsolate. A
handful of subsystems kept their per-isolate state in process-wide maps keyed
by v8::Isolate*, which makes the *container* shared even though the entries are
not: one runtime inserting its own entry while another erases its own corrupts
the map. Holding the isolate's Locker does not help, because each thread holds
only its own isolate's lock, so two runtimes never exclude each other.

The reproduced crash walked a freed red-black tree node:

  std::less<v8::Isolate*>::operator()
  std::map<v8::Isolate*, std::map<std::string,double>>::insert
  tns::Console::createConsole
  tns::Runtime::PrepareV8Runtime
  Java_com_tns_Runtime_initNativeScript      (thread W41: ./EvalWork)

Rather than guard each container, remove the sharing: RuntimeState is a typed
per-runtime slot bag owned by Runtime. A subsystem declares a state struct,
usually in its own .cpp, and reaches it with RuntimeState::For<T>(isolate) --
an isolate data-slot read plus a vector index, with no lock and no shared
container. The bag is destroyed once in DestroyRuntime, on the runtime's own
thread and while the isolate is still alive, which is what state holding
v8::Persistents requires.

Moved onto it:

- Console: console.time() labels and the compiled inspect.js instance. Console
  now has no global mutable state and no mutex at all.
- ArgConverter: the java-long conversion helpers.
- JSONObjectHelper: the compiled JS->org.json serializer.
- MetadataNode: the per-isolate node cache and the array wrapper template, plus
  the constructor functions that used to hang off every node as a map keyed by
  isolate -- which is why teardown had to walk every node in
  s_treeNode2NodeCache to erase one entry. That walk, running on a dying
  worker's thread while other threads inserted, is gone.

Four onDisposeIsolate hooks disappear with it: nothing is keyed by isolate any
more, so there is no per-isolate entry to erase.

Also:

- MetadataNode::s_profilerEnabled and Runtime::s_mainThreadInitialized are now
  atomic. The latter gated the one-time BuildMetadata, so as a plain bool there
  was no happens-before edge between the main thread's metadata construction
  and a worker's first read of s_metadataReader.
- TypeLongOperationsCache gains a destructor; it was deleted without one,
  leaking two v8::Persistents per isolate.
- console.time/timeEnd no longer dereference the iterator returned by a failed
  find (both had a "// throw?" comment and then used it anyway).

Verified on an arm64 emulator with the SIGSEGV handler temporarily disabled, so
faults are fatal and tombstoned rather than swallowed: the earlier, narrower
mutex-based version of this fix ran 20/20 full-suite runs clean against a
baseline that reproduced roughly 1 in 5. Re-verification of this version is
running; suite is 879/0.

Still shared, and deliberately left for a follow-up: the metadata tree and
MetadataReader's buffers (genuinely one blob for the process, so they need a
narrow lock rather than per-runtime storage), and the string-keyed
MethodCache::s_mthod_ctor_signature_cache and JEnv::s_classCache.

* fix: release isolate-bound state while the isolate is still alive

Follows the same invariant as the per-runtime state change: anything holding
v8 handles has to be released on the runtime's own thread, before the caller
disposes the isolate.

Use-after-free, and a crash rather than a leak: ~Runtime ran
CallbackHandlers::RemoveIsolateEntries and FrameCallbacks::RemoveIsolateEntries,
whose entry destructors call v8::Global::Reset(). ~Runtime runs after
isolate->Dispose(), so those writes land in a freed handle table. Nothing
dropped the entries earlier either, so between DestroyRuntime and ~Runtime the
main thread could still pick up a queued __runOnMainThread entry and take a
v8::Locker on an isolate that had already been disposed. Both calls move into
DestroyRuntime, which fixes the write-after-free and closes the window, since
the removal now happens under the worker's own Locker before disposal.

URL, URLSearchParams and URLPattern each carried a copy of the same
weak-handle/finalizer block and freed themselves only from the GC finalizer.
V8 does not run weak callbacks when an isolate is disposed, so every instance
still alive when a runtime went away leaked its ada state, and URLPattern its
compiled v8::Global regexps with it. They now share an IsolateTracked base
that registers each instance per runtime; instances die either in the GC
finalizer or in SweepAll at teardown. Mirrors NativeScript/ios#438, with the
registry in RuntimeState rather than Caches.

Also released in DestroyRuntime, none of which had any cleanup at all:
PerIsolateV8Constants (19 handles per runtime, and its destructor was missing
DISCARDED_ERROR_PERSISTENT and only Reset the handles rather than freeing
them), m_context and m_gcFunc.

The com.tns.Runtime JNI global ref is deleted in ~Runtime. It was never
released, which pinned the Java runtime object and every Java object the
runtime had strongly registered through it for the life of the process. It has
to happen there, after ObjectManager's teardown, which calls Java through that
same object, and before the worker thread detaches.

Five subsystems had each grown a private copy of "read the isolate slot
because Runtime::GetRuntime throws" -- three identical GetRuntimeOrNull
helpers plus two inline reads. They share Runtime::TryGetRuntime now, which
also gives RuntimeState a lookup safe to call from a GC weak callback.

Verified on an arm64 emulator with the SIGSEGV handler temporarily disabled so
faults are fatal and tombstoned; suite 879/0.

* fix: don't reset constant handles the constructor never allocated

PerIsolateV8Constants declares 20 Persistent<String>* members but the
constructor allocates 19: DEBUG_NAME_PERSISTENT is never assigned. Its
destructor reset that member unconditionally, so it would have faulted on an
uninitialized pointer the first time it ran -- which nothing ever did, because
the object was leaked rather than deleted. Deleting it exposed the fault
immediately: every worker teardown segfaulted in ~PerIsolateV8Constants.

Default-initialize every member so the destructor is safe regardless of which
ones the constructor populates; ResetAndDelete already skips nulls.

* fix: free the constructor caches' persistents in ~MetadataNodeCache

CtorCacheData::ft and ExtendedClassCacheData::extendedCtorFunction are owning
raw pointers, so each runtime leaked a v8::Persistent and its global handle per
materialized class and per .extend().

They are freed from the maps rather than from the two structs: both are stored
by value and handed out by value -- GetCachedExtendedClassData returns a copy --
and the copies share these pointers, so a destructor on either struct would turn
every copy into a double free.
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.

2 participants