Skip to content

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

@tominou

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions