Summary
openiap-google can crash intermittently in Helpers.queryPurchases with IllegalStateException: Already resumed.
Affected releases observed:
react-native-iap 15.2.4, which shipped with openiap-google 2.1.5
react-native-iap 15.3.0 / openiap-google 2.2.0 appear to keep the same queryPurchases implementation
- Google Play Billing Library 8.3.0
Crash
Fatal Exception: java.lang.IllegalStateException: Already resumed, but proposed with update []
at kotlinx.coroutines.CancellableContinuationImpl.alreadyResumedError(CancellableContinuationImpl.kt:556)
at dev.hyo.openiap.helpers.HelpersKt$queryPurchases$2$2.onQueryPurchasesResponse(Helpers.kt:100)
at com.android.billingclient.api.BillingClientImpl.zzM(billing@@8.3.0:3)
...
Current Implementation
The Play implementation currently checks continuation.isActive before calling resume, but the check and the resume are not atomic:
billingClient.queryPurchasesAsync(params) { result, purchaseList ->
if (!continuation.isActive) return@queryPurchasesAsync
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
val mapped = purchaseList.map { it.toPurchase(productType, null) }
continuation.resume(mapped)
} else {
continuation.resume(emptyList())
}
}
Relevant path:
packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
Root Cause
If Play Billing invokes the callback more than once, or if two callback paths race on different threads, both callbacks can observe continuation.isActive == true before either call to resume wins. The second resume then crashes with IllegalStateException: Already resumed.
This is related to the class of issue addressed in #89 and #95. Those fixes added isActive guards and made the purchase callback slot single-shot, but queryPurchases still has a check-then-resume race.
Suggested Fix
Use an atomic single-resume guard around queryPurchases callback completion. For example:
import java.util.concurrent.atomic.AtomicBoolean
internal suspend fun queryPurchases(...): List<Purchase> = suspendCancellableCoroutine { continuation ->
val resumed = AtomicBoolean(false)
fun safeResume(value: List<Purchase>) {
if (resumed.compareAndSet(false, true)) {
continuation.resume(value)
}
}
val billingClient = client ?: run {
safeResume(emptyList())
return@suspendCancellableCoroutine
}
billingClient.queryPurchasesAsync(params) { result, purchaseList ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
safeResume(purchaseList.map { it.toPurchase(productType, null) })
} else {
safeResume(emptyList())
}
}
}
After fixing queryPurchases, it would be worth auditing other Play Billing async wrappers that use the same isActive-then-resume pattern, especially around queryProductDetailsAsync, consumeAsync, and acknowledgePurchase.
Environment
react-native-iap 15.2.4
openiap-google 2.1.5
- Google Play Billing Library 8.3.0
- Android production crash observed via Crashlytics
Summary
openiap-googlecan crash intermittently inHelpers.queryPurchaseswithIllegalStateException: Already resumed.Affected releases observed:
react-native-iap15.2.4, which shipped withopeniap-google2.1.5react-native-iap15.3.0 /openiap-google2.2.0 appear to keep the samequeryPurchasesimplementationCrash
Current Implementation
The Play implementation currently checks
continuation.isActivebefore callingresume, but the check and the resume are not atomic:billingClient.queryPurchasesAsync(params) { result, purchaseList -> if (!continuation.isActive) return@queryPurchasesAsync if (result.responseCode == BillingClient.BillingResponseCode.OK) { val mapped = purchaseList.map { it.toPurchase(productType, null) } continuation.resume(mapped) } else { continuation.resume(emptyList()) } }Relevant path:
Root Cause
If Play Billing invokes the callback more than once, or if two callback paths race on different threads, both callbacks can observe
continuation.isActive == truebefore either call toresumewins. The secondresumethen crashes withIllegalStateException: Already resumed.This is related to the class of issue addressed in #89 and #95. Those fixes added
isActiveguards and made the purchase callback slot single-shot, butqueryPurchasesstill has a check-then-resume race.Suggested Fix
Use an atomic single-resume guard around
queryPurchasescallback completion. For example:After fixing
queryPurchases, it would be worth auditing other Play Billing async wrappers that use the sameisActive-then-resumepattern, especially aroundqueryProductDetailsAsync,consumeAsync, andacknowledgePurchase.Environment
react-native-iap15.2.4openiap-google2.1.5