Skip to content

Android crash in Helpers.queryPurchases: Already resumed race #158

Description

@tominou

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions