From 65aabbda2341ad2264f908fbc1459b0c5e78a9ad Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Fri, 23 Oct 2026 13:58:32 +0200 Subject: [PATCH] test: destroy native Promise listeners on another thread JSI lets a JS engine destroy a native function's state on any thread. Hermes' concurrent GC does that on its own background thread, which is not attached to the JVM on Android. The test runtime now has `destroyOnBackgroundThread(fn)`, which takes a native function's state and destroys it on a new thread. Two cases use it on the `then` and `catch` listeners Nitro adds to a JS Promise passed to `awaitAndGetPromise(...)`, once while pending and once after it resolved. Kotlin crashes on both: the listeners release JNI global references and reject the Kotlin Promise without an attached thread. --- .../test-runner/cpp/TestRuntime.cpp | 16 +++++++++ .../standalone/test-runner/js/memory.test.ts | 35 +++++++++++++++++++ .../test-runner/js/utils/testObjects.ts | 2 ++ 3 files changed, 53 insertions(+) diff --git a/tests/standalone/test-runner/cpp/TestRuntime.cpp b/tests/standalone/test-runner/cpp/TestRuntime.cpp index dc6319fe69..347ad9cbc1 100644 --- a/tests/standalone/test-runner/cpp/TestRuntime.cpp +++ b/tests/standalone/test-runner/cpp/TestRuntime.cpp @@ -185,6 +185,22 @@ namespace { rt.instrumentation().collectGarbage("Nitro runtime test"); return jsi::Value::undefined(); })); + // JSI may destroy a native function's state on any thread, e.g. Hermes' concurrent GC thread. + // This takes that state out of the function and destroys it on a new thread right away. On + // Android, that thread is not attached to the JVM, like Hermes' GC thread. + runtime->global().setProperty( + *runtime, "destroyOnBackgroundThread", + jsi::Function::createFromHostFunction(*runtime, jsi::PropNameID::forAscii(*runtime, "destroyOnBackgroundThread"), 1, + [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) { + if (count != 1 || !args[0].isObject() || !args[0].getObject(rt).isFunction(rt)) + throw jsi::JSError(rt, "destroyOnBackgroundThread(...) expects a function"); + jsi::Function function = args[0].getObject(rt).getFunction(rt); + if (!function.isHostFunction(rt)) + throw jsi::JSError(rt, "destroyOnBackgroundThread(...) expects a native function"); + jsi::HostFunctionType state = std::move(function.getHostFunction(rt)); + std::thread([state = std::move(state)]() mutable { state = nullptr; }).join(); + return jsi::Value::undefined(); + })); phase = "loading JS test bundle"; const auto timeout = std::chrono::milliseconds(timeoutMs); const auto timeoutMessage = "Timed out after " + std::to_string(timeoutMs) + "ms"; diff --git a/tests/standalone/test-runner/js/memory.test.ts b/tests/standalone/test-runner/js/memory.test.ts index f101c454ca..32ed9bb950 100644 --- a/tests/standalone/test-runner/js/memory.test.ts +++ b/tests/standalone/test-runner/js/memory.test.ts @@ -35,8 +35,43 @@ function getHermesExternalMemorySize(): number { return hermesInternal.getInstrumentedStats().js_externalBytes } +/** + * Creates a pending JS Promise that keeps the native `then(onResolved, onRejected)` listeners Nitro + * adds to it, instead of handing them to the JS engine, so a test can destroy their native state. + */ +function createListenedPromise(): { promise: Promise; listeners: ((value: unknown) => void)[] } { + const listeners: ((value: unknown) => void)[] = [] + const promise = new Promise(() => {}) + Object.defineProperty(promise, 'then', { + value: (onResolved: (value: unknown) => void, onRejected: (value: unknown) => void) => { + listeners.push(onResolved, onRejected) + }, + }) + return { promise, listeners } +} + export const cases = { ...casesForTestObjects('Memory', { + 'awaitAndGetPromise(...) listeners can be destroyed on another thread while pending': async (testObject) => { + const { promise, listeners } = createListenedPromise() + // Destroying the listeners rejects the native Promise if nothing else holds it. + testObject.awaitAndGetPromise(promise).catch(() => {}) + expect(listeners).to.have.length(2) + + for (const listener of listeners) destroyOnBackgroundThread(listener) + await yieldToEventLoop() + }, + + 'awaitAndGetPromise(...) listeners can be destroyed on another thread after resolving': async (testObject) => { + const { promise, listeners } = createListenedPromise() + const result = testObject.awaitAndGetPromise(promise) + expect(listeners).to.have.length(2) + + listeners[0]?.(42) + expect(await result).to.equal(42) + for (const listener of listeners) destroyOnBackgroundThread(listener) + }, + 'createNativeCallback(...) callbacks still work after gc()': async (testObject) => { const onCalled = recordCallback<(num: number) => void>() const callbacks: ((num: number) => void)[] = [] diff --git a/tests/standalone/test-runner/js/utils/testObjects.ts b/tests/standalone/test-runner/js/utils/testObjects.ts index 6a480603d9..25b0c06c51 100644 --- a/tests/standalone/test-runner/js/utils/testObjects.ts +++ b/tests/standalone/test-runner/js/utils/testObjects.ts @@ -7,6 +7,8 @@ export type TestObject = TestObjectCpp | TestObjectSwiftKotlin declare global { var __nitroTestPlatform: 'apple' | 'android' function gc(): void + /** Destroys a native function's state on a new native thread, like Hermes' concurrent GC can. */ + function destroyOnBackgroundThread(nativeFunction: (...args: never[]) => unknown): void } export const HybridChild = new TestObjectSwiftKotlin().createChild()