Skip to content

Commit 77d26ee

Browse files
hyochanclaude
andauthored
fix(google): prevent double-resume crash in purchase flow (#95)
## Summary - Replace the mutable `currentPurchaseCallback` var in both Play and Horizon `OpenIapModule` with an `AtomicReference`, and route every invocation through a new `consumePurchaseCallback()` helper that atomically swaps the slot to `null` before invoking — guaranteeing the underlying `suspendCancellableCoroutine` continuation can be resumed at most once. - Clear the callback slot on continuation cancellation so a late billing event can't resume a cancelled coroutine. Closes #94 ## Root cause `requestPurchase` stored the resume lambda in a shared mutable field and only nulled it out at the end of `onPurchasesUpdated`. Between the invocation and the clear: - `onPurchasesUpdated` could fire again (Play Billing is known to deliver duplicate updates), or - a re-entrant / racing callback could observe the still-set lambda and call it a second time, or - an early-return path inside `requestPurchase` could invoke the callback without nulling the slot, leaving it primed for a stale event later. The lambda's `if (continuation.isActive)` guard is not atomic with `resume()`, so two callers could both pass the check and both call `resume()` — producing the observed `IllegalStateException: Already resumed`. Making the slot single-shot via `AtomicReference.getAndSet(null)` eliminates the window entirely. ## Test plan - [x] `./gradlew :openiap:compilePlayDebugKotlin :openiap:compileHorizonDebugKotlin` passes - [ ] Verify a normal purchase flow still resolves (manual smoke on a debug build) - [ ] Verify rapid double-tap of `requestPurchase` no longer crashes 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Improved internal robustness of purchase callback handling for more reliable operation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d1c1253 commit 77d26ee

3 files changed

Lines changed: 71 additions & 35 deletions

File tree

.claude/commands/resolve-issue.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ EOF
130130
)"
131131
```
132132

133+
#### 4e. Add labels to the PR
134+
135+
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.
136+
137+
> **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):
138+
139+
```bash
140+
gh api -X POST repos/hyodotdev/openiap/issues/<PR_NUMBER>/labels \
141+
-f "labels[]=<label1>" \
142+
-f "labels[]=<label2>"
143+
```
144+
133145
### 5. Comment on the Issue
134146

135147
Always comment on the issue with your findings:

packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import kotlinx.coroutines.launch
4949
import kotlinx.coroutines.suspendCancellableCoroutine
5050
import kotlinx.coroutines.withContext
5151
import java.lang.ref.WeakReference
52+
import java.util.concurrent.atomic.AtomicReference
5253
import kotlin.coroutines.resume
5354
import kotlin.coroutines.resumeWithException
5455

@@ -97,7 +98,16 @@ class OpenIapModule(
9798

9899
private var billingClient: BillingClient? = null
99100
private var currentActivityRef: WeakReference<Activity>? = null
100-
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null
101+
private val currentPurchaseCallback = AtomicReference<((Result<List<Purchase>>) -> Unit)?>(null)
102+
103+
/**
104+
* Atomically consume the pending purchase callback so the underlying
105+
* continuation cannot be resumed twice if Horizon Billing fires
106+
* `onPurchasesUpdated` multiple times or races with an early-return path.
107+
*/
108+
private fun consumePurchaseCallback(result: Result<List<Purchase>>) {
109+
currentPurchaseCallback.getAndSet(null)?.invoke(result)
110+
}
101111
private val productManager = ProductManager()
102112
private val fallbackActivity: Activity? = if (context is Activity) context else null
103113
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -370,9 +380,15 @@ class OpenIapModule(
370380
}
371381

372382
suspendCancellableCoroutine<List<Purchase>> { continuation ->
373-
currentPurchaseCallback = { result ->
383+
val callback: (Result<List<Purchase>>) -> Unit = { result ->
374384
if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList()))
375385
}
386+
if (!currentPurchaseCallback.compareAndSet(null, callback)) {
387+
OpenIapLog.w("requestPurchase rejected: another purchase is already in progress", TAG)
388+
if (continuation.isActive) continuation.resumeWithException(OpenIapError.DeveloperError)
389+
return@suspendCancellableCoroutine
390+
}
391+
continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) }
376392

377393
val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
378394

@@ -421,7 +437,7 @@ class OpenIapModule(
421437
OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG)
422438
val err = OpenIapError.SkuOfferMismatch
423439
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
424-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
440+
consumePurchaseCallback(Result.success(emptyList()))
425441
return
426442
}
427443

@@ -437,7 +453,7 @@ class OpenIapModule(
437453
OpenIapLog.w("Invalid empty offerToken provided for ${productDetails.productId}", TAG)
438454
val err = OpenIapError.SkuOfferMismatch
439455
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
440-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
456+
consumePurchaseCallback(Result.success(emptyList()))
441457
return
442458
}
443459

@@ -502,7 +518,7 @@ class OpenIapModule(
502518
else -> OpenIapError.PurchaseFailed
503519
}
504520
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
505-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
521+
consumePurchaseCallback(Result.success(emptyList()))
506522
} else {
507523
// CRITICAL FIX: Proactively query purchases in case onPurchasesUpdated doesn't fire
508524
// Horizon SDK may not always trigger the callback, so we query after a delay
@@ -524,7 +540,7 @@ class OpenIapModule(
524540
runCatching { listener.onPurchaseUpdated(purchase) }
525541
}
526542
}
527-
currentPurchaseCallback?.invoke(Result.success(filtered))
543+
consumePurchaseCallback(Result.success(filtered))
528544
}
529545
} catch (e: Exception) {
530546
OpenIapLog.e("Error in proactive purchase query", e, TAG)
@@ -540,7 +556,7 @@ class OpenIapModule(
540556
val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) }
541557
val err = OpenIapError.SkuNotFound(missingSku ?: "")
542558
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
543-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
559+
consumePurchaseCallback(Result.success(emptyList()))
544560
return@suspendCancellableCoroutine
545561
}
546562
buildAndLaunch(ordered)
@@ -560,7 +576,7 @@ class OpenIapModule(
560576
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
561577
val err = OpenIapError.QueryProduct
562578
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
563-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
579+
consumePurchaseCallback(Result.success(emptyList()))
564580
return@queryProductDetailsAsync
565581
}
566582

@@ -578,7 +594,7 @@ class OpenIapModule(
578594
}
579595
val err = OpenIapError.SkuNotFound(missingSku ?: "")
580596
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
581-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
597+
consumePurchaseCallback(Result.success(emptyList()))
582598
return@queryProductDetailsAsync
583599
}
584600

@@ -856,26 +872,19 @@ class OpenIapModule(
856872
}
857873

858874
OpenIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG)
859-
currentPurchaseCallback?.let { cb ->
860-
currentPurchaseCallback = null
861-
cb.invoke(Result.success(mapped))
862-
}
863-
OpenIapLog.i("Purchase callback invoked", TAG)
875+
consumePurchaseCallback(Result.success(mapped))
876+
OpenIapLog.i("Purchase callback invoked", TAG)
864877
} else {
865878
// Purchases is null - likely DEFERRED mode
866879
OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
867-
currentPurchaseCallback?.let { cb ->
868-
currentPurchaseCallback = null
869-
cb.invoke(Result.success(emptyList()))
870-
}
880+
consumePurchaseCallback(Result.success(emptyList()))
871881
}
872882
} else {
873883
OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG)
874884
val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage)
875885
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } }
876-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
886+
consumePurchaseCallback(Result.success(emptyList()))
877887
}
878-
currentPurchaseCallback = null
879888
OpenIapLog.i("=== END onPurchasesUpdated ===", TAG)
880889
} catch (e: Exception) {
881890
OpenIapLog.e("Exception in onPurchasesUpdated", e, TAG)

packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import kotlinx.coroutines.withContext
7171
import kotlin.coroutines.resume
7272
import kotlin.coroutines.resumeWithException
7373
import java.lang.ref.WeakReference
74+
import java.util.concurrent.atomic.AtomicReference
7475

7576
// AlternativeBillingMode moved to main source set (shared between Play and Horizon)
7677

@@ -109,7 +110,16 @@ class OpenIapModule(
109110
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
110111
private val userChoiceBillingListeners = mutableSetOf<OpenIapUserChoiceBillingListener>()
111112
private val developerProvidedBillingListeners = mutableSetOf<OpenIapDeveloperProvidedBillingListener>()
112-
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null
113+
private val currentPurchaseCallback = AtomicReference<((Result<List<Purchase>>) -> Unit)?>(null)
114+
115+
/**
116+
* Atomically consume the pending purchase callback. Ensures the underlying
117+
* continuation is resumed at most once even if Google Play Billing fires
118+
* `onPurchasesUpdated` multiple times or races with an early-return path.
119+
*/
120+
private fun consumePurchaseCallback(result: Result<List<Purchase>>) {
121+
currentPurchaseCallback.getAndSet(null)?.invoke(result)
122+
}
113123

114124
// Billing programs enabled via enableBillingProgram (8.2.0+, EXTERNAL_PAYMENTS in 8.3.0+)
115125
private val enabledBillingPrograms = mutableSetOf<BillingProgramAndroid>()
@@ -850,9 +860,15 @@ class OpenIapModule(
850860
}
851861

852862
suspendCancellableCoroutine<List<Purchase>> { continuation ->
853-
currentPurchaseCallback = { result ->
863+
val callback: (Result<List<Purchase>>) -> Unit = { result ->
854864
if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList()))
855865
}
866+
if (!currentPurchaseCallback.compareAndSet(null, callback)) {
867+
OpenIapLog.w("requestPurchase rejected: another purchase is already in progress", TAG)
868+
if (continuation.isActive) continuation.resumeWithException(OpenIapError.DeveloperError)
869+
return@suspendCancellableCoroutine
870+
}
871+
continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) }
856872

857873
val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
858874

@@ -878,7 +894,7 @@ class OpenIapModule(
878894
)
879895
val err = OpenIapError.SkuOfferMismatch
880896
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
881-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
897+
consumePurchaseCallback(Result.success(emptyList()))
882898
return
883899
}
884900

@@ -915,7 +931,7 @@ class OpenIapModule(
915931
OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG)
916932
val err = OpenIapError.SkuOfferMismatch
917933
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
918-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
934+
consumePurchaseCallback(Result.success(emptyList()))
919935
return
920936
}
921937

@@ -942,15 +958,15 @@ class OpenIapModule(
942958
OpenIapLog.w("No one-time purchase offers available for ${productDetails.productId}, but offerToken was provided: ${androidArgs.offerToken}", TAG)
943959
val err = OpenIapError.SkuOfferMismatch
944960
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
945-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
961+
consumePurchaseCallback(Result.success(emptyList()))
946962
return
947963
}
948964

949965
if (!availableTokens.contains(androidArgs.offerToken)) {
950966
OpenIapLog.w("Invalid one-time offer token: ${androidArgs.offerToken} not in $availableTokens", TAG)
951967
val err = OpenIapError.SkuOfferMismatch
952968
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
953-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
969+
consumePurchaseCallback(Result.success(emptyList()))
954970
return
955971
}
956972

@@ -1034,7 +1050,7 @@ class OpenIapModule(
10341050
else -> OpenIapError.PurchaseFailed
10351051
}
10361052
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1037-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1053+
consumePurchaseCallback(Result.success(emptyList()))
10381054
}
10391055
}
10401056

@@ -1044,7 +1060,7 @@ class OpenIapModule(
10441060
val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) }
10451061
val err = OpenIapError.SkuNotFound(missingSku ?: "")
10461062
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1047-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1063+
consumePurchaseCallback(Result.success(emptyList()))
10481064
return@suspendCancellableCoroutine
10491065
}
10501066
buildAndLaunch(ordered)
@@ -1070,14 +1086,14 @@ class OpenIapModule(
10701086
val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) }
10711087
val err = OpenIapError.SkuNotFound(missingSku ?: "")
10721088
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1073-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1089+
consumePurchaseCallback(Result.success(emptyList()))
10741090
return@queryProductDetailsAsync
10751091
}
10761092
buildAndLaunch(ordered)
10771093
} else {
10781094
val err = OpenIapError.QueryProduct
10791095
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1080-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1096+
consumePurchaseCallback(Result.success(emptyList()))
10811097
}
10821098
}
10831099
}
@@ -1334,18 +1350,18 @@ class OpenIapModule(
13341350
runCatching { listener.onPurchaseUpdated(converted) }
13351351
}
13361352
}
1337-
currentPurchaseCallback?.invoke(Result.success(mapped))
1353+
consumePurchaseCallback(Result.success(mapped))
13381354
} else {
13391355
// Purchases is null - likely DEFERRED mode
13401356
OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
1341-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1357+
consumePurchaseCallback(Result.success(emptyList()))
13421358
}
13431359
} else {
13441360
when (billingResult.responseCode) {
13451361
BillingClient.BillingResponseCode.USER_CANCELED -> {
13461362
val err = OpenIapError.UserCancelled
13471363
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1348-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1364+
consumePurchaseCallback(Result.success(emptyList()))
13491365
}
13501366
else -> {
13511367
val error = OpenIapError.fromBillingResponseCode(
@@ -1354,11 +1370,10 @@ class OpenIapModule(
13541370
)
13551371
OpenIapLog.w("Purchase failed: code=${billingResult.responseCode} msg=${error.message}", TAG)
13561372
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(error) } }
1357-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
1373+
consumePurchaseCallback(Result.success(emptyList()))
13581374
}
13591375
}
13601376
}
1361-
currentPurchaseCallback = null
13621377
}
13631378

13641379
private fun buildBillingClient() {

0 commit comments

Comments
 (0)