fix: eliminate JPromise listener leak and SafeContinuation deadlock (#1439)#1441
Open
skjolber wants to merge 7 commits into
Open
fix: eliminate JPromise listener leak and SafeContinuation deadlock (#1439)#1441skjolber wants to merge 7 commits into
skjolber wants to merge 7 commits into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 rawContinuation(theCustomContinuationpatterncommon in Java coroutine interop).
Two bugs compound to cause the hang:
Bug 1 — Wrong-state listener registration (
JPromise.hpp)addOnResolvedListeneron an already-rejectedJPromise(and the symmetricaddOnRejectedListeneron an already-resolved one) fell through the plainelsebranch.The listener was silently pushed into
_onResolvedListeners/_onRejectedListeners— a queuethat would never be drained. The listener and everything it captured were retained for the
lifetime of the
JPromise.Bug 2 —
SafeContinuationfast-path skipsresumeWith(Promise.kt)await()usedsuspendCoroutine, which wraps the caller's continuation in aSafeContinuation.When the
Promiseis already resolved at the timeawait()is called,SafeContinuation::resumeWith()stores the value andgetOrThrow()returns it directly —without ever calling
delegate.resumeWith(). Java callers that block onCompletableFuture.get()after callingawait()therefore hang forever, becauseCustomContinuation.resumeWith()is never invoked.Fix
JPromise.hpp— add explicitelse ifguards for the opposite-settled state:addOnResolvedListeneron a rejected promise → discard the listeneraddOnRejectedListeneron a resolved promise → discard the listenerresolve()/reject()dispatch listeners outside the lock (mirrors C++Promise<T>)and clear the opposite listener vector immediately
Promise.kt— replacesuspendCoroutinewithsuspendCoroutineUninterceptedOrReturnintercepted():By always returning
COROUTINE_SUSPENDEDand routing all results throughcont.resumeWith(),the underlying continuation — including any Java-side
CustomContinuation— is always invokedregardless of whether the Promise was already settled at call time.
.intercepted()preservescorrect dispatcher behaviour for Kotlin coroutine callers.
Test
Adds
callCallbackSequentiallyFirstThrowstoTestObjectSwiftKotlinalong with aSequentialCallbackInputstruct. The method calls a JS async callback twice from asingle-threaded Java executor using the
CustomContinuation + await()pattern — the exactpattern from affected downstream SDKs. The first invocation throws; the second must not hang.
"Timeouted!"The 4 pre-existing
ArrayBufferfailures in the suite are unrelated to this PR.Files changed
packages/react-native-nitro-modules/android/src/main/cpp/core/JPromise.hpppackages/react-native-nitro-modules/android/src/main/java/com/margelo/nitro/core/Promise.ktawait()to always callresumeWithpackages/react-native-nitro-test/src/specs/TestObject.nitro.tsSequentialCallbackInput+callCallbackSequentiallyFirstThrowspackages/react-native-nitro-test/android/.../HybridTestObjectKotlin.ktCustomContinuation+JavaCallHelperpackages/react-native-nitro-test/android/.../CustomContinuation.javaContinuationimplementation mirroring the real-world anti-patternpackages/react-native-nitro-test/android/.../JavaCallHelper.javaawait(continuation)+future.get()packages/react-native-nitro-test/ios/.../HybridTestObjectSwift.swiftexample/src/getTests.tsexample/__tests__/nitro.harness.tsTestObject (Swift/Kotlin)harness describe block