fix: delete self-owned URL wrappers at isolate teardown - #438
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThe 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. ChangesIsolate tracking lifecycle
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
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
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. Comment |
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.
…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.
Problem
URLImpl,URLSearchParamsImplandURLPatternImplfree themselves from aSetWeak(kParameter)finalizer. Weak callbacks never fire at isolate disposal, and these objects are not in any of theCachesstructures thatObjectManager::DisposeAllRegisteredwalks — so every instance still alive when aRuntimeis destroyed (e.g. a worker isolate shutting down) leaked its ada state, and forURLPatternalso its compiledv8::Global<RegExp>handles.Fix
IsolateTrackedbase class absorbs the three identical copy-pasted weak-handle/finalizer blocks and registers each instance in a per-isolaterobin_hood::unordered_setonCaches.ObjectManager::FinalizerCallback), andIsolateTracked::SweepAllin~Runtime(detach-the-set-first then delete survivors, same idiom asDisposeAllRegistered). The sweep runs under theLockerwhile the isolate is alive, so destructors may reset theirv8::Globalhandles.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::RegisterinInterop::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