🐛 Deadlock with RC Paywalls Restore
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
- Present RC paywall on fresh app install
- Press restore button
- App freez
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.
👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!
@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!
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 {
...
}
}
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.
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.
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
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.
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?
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.
Okay. Then we likely have an issue with the user defaults cache that is in our SDK. I'll continue to look into it
@denrase can you confirm if you are on a beta version of iOS 26 or the stable version?
@JZDesign This was tested on a iPhone 16 Pro with iOS 26.1 stable version.
@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 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.
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.
@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.
I appreciate how thorough you've been, and sorry for the hassle. We'll get it squared away soon.
@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 I reopened that branch with most of your solution and a bunch of tests.