Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
--------- beginning of crash
07-17 16:50:54.323 9295 9295 E AndroidRuntime: FATAL EXCEPTION: main
07-17 16:50:54.323 9295 9295 E AndroidRuntime: Process: com.margelo.nitroexample, PID: 9295
07-17 16:50:54.323 9295 9295 E AndroidRuntime: com.facebook.soloader.SoLoaderDSONotFoundError: couldn't find DSO to load: libreactnative.so
07-17 16:50:54.323 9295 9295 E AndroidRuntime: existing SO sources:
07-17 16:50:54.323 9295 9295 E AndroidRuntime: SoSource 0: ApplicationSoSource[DirectorySoSource[root = /data/app/~~ltrc45ffcQoMl_alJJueQw==/com.margelo.nitroexample--0m3_FOd2upKxHOUO5Z-Sg==/lib/arm64 flags = 0]]
07-17 16:50:54.323 9295 9295 E AndroidRuntime: SoSource 1: DirectApkSoSource[root = [/data/app/~~ltrc45ffcQoMl_alJJueQw==/com.margelo.nitroexample--0m3_FOd2upKxHOUO5Z-Sg==/base.apk!/lib/x86_64]]
07-17 16:50:54.323 9295 9295 E AndroidRuntime: SoSource 2: DirectorySoSource[root = /system/lib64 flags = 3]
07-17 16:50:54.323 9295 9295 E AndroidRuntime: SoSource 3: DirectorySoSource[root = /vendor/lib64 flags = 3]
07-17 16:50:54.323 9295 9295 E AndroidRuntime: Native lib dir: /data/app/~~ltrc45ffcQoMl_alJJueQw==/com.margelo.nitroexample--0m3_FOd2upKxHOUO5Z-Sg==/lib/arm64
07-17 16:50:54.323 9295 9295 E AndroidRuntime:
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.soloader.SoLoader.doLoadLibraryBySoName(SoLoader.java:1216)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.soloader.SoLoader.loadLibraryBySoNameImpl(SoLoader.java:1078)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.soloader.SoLoader.loadLibraryBySoName(SoLoader.java:943)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.soloader.SoLoader.loadLibraryOnAndroid(SoLoader.java:859)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.soloader.SoLoader.loadLibrary(SoLoader.java:842)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.soloader.SoLoader.loadLibrary(SoLoader.java:812)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsCxxInterop.<clinit>(ReactNativeFeatureFlagsCxxInterop.kt:28)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsCxxAccessor.override(ReactNativeFeatureFlagsCxxAccessor.kt:925)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.react.internal.featureflags.ReactNativeFeatureFlags.override(ReactNativeFeatureFlags.kt:588)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load(DefaultNewArchitectureEntryPoint.kt:101)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load(DefaultNewArchitectureEntryPoint.kt:43)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative(ReactNativeApplicationEntryPoint.java:31)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.margelo.nitroexample.MainApplication.onCreate(MainApplication.kt:29)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:1396)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.app.ActivityThread.handleBindApplication(ActivityThread.java:8365)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.app.ActivityThread.-$$Nest$mhandleBindApplication(ActivityThread.java:0)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2776)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.os.Handler.dispatchMessageImpl(Handler.java:142)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.os.Handler.dispatchMessage(Handler.java:126)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.os.Looper.loopOnce(Looper.java:295)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.os.Looper.loop(Looper.java:398)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at android.app.ActivityThread.main(ActivityThread.java:9572)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at java.lang.reflect.Method.invoke(Native Method)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:575)
07-17 16:50:54.323 9295 9295 E AndroidRuntime: at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:842)
4 changes: 4 additions & 0 deletions example/.harness/manifest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
global.RN_HARNESS = {
appRegistryComponentName: 'NitroExample',
disableViewFlattening: false,
};
13 changes: 12 additions & 1 deletion example/__tests__/nitro.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
TestObjectCpp,
TestObjectSwiftKotlin,
} from 'react-native-nitro-test'
import { getTests } from '../src/getTests'
import { getTests, getIssue1439Tests } from '../src/getTests'
import { harnessBackend } from '../src/testing/backends/harness'

