Skip to content

2.7.6#378

Merged
ianrumac merged 2 commits intomainfrom
develop
Mar 9, 2026
Merged

2.7.6#378
ianrumac merged 2 commits intomainfrom
develop

Conversation

@ianrumac
Copy link
Collaborator

@ianrumac ianrumac commented Mar 9, 2026

Changes in this pull request

  • Improve billing resillience and recovery
  • Fix concurrency issue with product loads on early starts
  • Fix concurrency issue with identity manager causing locks
  • Fix bug with params not being templated into second presentation

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run ktlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Greptile Summary

This PR improves billing resilience and fixes several concurrency and lifecycle bugs. The paywall activity fix is correct and an improvement, but the StoreManager concurrency change introduces a subtle but critical correctness regression.

Key changes:

  • SuperwallPaywallActivity.kt – The scrim tap-outside handler in the bottom-sheet layout now calls paywallView()?.dismiss(PaywallResult.Declined(), ManualClose) instead of bare finish(), ensuring proper delegate callbacks and paywall result propagation on manual dismissal. This is a clean and correct fix.
  • StoreManager.kt – Replaces ConcurrentHashMap.replace(id, state, newState) (an atomic CAS) with synchronized(productsByFullId) { … }. This is problematic: productsByFullId is a ConcurrentHashMap, and the synchronized block uses the object's intrinsic monitor lock, which is not held by any other accessor (cacheProduct, fetchNewProducts, getOrPut, etc.). As a result, the synchronization is one-sided and does not prevent concurrent modifications — e.g., a concurrent cacheProduct call can overwrite the map entry between the read and write inside the synchronized block, potentially turning a freshly cached Loaded state back into Loading. The original atomic CAS via replace(key, expected, update) was the correct pattern for ConcurrentHashMap and should be restored.

Confidence Score: 2/5

  • Not safe to merge as-is due to a concurrency regression in StoreManager that can cause product state corruption under concurrent load.
  • The paywall activity change is sound, but the StoreManager change replaces a correct atomic CAS with a synchronized block that is not honored by any other mutator in the class. This makes the "fix" a no-op in terms of thread safety while also introducing the possibility of overwriting a Loaded product state with a new Loading state, breaking downstream product fetching.
  • Pay close attention to StoreManager.kt — specifically the interaction between the new synchronized block and the unsynchronized writes in cacheProduct and fetchNewProducts.

Important Files Changed

Filename Overview
superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt Replaces an atomic CAS (ConcurrentHashMap.replace) with a synchronized block that is not used by any other accessor, making the "fix" ineffective and potentially introducing a new race condition where a Loading state can overwrite a Loaded state.
superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt Correctly replaces the bare finish() scrim-tap handler in the bottom-sheet layout with a proper paywallView()?.dismiss(PaywallResult.Declined(), ManualClose) call, ensuring delegate callbacks are triggered on manual outside-tap dismissal.

Sequence Diagram

sequenceDiagram
    participant CA as Coroutine A (fetchOrAwaitProducts)
    participant CB as Coroutine B (cacheProduct)
    participant Map as productsByFullId (ConcurrentHashMap)

    CA->>Map: getOrPut(id) → ProductState.Error
    Note over CA: enters synchronized(productsByFullId)
    CA->>Map: read: productsByFullId[id] is Error → true
    CB->>Map: productsByFullId[id] = Loaded(product) ← no lock held!
    CA->>Map: productsByFullId[id] = Loading(deferred) ← overwrites Loaded!
    Note over CA: newDeferreds[id] = deferred
    CA->>Map: (later) fetchNewProducts → triggers a new billing fetch for already-loaded product
    Note over Map: Product state corrupted: Loaded → Loading → re-fetched unnecessarily
Loading

Last reviewed commit: 8dfcbd4

Greptile also left 1 inline comment on this PR.

@ianrumac ianrumac merged commit 27a5ddc into main Mar 9, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant