From b921a5390f7505be34d2b9259cd8862659187a95 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 14 Apr 2026 18:32:28 +0900 Subject: [PATCH 1/3] fix(google): prevent double-resume crash in purchase flow Replace the mutable `currentPurchaseCallback` var with an `AtomicReference` and route every invocation through a new `consumePurchaseCallback()` helper that atomically swaps the slot to null before invoking the callback. This guarantees the underlying `suspendCancellableCoroutine` continuation can be resumed at most once, even if Google Play Billing fires `onPurchasesUpdated` multiple times, races with an early-return path, or re-entrantly dispatches another event while the first invocation is still in flight. Also clears the slot on continuation cancellation to avoid leaking a stale callback that could be invoked by a late billing event after the request was cancelled. Applied symmetrically to both Play and Horizon variants. Closes #94 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/dev/hyo/openiap/OpenIapModule.kt | 43 +++++++++++-------- .../java/dev/hyo/openiap/OpenIapModule.kt | 41 +++++++++++------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index dc6f8da0..6bc70a8d 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -97,7 +98,16 @@ class OpenIapModule( private var billingClient: BillingClient? = null private var currentActivityRef: WeakReference? = null - private var currentPurchaseCallback: ((Result>) -> Unit)? = null + private val currentPurchaseCallback = AtomicReference<((Result>) -> Unit)?>(null) + + /** + * Atomically consume the pending purchase callback so the underlying + * continuation cannot be resumed twice if Horizon Billing fires + * `onPurchasesUpdated` multiple times or races with an early-return path. + */ + private fun consumePurchaseCallback(result: Result>) { + currentPurchaseCallback.getAndSet(null)?.invoke(result) + } private val productManager = ProductManager() private val fallbackActivity: Activity? = if (context is Activity) context else null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -370,9 +380,10 @@ class OpenIapModule( } suspendCancellableCoroutine> { continuation -> - currentPurchaseCallback = { result -> + currentPurchaseCallback.set { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } + continuation.invokeOnCancellation { currentPurchaseCallback.set(null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP @@ -421,7 +432,7 @@ class OpenIapModule( OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) val err = OpenIapError.SkuOfferMismatch purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -437,7 +448,7 @@ class OpenIapModule( OpenIapLog.w("Invalid empty offerToken provided for ${productDetails.productId}", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -502,7 +513,7 @@ class OpenIapModule( else -> OpenIapError.PurchaseFailed } purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } else { // CRITICAL FIX: Proactively query purchases in case onPurchasesUpdated doesn't fire // Horizon SDK may not always trigger the callback, so we query after a delay @@ -524,7 +535,7 @@ class OpenIapModule( runCatching { listener.onPurchaseUpdated(purchase) } } } - currentPurchaseCallback?.invoke(Result.success(filtered)) + consumePurchaseCallback(Result.success(filtered)) } } catch (e: Exception) { OpenIapLog.e("Error in proactive purchase query", e, TAG) @@ -540,7 +551,7 @@ class OpenIapModule( val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } val err = OpenIapError.SkuNotFound(missingSku ?: "") purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@suspendCancellableCoroutine } buildAndLaunch(ordered) @@ -560,7 +571,7 @@ class OpenIapModule( if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { val err = OpenIapError.QueryProduct purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync } @@ -578,7 +589,7 @@ class OpenIapModule( } val err = OpenIapError.SkuNotFound(missingSku ?: "") purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync } @@ -856,26 +867,20 @@ class OpenIapModule( } OpenIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG) - currentPurchaseCallback?.let { cb -> - currentPurchaseCallback = null - cb.invoke(Result.success(mapped)) - } + consumePurchaseCallback(Result.success(mapped)) OpenIapLog.i("Purchase callback invoked", TAG) } else { // Purchases is null - likely DEFERRED mode OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG) - currentPurchaseCallback?.let { cb -> - currentPurchaseCallback = null - cb.invoke(Result.success(emptyList())) - } + consumePurchaseCallback(Result.success(emptyList())) } } else { OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG) val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage) purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } - currentPurchaseCallback = null + currentPurchaseCallback.set(null) OpenIapLog.i("=== END onPurchasesUpdated ===", TAG) } catch (e: Exception) { OpenIapLog.e("Exception in onPurchasesUpdated", e, TAG) diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 51fed5da..323d22b5 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicReference // AlternativeBillingMode moved to main source set (shared between Play and Horizon) @@ -109,7 +110,16 @@ class OpenIapModule( private val purchaseErrorListeners = mutableSetOf() private val userChoiceBillingListeners = mutableSetOf() private val developerProvidedBillingListeners = mutableSetOf() - private var currentPurchaseCallback: ((Result>) -> Unit)? = null + private val currentPurchaseCallback = AtomicReference<((Result>) -> Unit)?>(null) + + /** + * Atomically consume the pending purchase callback. Ensures the underlying + * continuation is resumed at most once even if Google Play Billing fires + * `onPurchasesUpdated` multiple times or races with an early-return path. + */ + private fun consumePurchaseCallback(result: Result>) { + currentPurchaseCallback.getAndSet(null)?.invoke(result) + } // Billing programs enabled via enableBillingProgram (8.2.0+, EXTERNAL_PAYMENTS in 8.3.0+) private val enabledBillingPrograms = mutableSetOf() @@ -850,9 +860,10 @@ class OpenIapModule( } suspendCancellableCoroutine> { continuation -> - currentPurchaseCallback = { result -> + currentPurchaseCallback.set { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } + continuation.invokeOnCancellation { currentPurchaseCallback.set(null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP @@ -878,7 +889,7 @@ class OpenIapModule( ) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -915,7 +926,7 @@ class OpenIapModule( OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -942,7 +953,7 @@ class OpenIapModule( OpenIapLog.w("No one-time purchase offers available for ${productDetails.productId}, but offerToken was provided: ${androidArgs.offerToken}", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -950,7 +961,7 @@ class OpenIapModule( OpenIapLog.w("Invalid one-time offer token: ${androidArgs.offerToken} not in $availableTokens", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -1034,7 +1045,7 @@ class OpenIapModule( else -> OpenIapError.PurchaseFailed } for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } @@ -1044,7 +1055,7 @@ class OpenIapModule( val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } val err = OpenIapError.SkuNotFound(missingSku ?: "") for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@suspendCancellableCoroutine } buildAndLaunch(ordered) @@ -1070,14 +1081,14 @@ class OpenIapModule( val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } val err = OpenIapError.SkuNotFound(missingSku ?: "") for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync } buildAndLaunch(ordered) } else { val err = OpenIapError.QueryProduct for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } } @@ -1334,18 +1345,18 @@ class OpenIapModule( runCatching { listener.onPurchaseUpdated(converted) } } } - currentPurchaseCallback?.invoke(Result.success(mapped)) + consumePurchaseCallback(Result.success(mapped)) } else { // Purchases is null - likely DEFERRED mode OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG) - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } else { when (billingResult.responseCode) { BillingClient.BillingResponseCode.USER_CANCELED -> { val err = OpenIapError.UserCancelled for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } else -> { val error = OpenIapError.fromBillingResponseCode( @@ -1354,11 +1365,11 @@ class OpenIapModule( ) OpenIapLog.w("Purchase failed: code=${billingResult.responseCode} msg=${error.message}", TAG) for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(error) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } } - currentPurchaseCallback = null + currentPurchaseCallback.set(null) } private fun buildBillingClient() { From ace0915c742bbdd0d7d25f37cf031f0660b6a254 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 14 Apr 2026 19:26:25 +0900 Subject: [PATCH 2/3] fix(google): use compareAndSet on cancellation to preserve newer callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind the callback to a local val so invokeOnCancellation can compareAndSet(callback, null) — this prevents an older cancelled request from accidentally nulling out a newer request's callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/resolve-issue.md | 12 ++++++++++++ .../horizon/java/dev/hyo/openiap/OpenIapModule.kt | 5 +++-- .../src/play/java/dev/hyo/openiap/OpenIapModule.kt | 5 +++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.claude/commands/resolve-issue.md b/.claude/commands/resolve-issue.md index c43febb1..4e80db13 100644 --- a/.claude/commands/resolve-issue.md +++ b/.claude/commands/resolve-issue.md @@ -130,6 +130,18 @@ EOF )" ``` +#### 4e. Add labels to the PR + +Mirror the same labels you applied to the issue onto the PR so the dashboard views stay consistent. Use the same label selection guide from Step 2. + +> **Note:** `gh pr edit --add-label` may fail with a `Projects (classic)` GraphQL error on this repo. Use the REST API directly instead (works reliably since PRs are issues on GitHub): + +```bash +gh api -X POST repos/hyodotdev/openiap/issues//labels \ + -f "labels[]=" \ + -f "labels[]=" +``` + ### 5. Comment on the Issue Always comment on the issue with your findings: diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 6bc70a8d..1e978b70 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -380,10 +380,11 @@ class OpenIapModule( } suspendCancellableCoroutine> { continuation -> - currentPurchaseCallback.set { result -> + val callback: (Result>) -> Unit = { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } - continuation.invokeOnCancellation { currentPurchaseCallback.set(null) } + currentPurchaseCallback.set(callback) + continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 323d22b5..bc987452 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -860,10 +860,11 @@ class OpenIapModule( } suspendCancellableCoroutine> { continuation -> - currentPurchaseCallback.set { result -> + val callback: (Result>) -> Unit = { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } - continuation.invokeOnCancellation { currentPurchaseCallback.set(null) } + currentPurchaseCallback.set(callback) + continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP From 36b1b25d458c6c1ede20080951725e41438a1969 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 14 Apr 2026 20:23:01 +0900 Subject: [PATCH 3/3] fix(google): use compareAndSet on callback storage and drop redundant clears Address review feedback: - requestPurchase now uses currentPurchaseCallback.compareAndSet(null, callback) so a concurrent second invocation can no longer silently strand the earlier suspendCancellableCoroutine continuation. The losing call resumes with OpenIapError.DeveloperError instead of overwriting the in-flight slot. - Drop the redundant currentPurchaseCallback.set(null) tail in onPurchasesUpdated for both flavors. consumePurchaseCallback already getAndSet(null)s atomically in every branch, and the trailing clear lived inside the try block so it would not run on exception anyway. - Fix horizon onPurchasesUpdated indentation around the success-path callback invocation lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/horizon/java/dev/hyo/openiap/OpenIapModule.kt | 11 +++++++---- .../src/play/java/dev/hyo/openiap/OpenIapModule.kt | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 1e978b70..70c3eccc 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -383,7 +383,11 @@ class OpenIapModule( val callback: (Result>) -> Unit = { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } - currentPurchaseCallback.set(callback) + if (!currentPurchaseCallback.compareAndSet(null, callback)) { + OpenIapLog.w("requestPurchase rejected: another purchase is already in progress", TAG) + if (continuation.isActive) continuation.resumeWithException(OpenIapError.DeveloperError) + return@suspendCancellableCoroutine + } continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP @@ -868,8 +872,8 @@ class OpenIapModule( } OpenIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG) - consumePurchaseCallback(Result.success(mapped)) - OpenIapLog.i("Purchase callback invoked", TAG) + consumePurchaseCallback(Result.success(mapped)) + OpenIapLog.i("Purchase callback invoked", TAG) } else { // Purchases is null - likely DEFERRED mode OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG) @@ -881,7 +885,6 @@ class OpenIapModule( purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } } consumePurchaseCallback(Result.success(emptyList())) } - currentPurchaseCallback.set(null) OpenIapLog.i("=== END onPurchasesUpdated ===", TAG) } catch (e: Exception) { OpenIapLog.e("Exception in onPurchasesUpdated", e, TAG) diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index bc987452..fcf67f80 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -863,7 +863,11 @@ class OpenIapModule( val callback: (Result>) -> Unit = { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } - currentPurchaseCallback.set(callback) + if (!currentPurchaseCallback.compareAndSet(null, callback)) { + OpenIapLog.w("requestPurchase rejected: another purchase is already in progress", TAG) + if (continuation.isActive) continuation.resumeWithException(OpenIapError.DeveloperError) + return@suspendCancellableCoroutine + } continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP @@ -1370,7 +1374,6 @@ class OpenIapModule( } } } - currentPurchaseCallback.set(null) } private fun buildBillingClient() {