Skip to content

fix: SUP3-118 defer consumePurchases when product types unknown#797

Merged
NickSxti merged 1 commit into
mainfrom
nch/sup3-118-fix-lifetime-consume-race-condition
Apr 20, 2026
Merged

fix: SUP3-118 defer consumePurchases when product types unknown#797
NickSxti merged 1 commit into
mainfrom
nch/sup3-118-fix-lifetime-consume-race-condition

Conversation

@NickSxti
Copy link
Copy Markdown
Contributor

@NickSxti NickSxti commented Apr 9, 2026

Summary

  • Fix race condition where restore() and handlePurchases() consume Lifetime purchases when called before launch() completes
  • getNonConsumableStoreIds() now returns null (not emptySet()) when products aren't loaded
  • Hybrid approach: consume immediately if products known (current behavior preserved), defer to API response onSuccess if not
  • No consume on API error when products unknown - purchase stays safe in Google Play

Root Cause

getNonConsumableStoreIds() returned emptySet() when launchResultCache.getActualProducts() was null (fresh install, session cleared by resetSessionCache()). This caused consumePurchases to treat ALL in-app purchases as consumable, permanently destroying Lifetime purchases via Google's consume().

Two unguarded call sites: restore() (line 419) and handlePurchases() (line 1032). Compare with handlePendingPurchases() which correctly guards with isLaunchingFinished, and launch onSuccess which correctly runs updateLaunchResult before consumePurchases.

Introduced by commit 1d4fd73 (DEV-589).

Evidence

ES logs show restore request arriving 686ms before init for the same client - confirming restore ran before launch completed.

Linear

SUP3-118

Test plan

  • New tests: restore with products not loaded defers consumePurchases to onSuccess
  • New tests: restore with products loaded calls consumePurchases immediately (current behavior)
  • New tests: restore with API error and products not loaded never calls consumePurchases
  • New tests: handlePurchases with products not loaded defers consumePurchases to onSuccess
  • Existing restore tests continue to pass
  • Existing handlePendingPurchases tests continue to pass

Generated with Claude Code

When restore() or handlePurchases() runs before launch() completes
(e.g., app calls restore immediately after SDK init on fresh install),
getNonConsumableStoreIds() returns emptySet() because the product cache
is not yet populated. This causes ALL in-app purchases to be consumed,
permanently destroying Lifetime purchases.

Fix: getNonConsumableStoreIds() now returns null (not emptySet()) when
products are unavailable. restore() and handlePurchases() use a hybrid
approach - consume immediately if products are known (preserving current
behavior), or defer to onSuccess callback where fresh product types from
the API response are available. If the API call fails and products are
still unknown, no consume happens - the purchase stays safe in Google
Play and will be processed on the next successful launch.

The launch onSuccess path (line 807) already followed this pattern
correctly (updateLaunchResult before consumePurchases).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@NickSxti NickSxti requested a review from SpertsyanKM April 9, 2026 15:15
Copy link
Copy Markdown
Contributor Author

@NickSxti NickSxti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review (LEVER)

Logic: Race in restore() and handlePurchases(): when called before launch() completes, getNonConsumableStoreIds() returned emptySet() (product cache empty), so consumePurchases treated all in-app purchases as consumable and permanently destroyed Lifetime purchases via Google consume(). Regressed in 1d4fd73 (DEV-589). Fix: return null when products aren't loaded; hybrid — consume immediately if loaded (preserves current behavior), defer to launch onSuccess otherwise; never consume on API error when unknown.

Edge cases:

  • Fresh install + restore before launch: products unknown → defer (was: consume Lifetime).
  • Products loaded + restore: consume immediately (unchanged).
  • API error + products unknown: no consume; purchase stays safe in Google Play, recovered on next launch.
  • Session cache cleared by resetSessionCache(): same path as fresh install, covered.
  • Matches the isLaunchingFinished guard pattern already used by handlePendingPurchases() and launch onSuccess.

Verification:

  • Tests planned for: restore + products-not-loaded → deferred; restore + products-loaded → immediate; restore + API error + products-not-loaded → no-op; handlePurchases + products-not-loaded → deferred.
  • Existing restore and handlePendingPurchases tests must stay green.
  • Repro evidence: ES logs show restore arriving 686ms before init for the same client.

Effects / blast radius:

  • Two call sites: restore() (~line 419) and handlePurchases() (~line 1032).
  • No public API change.
  • Behavior shift only for callers inside the pre-launch race window.

Risks / rollback:

  • Pre-fix worst case: irreversible Lifetime destruction. Post-fix worst case: short consume deferral.
  • Revert restores the bug; safe to roll forward.
  • Test boxes in the PR body are still unchecked - confirm all pass before merge.

@NickSxti NickSxti merged commit cf32d76 into main Apr 20, 2026
1 check passed
@NickSxti NickSxti deleted the nch/sup3-118-fix-lifetime-consume-race-condition branch April 20, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants