From 7767fd6b37722d5ede59a60ed9c3bb4a7b71747d Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 25 Oct 2026 14:57:03 -0700 Subject: [PATCH] fix(android): flush queued finalizers before a jsi runtime is marked destroying DestroyRuntime set is_destroying before flushing the finalizer queue. HostObjectProxyPostFinalizer treats is_destroying as "leave the target handle", a rule carried over from napi, where js_free_napi_env frees leftover refs. engine:: handles have no such owner, so every queued proxy cleanup leaked its JS wrapper, and on QuickJS JS_FreeRuntime aborted on list_empty(&rt->gc_obj_list). The queue is normally drained on the next looper tick. A worker that calls close() from onmessage quits its looper first, which drops that tick, so any proxy collected during the handler was still queued at dispose. terminate() from the parent arrives after the tick has run. Flush the queue first, while the runtime is not yet marked destroying. Fixes #94 --- NativeScript/runtime/android/jsi/Runtime.cpp | 17 +++++++++-------- .../src/main/assets/app/shared/Workers/index.js | 9 +++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/NativeScript/runtime/android/jsi/Runtime.cpp b/NativeScript/runtime/android/jsi/Runtime.cpp index 7991157d2..aa2527539 100644 --- a/NativeScript/runtime/android/jsi/Runtime.cpp +++ b/NativeScript/runtime/android/jsi/Runtime.cpp @@ -557,6 +557,15 @@ std::string Runtime::ReadFileText(const std::string &filePath) { } void Runtime::DestroyRuntime() { + // Flush still-queued finalizer cleanup before is_destroying is set: with it + // set, the cleanup leaks the engine handles it would release, and a quit + // looper never drains it. Finalizers firing during teardown below run + // inline (Runtime::PostFinalizer's fallback). + if (m_finalizerQueue != nullptr) { + m_finalizerQueue->Destroy(); + delete m_finalizerQueue; + m_finalizerQueue = nullptr; + } is_destroying = true; engine::Runtime &rt = engineHost->GetRuntime(); if (m_looperTasks != nullptr) { @@ -578,14 +587,6 @@ void Runtime::DestroyRuntime() { #endif CallbackHandlers::RemoveEnvEntries(rt); this->m_objectManager->OnDisposeRuntime(); - // Release the finalizer handler and flush any still-queued cleanup while the - // runtime is still valid; finalizers firing during teardown below then run - // inline (Runtime::PostFinalizer's fallback). - if (m_finalizerQueue != nullptr) { - m_finalizerQueue->Destroy(); - delete m_finalizerQueue; - m_finalizerQueue = nullptr; - } // Every engine handle this runtime still owns must go while the VM is up. m_gcFunc = engine::Value::undefined(); // Last, because everything above may still call a js_util helper on its way diff --git a/platforms/android/test-app/app/src/main/assets/app/shared/Workers/index.js b/platforms/android/test-app/app/src/main/assets/app/shared/Workers/index.js index 7eaaa1cb8..1578259c8 100644 --- a/platforms/android/test-app/app/src/main/assets/app/shared/Workers/index.js +++ b/platforms/android/test-app/app/src/main/assets/app/shared/Workers/index.js @@ -219,6 +219,15 @@ describe("TNS Workers", () => { worker.postMessage({ eval: "close();" }); }); + if (global.android) { + it("Should not crash on close() after a native wrapper is collected in the same message", (done) => { + var worker = new Worker("./EvalWorker.js"); + + worker.onmessage = () => setTimeout(done, 500); + worker.postMessage({ eval: "new java.lang.Object(); gc(); postMessage('closing'); close();" }); + }); + } + it("Should not throw error if post message is called with native object", () => { var worker = new Worker("./EvalWorker.js");