Skip to content

fix: eliminate JPromise listener leak and SafeContinuation deadlock (#1439)#1441

Open
skjolber wants to merge 7 commits into
mrousavy:mainfrom
skjolber:fix/issue-1439-repro
Open

fix: eliminate JPromise listener leak and SafeContinuation deadlock (#1439)#1441
skjolber wants to merge 7 commits into
mrousavy:mainfrom
skjolber:fix/issue-1439-repro

Conversation

@skjolber

Copy link
Copy Markdown

This PR is again generated by AI, most code changes unfortunately are a bit over my head. But it is much cleaner than #1440 and also actually attempts to fix the relevant issues.

Problem

When a JS async callback is invoked twice in sequence from a native Java background thread,
the second call hangs forever. This was reported in real-world usage where a downstream SDK's
Java code calls Promise.await() using a raw Continuation (the CustomContinuation pattern
common in Java coroutine interop).

Two bugs compound to cause the hang:

Bug 1 — Wrong-state listener registration (JPromise.hpp)

addOnResolvedListener on an already-rejected JPromise (and the symmetric
addOnRejectedListener on an already-resolved one) fell through the plain else branch.
The listener was silently pushed into _onResolvedListeners / _onRejectedListeners — a queue
that would never be drained. The listener and everything it captured were retained for the
lifetime of the JPromise.

Bug 2 — SafeContinuation fast-path skips resumeWith (Promise.kt)

await() used suspendCoroutine, which wraps the caller's continuation in a SafeContinuation.
When the Promise is already resolved at the time await() is called,
SafeContinuation::resumeWith() stores the value and getOrThrow() returns it directly —
without ever calling delegate.resumeWith(). Java callers that block on
CompletableFuture.get() after calling await() therefore hang forever, because
CustomContinuation.resumeWith() is never invoked.

Fix

JPromise.hpp — add explicit else if guards for the opposite-settled state:

  • addOnResolvedListener on a rejected promise → discard the listener
  • addOnRejectedListener on a resolved promise → discard the listener
  • resolve() / reject() dispatch listeners outside the lock (mirrors C++ Promise<T>)
    and clear the opposite listener vector immediately

Promise.kt — replace suspendCoroutine with suspendCoroutineUninterceptedOrReturn

  • intercepted():
// Before — SafeContinuation fast-path skips resumeWith when already resolved:
suspend fun await(): T = suspendCoroutine { continuation ->
    then { result -> continuation.resume(result) }
    catch { error -> continuation.resumeWithException(error) }
}

// After — resumeWith is always called on the underlying continuation:
suspend fun await(): T = suspendCoroutineUninterceptedOrReturn { uCont ->
    val cont = uCont.intercepted()
    then { result -> cont.resumeWith(Result.success(result)) }
    catch { error -> cont.resumeWith(Result.failure(error)) }
    COROUTINE_SUSPENDED
}

By always returning COROUTINE_SUSPENDED and routing all results through cont.resumeWith(),
the underlying continuation — including any Java-side CustomContinuation — is always invoked
regardless of whether the Promise was already settled at call time. .intercepted() preserves
correct dispatcher behaviour for Kotlin coroutine callers.

Test

Adds callCallbackSequentiallyFirstThrows to TestObjectSwiftKotlin along with a
SequentialCallbackInput struct. The method calls a JS async callback twice from a
single-threaded Java executor using the CustomContinuation + await() pattern — the exact
pattern from affected downstream SDKs. The first invocation throws; the second must not hang.

Result
Before fix timed out after 1552 ms with "Timeouted!"
After fix ✓ passes in 3 ms

The 4 pre-existing ArrayBuffer failures in the suite are unrelated to this PR.

Files changed

File Change
packages/react-native-nitro-modules/android/src/main/cpp/core/JPromise.hpp Fix wrong-state listener registration and listener dispatch
packages/react-native-nitro-modules/android/src/main/java/com/margelo/nitro/core/Promise.kt Fix await() to always call resumeWith
packages/react-native-nitro-test/src/specs/TestObject.nitro.ts Add SequentialCallbackInput + callCallbackSequentiallyFirstThrows
packages/react-native-nitro-test/android/.../HybridTestObjectKotlin.kt Kotlin implementation using CustomContinuation + JavaCallHelper
packages/react-native-nitro-test/android/.../CustomContinuation.java Java Continuation implementation mirroring the real-world anti-pattern
packages/react-native-nitro-test/android/.../JavaCallHelper.java Java caller that triggers the deadlock via await(continuation) + future.get()
packages/react-native-nitro-test/ios/.../HybridTestObjectSwift.swift Swift implementation (coroutine-based, not affected by the bug)
example/src/getTests.ts Failing runtime assertion merged into callback test section
example/__tests__/nitro.harness.ts Test wired into TestObject (Swift/Kotlin) harness describe block

skjolber and others added 6 commits July 17, 2026 19:22
…rom native Java thread

Adds a minimal failing test for issue mrousavy#1439: a JPromise listener registered
after the promise is already settled (wrong-state registration) is silently
queued in _onResolvedListeners / _onRejectedListeners and never called,
causing a deadlock when Java code blocks on CompletableFuture.get().

The reproduction pattern (mirroring real-world usage in downstream SDKs):
  1. A JS async function is passed as a struct field (Issue1439Params.fn).
  2. Native code calls fn twice on a background thread using the
     CustomContinuation + await() anti-pattern common in Java callers.
  3. The first call throws immediately; the already-rejected JPromise's
     addOnResolvedListener() queues the continuation in _onResolvedListeners
     (which will never fire) instead of discarding it.
  4. The second call succeeds but the CompletableFuture created for it is
     never completed because SafeContinuation.getOrThrow() returns the value
     directly and resumeWith() is never called -- Java ignores the return and
     blocks on future.get() forever.

Both runtime tests time out with "Timeouted!" on Android, making CI red.
iOS returns a stub (not-yet-implemented) so the harness skips those tests.

Fixes: mrousavy#1439

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…round thread

Reproduces mrousavy#1439.

Adds callCallbackSequentiallyFirstThrows to TestObjectSwiftKotlin and its
implementations. Native code calls a JS async callback twice in sequence
on a background thread using the CustomContinuation + await() pattern
common in Java callers:

  Promise<Promise<String>> call = fn.invoke(input);
  CompletableFuture<String> future = new CompletableFuture<>();
  inner.await(new CustomContinuation<>(future));   // ← if inner already resolved,
  String result = future.get();                    //   SafeContinuation returns the
                                                   //   value directly without calling
                                                   //   resumeWith(); future.get() hangs

The first invocation throws; the second invocation then hangs forever because
JPromise.addOnResolvedListener registers a listener on an already-rejected
promise (wrong-state registration) that is never called, leaving the
CompletableFuture permanently incomplete.

The test times out with "Timeouted!" on Android, making CI red.
Swift implementation uses coroutines and does not exhibit the hang.

New files:
  - CustomContinuation.java   exact Java continuation anti-pattern
  - JavaCallHelper.java       Java caller that triggers the deadlock

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…state

addOnResolvedListener on an already-rejected JPromise (and the symmetric
addOnRejectedListener on an already-resolved one) fell into the plain else
branch, silently pushing the listener into a queue that would never be
drained. The listener — and any objects it captured — were retained for the
lifetime of the JPromise with no chance of ever being called.

Fix: add an explicit else-if for the opposite settled state and discard the
listener instead of queuing it.

Also move listener dispatch in resolve()/reject() to after the lock is
released (mirrors the C++ Promise<T> pattern) and clear the opposite
listener list on settlement so any already-queued wrong-state listeners
are also released immediately.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two related bugs causing hangs when a JS async callback is invoked twice
from a native Java background thread (issue mrousavy#1439):

1. Wrong-state listener registration (JPromise.hpp)
   addOnResolvedListener on an already-rejected JPromise (and the symmetric
   addOnRejectedListener on an already-resolved one) fell into the plain else
   branch, silently queuing the listener in a vector that would never be
   drained. The listener — and any captured objects — were retained for the
   entire lifetime of the JPromise.

   Fix: add explicit else-if guards for the settled-opposite-state case and
   discard the listener immediately. Also move listener dispatch in resolve()
   and reject() to after the lock is released (mirrors Promise<T> in C++),
   and clear the opposite listener vector on settlement so any previously
   queued wrong-state listeners are released right away.

2. SafeContinuation fast-path skips resumeWith (Promise.kt)
   await() used suspendCoroutine, which internally wraps the caller's
   continuation in a SafeContinuation. When the Promise is already resolved
   at the time await() is called, SafeContinuation::resume() stores the
   value and getOrThrow() returns it directly — without ever calling
   delegate.resumeWith(). Java callers that ignore the return value of
   await() (treating it as a pure void suspend function) and block on
   CompletableFuture.get() therefore hang forever.

   Fix: replace suspendCoroutine with suspendCoroutineUninterceptedOrReturn
   + intercepted(). By always returning COROUTINE_SUSPENDED and routing both
   then/catch results through cont.resumeWith(), the underlying continuation
   (including any Java-side CustomContinuation) is always invoked, regardless
   of whether the Promise was already settled when await() was called.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
nitro-docs Skipped Skipped Jul 17, 2026 6:07pm

Request Review

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.

1 participant