purchases-ios icon indicating copy to clipboard operation
purchases-ios copied to clipboard

🐛 Deadlock with RC Paywalls Restore

Open denrase opened this issue 1 month ago • 20 comments

Describe the bug

When pressing the restore button of a RC paywall, the app freezes.

Platform

iOS

SDK version

5.45.1

SDK integration method

Swift Package Manager

StoreKit version

StoreKit 2 (default on versions >=5.0.0)

OS version

iOS 26

Xcode version

Xcode 26

Device and/or simulator

Device

Environment

Sandbox

How widespread is the issue

100%

Debug logs

DEBUG: Restoring purchases
DEBUG: Will execute restore purchases logic provided by RevenueCat.
DEBUG: ℹ️ Found 0 unsynced attributes for App User ID: $RCAnonymousID:6b034102d4ff4a7fb4e41e0dd147605a
DEBUG: ℹ️ Skipping products request for these products because they were already cached: ["cc.cashcounter.apple.pro.sub.weekly.2"]
DEBUG: ℹ️ PostReceiptDataOperation: Started
DEBUG: ℹ️ PostReceiptDataOperation: Posting JWS token (source: 'restore'):
DEBUG: ℹ️ There are no requests currently running, starting request POST /v1/receipts
DEBUG: ℹ️ API request started: POST '/v1/receipts'
DEBUG: ℹ️ API request completed: POST '/v1/receipts' (200)

Steps to reproduce

  1. Present RC paywall on fresh app install
  2. Press restore button
  3. App freez
Image

Other information

Since this is happening 100%, this makes the Paywall feature unusable in the current form. Looking through the repo, there seems to be a [deadlocking issue](https://github.com/RevenueCat/purchases-ios/issues/4137) which this might relate to.


Thread 1 Queue : com.apple.main-thread (serial)
#0	0x000000023fcd40b4 in __psynch_mutexwait ()
#1	0x00000001f295bc94 in _pthread_mutex_firstfit_lock_wait ()
#2	0x00000001f295acac in _pthread_mutex_firstfit_lock_slow ()
#3	0x0000000108648960 in Lock.perform<Foundation.Data?>(_:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/Sources/Misc/Concurrency/Lock.swift:41
#4	0x00000001086476a8 in Atomic.withValue<RevenueCat.CustomerInfoManager.Data>(_:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/Sources/Misc/Concurrency/Atomic.swift:81
#5	0x0000000108593754 in CustomerInfoManager.withData<Foundation.Data?>(_:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/Sources/Identity/CustomerInfoManager.swift:674
#6	0x0000000108593c44 in CustomerInfoManager.cachedCustomerInfo(appUserID:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/Sources/Identity/CustomerInfoManager.swift:241
#7	0x00000001089d6e2c in Purchases.cachedCustomerInfo.getter at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/Sources/Purchasing/Purchases/Purchases.swift:1084
#8	0x0000000108e5a76c in static PaywallView.loadCachedCustomerInfoIfPossible() at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/RevenueCatUI/PaywallView.swift:405
#9	0x0000000108e59a40 in PaywallView.init(configuration:paywallViewOwnsPurchaseHandler:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/RevenueCatUI/PaywallView.swift:161
#10	0x0000000108e5a1b8 in PaywallView.init(offering:fonts:displayCloseButton:useDraftPaywall:introEligibility:performPurchase:performRestore:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/RevenueCatUI/PaywallView.swift:131
#11	0x0000000108e59eb8 in PaywallView.init(offering:fonts:displayCloseButton:performPurchase:performRestore:) at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/purchases-ios/RevenueCatUI/PaywallView.swift:109
#12	0x000000010849b588 in closure #1 in ProScreen.body.getter at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/base/Sources/BaseViews/Pro/ProScreen.swift:31
#13	0x00000001a19ad7c0 in closure #1 () -> τ_0_0 in SwiftUI.VStack.init(alignment: SwiftUI.HorizontalAlignment, spacing: Swift.Optional<CoreGraphics.CGFloat>, content: () -> τ_0_0) -> SwiftUI.VStack<τ_0_0> ()
#14	0x00000001a190e514 in __allocating_init ()
#15	0x00000001a19ad6f0 in __allocating_init ()
#16	0x000000010849a88c in ProScreen.body.getter at /Users/denis/Library/Developer/Xcode/DerivedData/CashCounter-erjadhymqflniahhdlkthhtkfuuv/SourcePackages/checkouts/base/Sources/BaseViews/Pro/ProScreen.swift:27
#17	0x000000010849ca1c in protocol witness for View.body.getter in conformance ProScreen ()
#18	0x00000001a18ebe40 in closure #1 @Swift.MainActor () -> () in SwiftUI.ViewBodyAccessor.updateBody(of: τ_0_0, changed: Swift.Bool) -> () ()
#19	0x00000001a18eb748 in updateBody ()
#20	0x00000001a18eb4e0 in protocol witness for SwiftUI.BodyAccessor.updateBody(of: τ_0_0.Container, changed: Swift.Bool) -> () in conformance SwiftUI.ViewBodyAccessor<τ_0_0> : SwiftUI.BodyAccessor in SwiftUI ()
#21	0x00000001a18de770 in closure #1 () -> () in SwiftUI.DynamicBody.updateValue() -> () ()
#22	0x00000001a18ddf6c in updateValue ()
#23	0x00000001a1e67504 in partial apply forwarder for implicit closure #1 (Swift.UnsafeMutableRawPointer, __C.AGAttribute) -> () in closure #1 () -> (Swift.UnsafeMutableRawPointer, __C.AGAttribute) -> () in closure #1 (Swift.UnsafePointer<τ_1_0>) -> AttributeGraph.Attribute<τ_0_0> in AttributeGraph.Attribute.init<τ_0_0 where τ_0_0 == τ_1_0.Value, τ_1_0: AttributeGraph.StatefulRule>(τ_1_0) -> AttributeGraph.Attribute<τ_0_0> ()
#24	0x00000001c8abee48 in AG::Graph::UpdateStack::update ()
#25	0x00000001c8ac0cf0 in AG::Subgraph::update ()
#26	0x00000001a1946c88 in merged function signature specialization <Arg[3] = Owned To Guaranteed> of function signature specialization <Arg[1] = [Closure Propagated : implicit closure #2 () -> () in implicit closure #1 @Sendable (SwiftUI.(AsyncTransaction in _F9F204BD2F8DB167A76F17F3FB1B3335)) -> () -> () in SwiftUI.GraphHost.flushTransactions() -> (), Argument Types : [SwiftUI.AsyncTransaction]> of SwiftUI.GraphHost.runTransaction(_: Swift.Optional<SwiftUI.Transaction>, do: () -> (), id: Swift.Optional<Swift.UInt32>) -> () ()
#27	0x00000001a18d7218 in flushTransactions ()
#28	0x00000001a0659270 in closure #1 (SwiftUI.GraphHost) -> () in SwiftUI._UIHostingView._renderForTest(interval: Swift.Double) -> () ()
#29	0x00000001a18d14bc in partial apply forwarder for closure #1 (SwiftUI.ViewGraph) -> τ_1_0 in SwiftUI.ViewGraphRootValueUpdater.updateGraph<τ_0_0>(body: (SwiftUI.GraphHost) -> τ_1_0) -> τ_1_0 ()
#30	0x00000001a18d3e98 in _updateViewGraph ()
#31	0x00000001a18d1414 in updateGraph ()
#32	0x00000001a065923c in closure #1 () -> () in closure #1 () -> () in closure #1 () -> () in SwiftUI._UIHostingView.beginTransaction() -> () ()
#33	0x00000001a0659188 in partial apply forwarder for closure #1 () -> () in closure #1 () -> () in closure #1 () -> () in SwiftUI._UIHostingView.beginTransaction() -> () ()
#34	0x00000001a18c99e4 in closure #1 () throws -> τ_0_0 in static SwiftUI.Update.ensure<τ_0_0>(() throws -> τ_0_0) throws -> τ_0_0 ()
#35	0x00000001a18c9c28 in static SwiftUI.Update.ensure<τ_0_0>(() throws -> τ_0_0) throws -> τ_0_0 ()
#36	0x00000001a0659160 in partial apply forwarder for closure #1 () -> () in closure #1 () -> () in SwiftUI._UIHostingView.beginTransaction() -> () ()
#37	0x000000019d403b44 in ___lldb_unnamed_symbol293584 ()
#38	0x000000019d914e4c in ___lldb_unnamed_symbol308985 ()
#39	0x000000019d41f3c8 in ___lldb_unnamed_symbol293684 ()
#40	0x00000001a18c274c in static SwiftUI.Update.dispatchImmediately<τ_0_0>(reason: Swift.Optional<SwiftUI.CustomEventTrace.ActionEventType.Reason>, _: () -> τ_0_0) -> τ_0_0 ()
#41	0x00000001a1e35038 in static SwiftUI.ViewGraphHostUpdate.dispatchImmediately<τ_0_0>(() -> τ_0_0) -> τ_0_0 ()
#42	0x000000019d472e40 in ___lldb_unnamed_symbol294125 ()
#43	0x000000019d472d24 in ___lldb_unnamed_symbol294123 ()
#44	0x000000019d480ee8 in _UIUpdateSequenceRunNext ()
#45	0x000000019d480378 in schedulerStepScheduledMainSectionContinue ()
#46	0x00000002821295f8 in UC::DriverCore::continueProcessing ()
#47	0x0000000197b77230 in __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ ()
#48	0x0000000197b771a4 in __CFRunLoopDoSource0 ()
#49	0x0000000197b54c6c in __CFRunLoopDoSources0 ()
#50	0x0000000197b2a8b0 in __CFRunLoopRun ()
#51	0x0000000197b29c44 in _CFRunLoopRunSpecificWithOptions ()
#52	0x0000000236f1a498 in GSEventRunModal ()
#53	0x000000019d4a4ddc in -[UIApplication _run] ()
#54	0x000000019d449b0c in UIApplicationMain ()
#55	0x00000001a05fe6f0 in closure #1 (Swift.UnsafeMutablePointer<Swift.Optional<Swift.UnsafeMutablePointer<Swift.Int8>>>) -> Swift.Never in SwiftUI.KitRendererCommon(Swift.AnyObject.Type) -> Swift.Never ()
#56	0x00000001a05fb22c in runApp ()
#57	0x00000001a05fad18 in static SwiftUI.App.main() -> () ()
#58	0x00000001083ca69c in static CashCounterApp.$main() ()
#59	0x00000001083ca774 in main ()
#60	0x0000000194ba2e28 in start ()

Additional context

Also, before presenting the paywall view, I load the offering and the user info, as it's the only way to reliably circumvent the presentation of the RC internal loading animation.

denrase avatar Oct 31 '25 10:10 denrase

👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!

RCGitBot avatar Oct 31 '25 10:10 RCGitBot

@denrase I tried to reproduce it locally but couldn't...

Since you say it's reproducible 100% of the times, do you think you can share some code with us? I suspect it could be related to the fact that you're loading offerings and user info before presenting the paywall. Have you tried removing those calls? Does it prevent it from deadlocking?

Thank you!

vegaro avatar Oct 31 '25 10:10 vegaro

Just removed the all calls and I'm now just presenting the paywall during onboarding, inline. Before i was waiting for offerings to load. Now i consistently get the deadlock, i see another thread that is also accessing customer info.

var body: some View {
    if !didCompleteOnboarding{
        PaywallView()
            .onRequestedDismissal {
                firstLaunch = true
                didCompleteOnboarding = true
            }
    } else {
        ...
    }
}
Image

denrase avatar Oct 31 '25 11:10 denrase

Tried to make a fresh app to reproduce the issue, but there I do not have the issue. Will try to pin down what the difference is.

denrase avatar Oct 31 '25 13:10 denrase

Unfortunately could not reproduce this in a separate app, only in one of my larger apps, but I'm happy to provide any info you might need.

denrase avatar Oct 31 '25 15:10 denrase

I've been looking into this without finding anything conclusive yet, but I've noticed we have different restore purchases button implementation... What type of paywall are you using @denrase? Is it v1, or v2? if it's v1, is it a Footer or a full paywall?

Thank you for your patience

vegaro avatar Nov 04 '25 17:11 vegaro

Hey @vegaro. I'm directly using the PaywallView() inline with SDK version 5.45.1, so fullscreen. Setup using these docs, so i guess these are V2.

denrase avatar Nov 06 '25 14:11 denrase

It looks like it's the device cache that may be the culprit in the stack trace. I just released a pretty major change to that area of the code. @denrase would you upgrade to the most recent version of the SDK and see if this is still occurring?

JZDesign avatar Nov 20 '25 01:11 JZDesign

It's a bit better, but the main issue still persists. While i could load the paywall now, the app is hanging after I make a purchase.

Image

denrase avatar Nov 20 '25 09:11 denrase

Okay. Then we likely have an issue with the user defaults cache that is in our SDK. I'll continue to look into it

JZDesign avatar Nov 20 '25 17:11 JZDesign

@denrase can you confirm if you are on a beta version of iOS 26 or the stable version?

JZDesign avatar Nov 20 '25 17:11 JZDesign

@JZDesign This was tested on a iPhone 16 Pro with iOS 26.1 stable version.

denrase avatar Nov 21 '25 17:11 denrase

@denrase I have a branch that may fix this for you. Would you test it out and let me know if it does?

Branch name jzdesign/re-entrant-lock-in-device-cache

And it's not in the purchases-ios-spm repo, it's in this one. So if you update the dependencies to point at a branch, it would need to point here.

JZDesign avatar Nov 24 '25 16:11 JZDesign

@JZDesign Looks like it's still happening. It has something to do with accessing user defaults from a notification, as you can see in the stacktrace. Happens when doing a purchase on the paywall.

Image Image

denrase avatar Nov 25 '25 16:11 denrase

Looks like the issue described here, with the solution described here. So replacing SynchronizedUserDefaults with regular UserDefaults, as it already is threa-safe, as decribed in the documentation.

The UserDefaults type is thread-safe, and you can use the same object in multiple threads or tasks simultaneously.

denrase avatar Nov 25 '25 16:11 denrase

@JZDesign Played around and this seems to be the issue.

  • Main thread acquires a lock on the cache
  • A background thread also acquires a lock, writes to the cache and therefore to user default
  • ~~User defaults posts a notification to main~~ -> Posted on same thread according to docs, at least the DidChangeNotification. Could be another on main though.
  • deadlock

I very naively skip the lock when reading from main. This resolved my issue, no more deadlock. ✅

purchases-ios fork: "FIX" deadlock

I did not think through all implications of this obviously, just wanted to narrow down this specific issue. Hope this helps.

denrase avatar Nov 25 '25 17:11 denrase

I appreciate how thorough you've been, and sorry for the hassle. We'll get it squared away soon.

JZDesign avatar Nov 25 '25 17:11 JZDesign

@JZDesign Any update on this? I'd really like to migrate to RC paywalls, but this is blocking me, as it's unusable for me.

denrase avatar Dec 08 '25 14:12 denrase

@denrase I reopened that branch with most of your solution and a bunch of tests.

JZDesign avatar Dec 08 '25 19:12 JZDesign

Hello there, this is a problem we were facing while building Splat and we had to work around it here. This was occurring on cold start ~50% of the time during the app's development until we worked around it. I'd recommend taking a look at @nguyenhuy's notes on the repro!

timonus avatar Dec 08 '25 21:12 timonus