function createTestRunner(
Expand Down Expand Up @@ -32,3 +32,14 @@ const testObjectSwiftKotlin =

describe('TestObject (C++)', createTestRunner(testObjectCpp))
describe('TestObject (Swift/Kotlin)', createTestRunner(testObjectSwiftKotlin))
describe('Issue 1439', () => {
const tests = getIssue1439Tests({ backend: harnessBackend })
for (const test of tests) {
it(test.name, async () => {
const result = await test.run()
if (result.status === 'failed') {
throw new Error(result.message)
}
})
}
})
63 changes: 63 additions & 0 deletions example/src/getTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Base,
HybridPlatformObject,
HybridChild,
HybridIssue1439,
} from 'react-native-nitro-test'
import {
type AssertionBackend,
Expand Down Expand Up @@ -2444,3 +2445,65 @@ export function getTests(
),
]
}

/**
* Tests reproducing https://github.com/mrousavy/nitro/issues/1439
*
* A JS async function is passed inside a struct to native code.
* Native calls it twice from a background thread — first call throws, second should succeed.
*/
export function getIssue1439Tests(options: GetTestsOptions = {}): TestRunner[] {
const backend = options.backend ?? throwingBackend
const { it } = createTestRunner(backend)
const createTest = createCreateTest()

return [
createTest(
'[Issue #1439] Second fn invocation succeeds when first threw',
async () =>
(
await it(async () => {
let callCount = 0
const result = await HybridIssue1439.callFnTwiceFirstThrows({
fn: async (input) => {
callCount++
if (input.value === 'first') {
throw new Error('Expected error on first call')
}
return input.value
},
})
if (callCount !== 2) {
throw new Error(
`Expected fn to be called twice, got ${callCount}`
)
}
return result
})
)
.didNotThrow()
.equals('second')
),
createTest(
'[Issue #1439] fn in struct can be called multiple times without error',
async () =>
(
await it(async () => {
const results: string[] = []
await HybridIssue1439.callFnTwiceFirstThrows({
fn: async (input) => {
if (input.value === 'first') {
throw new Error('Expected error on first call')
}
results.push(input.value)
return input.value
},
})
return results.join(',')
})
)
.didNotThrow()
.equals('second')
),
]
}
7 changes: 5 additions & 2 deletions example/src/screens/HybridObjectTestsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
HybridChild,
HybridBase,
} from 'react-native-nitro-test'
import { getTests, type TestRunner } from '../getTests'
import { getTests, getIssue1439Tests, type TestRunner } from '../getTests'
import { logPrototypeChain } from '../logPrototypeChain'
import SegmentedControl from '@react-native-segmented-control/segmented-control'
import { NitroModules } from 'react-native-nitro-modules'
Expand Down Expand Up @@ -53,7 +53,10 @@ export function HybridObjectTestsScreen() {
]
console.log(`Showing Tests for HybridObject "${selectedObject?.name}"`)
const allTests = React.useMemo(
() => getTests(selectedObject ?? HybridTestObjectCpp),
() => [
...getTests(selectedObject ?? HybridTestObjectCpp),
...getIssue1439Tests(),
],
[selectedObject]
)
const [unfilteredTests, setTests] = React.useState<TestState[]>(() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.margelo.nitro.test;

import org.jetbrains.annotations.NotNull;

import java.util.concurrent.CompletableFuture;

import kotlin.ResultKt;
import kotlin.coroutines.Continuation;
import kotlin.coroutines.CoroutineContext;
import kotlin.coroutines.EmptyCoroutineContext;

/**
* Bridges a Kotlin {@code Continuation<T>} to a Java {@link CompletableFuture}.
*
* <p>This is the <em>exact</em> pattern used in
* {@code JavaHybridAbtMobileClientSDK.java} (lines 627–641) that triggers
* https://github.com/mrousavy/nitro/issues/1439.</p>
*
* <p><strong>The bug:</strong> when the Kotlin {@code suspend fun await()} is called
* from Java with this continuation, and the {@code Promise} is <em>already resolved</em>
* by the time {@code await()} is called, Kotlin's {@code SafeContinuation} short-circuits:
* it returns the value directly from {@code await()} instead of calling
* {@link #resumeWith}. The Java caller ignores that return value, calls
* {@link CompletableFuture#get()}, and <strong>blocks forever</strong> because
* {@link #resumeWith} was never invoked.</p>
*/
public class CustomContinuation<T> implements Continuation<T> {

private final CompletableFuture<T> future;

public CustomContinuation(CompletableFuture<T> future) {
this.future = future;
}

@NotNull
@Override
public CoroutineContext getContext() {
return EmptyCoroutineContext.INSTANCE;
}

/**
* Called by Kotlin's coroutine machinery when the {@code Promise} resolves or
* rejects <em>asynchronously</em> (i.e. after {@code await()} returned
* {@code COROUTINE_SUSPENDED}).
*
* <p><em>Not</em> called when the Promise is already settled at the time
* {@code await()} is invoked — that is the root of the issue.</p>
*/
@Override
public void resumeWith(@NotNull Object result) {
try {
ResultKt.throwOnFailure(result);
//noinspection unchecked
future.complete((T) result);
} catch (Throwable t) {
future.completeExceptionally(t);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.margelo.nitro.test

import android.util.Log
import androidx.annotation.Keep
import com.facebook.proguard.annotations.DoNotStrip
import com.margelo.nitro.core.Promise
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

@Keep
@DoNotStrip
class HybridIssue1439 : HybridIssue1439Spec() {

/**
* Single-threaded executor pool — mirrors the `IdlingScheduledThreadPoolExecutor`
* used in JavaHybridAbtMobileClientSDK.java.
*/
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "issue1439-pool-thread-0").also { it.isDaemon = true }
}

/**
* Reproduces https://github.com/mrousavy/nitro/issues/1439
*
* Uses the EXACT pattern from JavaHybridAbtMobileClientSDK.java (lines 627–641):
* - A thread-pool executor (not a coroutine)
* - `call.await(new CustomContinuation<>(future))` from Java
* - `future.get()` blocking wait
*
* Root cause: when `Promise.await(continuation)` is called from Java/non-coroutine Kotlin
* and the Promise is *already resolved*, Kotlin's `SafeContinuation` returns the value
* directly from `await()` — without ever invoking `CustomContinuation.resumeWith()`.
* The Java caller ignores the return value and calls `future.get()` which blocks forever.
*
* This happens because the inner Promise (for the second `fn` invocation) can already be
* resolved by the time we call `await()` on it, depending on JS thread scheduling.
*/
override fun callFnTwiceFirstThrows(params: Issue1439Params): Promise<String> {
val outerPromise = Promise<String>()

executor.submit {
try {
// First invocation — expected to throw
try {
val result = callFnBlockingJavaStyle(params.fn, Issue1439CallInput("first"))
Log.w(TAG, "First call unexpectedly succeeded with: $result")
} catch (e: Exception) {
Log.d(TAG, "First call threw as expected: ${e.message}")
}

// Second invocation — should return "second" but hangs when inner Promise is
// already resolved and SafeContinuation returns the value without calling
// CustomContinuation.resumeWith(), leaving future2 incomplete.
Log.d(TAG, "Starting second invocation...")
val result = callFnBlockingJavaStyle(params.fn, Issue1439CallInput("second"))
Log.d(TAG, "Second call returned: $result")
outerPromise.resolve(result)
} catch (e: TimeoutException) {
val msg = "Issue #1439: second fn invocation timed out — " +
"SafeContinuation swallowed the resume, CompletableFuture.get() blocks forever"
Log.e(TAG, msg)
outerPromise.reject(RuntimeException(msg, e))
} catch (e: Exception) {
Log.e(TAG, "callFnTwiceFirstThrows failed: ${e.message}", e)
outerPromise.reject(e)
}
}

return outerPromise
}

/**
* Faithfully mirrors JavaHybridAbtMobileClientSDK.java lines 627–641.
*
* The critical path that triggers the bug:
*
* ```java
* Promise<Promise<String>> call = fn.invoke(input);
*
* CompletableFuture<Promise<String>> future1 = new CompletableFuture<>();
* call.await(new CustomContinuation<>(future1)); // ← Java calling a Kotlin suspend fun
* Promise<String> inner = future1.get(); // blocks until JS dispatched
*
* CompletableFuture<String> future2 = new CompletableFuture<>();
* inner.await(new CustomContinuation<>(future2)); // ← BUG: if inner already resolved,
* // SafeContinuation returns value directly,
* // CustomContinuation.resumeWith never called
* String result = future2.get(); // ← hangs forever
* ```
*
* Called from Kotlin via [JavaCallHelper] to stay on the same raw thread.
*/
@Throws(Exception::class)
private fun callFnBlockingJavaStyle(
fn: (input: Issue1439CallInput) -> Promise<Promise<String>>,
input: Issue1439CallInput,
): String {
val call: Promise<Promise<String>> = fn(input)
return JavaCallHelper.awaitFnBlocking(call, TIMEOUT_SECONDS, TimeUnit.SECONDS)
}

companion object {
private const val TAG = "HybridIssue1439"
private const val TIMEOUT_SECONDS = 3L
}
}

Loading