Skip to content

Conversation

@JZDesign
Copy link
Contributor

@JZDesign JZDesign commented Nov 24, 2025

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

resolves: #5729
resolves: #4137

Description

Modifies the "Atomic" class to accept an injected Lock, allowing the call site to include a recursive lock. Also checks if the thread is main and circumvents the lock when this is the case.

Big thanks to @denrase & @nguyenhuy for digging into this and even writing tests and solutions to look into.

@denrase has a PR here that removes atomic. I personally am not opposed to that. But I want other members of the team to be a part of that conversation.

Note

I am wanting to remove the use of Atomic, and have started doing that locally, but I can lock up UserDefaults on concurrent writes without the atomic wrapper. Concurrent reads is working just fine. That gives me pause. I haven't gotten to the bottom of it yet. But it only happens when I run my whole test class, not the individual tests.

Discussion

I believe that we should remove the lock around the UserDefaults. The one risk I see with that approach however is with concurrent writes potentially locking things up. I think that is a relatively low risk in comparison to what we are currently experiencing with an easily reproducible deadlock on read, considering that we do more reading than writing. This PR, as it currently sits works but seems like a duct tape patch for the underlying issue. We are simply checking if we're on the main thread and ignoring the lock in that case. We added a recursive lock, but it's actually of no benefit. If I'm remembering correctly, my testing of the concurrent reads and writes succeeded without the recursive lock. I can check again to be sure. But in the end, we either have the lock, with a main thread check on read and ensure sequential reads and writes with no deadlocks. Or no lock at all and can potentially get a locked up thread on writes.

Now… there is probably another 3rd option I haven't found yet. But these are the 2 I am currently aware of, and their tradeoffs.

@RevenueCat-Danger-Bot
Copy link

1 Error
🚫 Label the PR using one of the change type labels. If you are not sure which label to use, choose pr:other.
Label Description
pr:feat A new feature. Use along with pr:breaking to force a major release.
pr:fix A bug fix. Use along with pr:force_minor to force a minor release.
pr:other Other changes. Catch-all for anything that doesn't fit the above categories. Releases that only contain this label will not be released. Use along with pr:force_patch, or pr:force_minor to force a patch or minor release.
pr:RevenueCatUI Use along any other tag to mark a PR that only contains RevenueCatUI changes
pr:next_release Preparing a new release
pr:dependencies Updating a dependency
pr:phc_dependencies Updating purchases-hybrid-common dependency
pr:changelog_ignore The PR will not be included in the changelog. This label doesn't determine the type of bump of the version and must be combined with pr:feat, pr:fix or pr:other.

Generated by 🚫 Danger

@emerge-tools
Copy link

emerge-tools bot commented Nov 24, 2025

📸 Snapshot Test

241 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
RevenueCat
com.revenuecat.PaywallsTester
0 0 0 0 241 0 N/A

🛸 Powered by Emerge Tools

Copy link
Contributor

@vegaro vegaro left a comment

Choose a reason for hiding this comment

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

I think this makes sense and should fix the issue. I don't have a lot of experience with recursive NSLocks, but I don't think there's any con on making the change right?

@JZDesign JZDesign closed this Nov 25, 2025
@JZDesign JZDesign deleted the jzdesign/re-entrant-lock-in-device-cache branch November 25, 2025 17:30
@nguyenhuy
Copy link
Contributor

There are actually cons with turning a lock into recursive. The biggest one is that one can easily lose control of the lock and "leak" it everywhere. This is a big problem that we have had for a long time in AsyncDisplayKit/Texture.

Go lang doesn't even have a built-in recursive lock out of principle.

@nguyenhuy
Copy link
Contributor

nguyenhuy commented Dec 6, 2025

Sorry for crashing the party. I stumbled upon this while checking #5729 and the progress on #4137 (which I spent some time investigating, reproduced, and proposed next steps).

@nguyenhuy
Copy link
Contributor

nguyenhuy commented Dec 6, 2025

And btw, this fix (i.e making the lock recursive) doesn't work because re-entrant is not the root cause here. The root cause, as described here, involves 2 threads, a lock and a _pthread_cond_wait.

@JZDesign JZDesign restored the jzdesign/re-entrant-lock-in-device-cache branch December 8, 2025 19:39
Introduces comprehensive unit tests for SynchronizedUserDefaults, covering deadlock scenarios, basic read/write operations, re-entrant and concurrent access. Also updates Atomic.swift to avoid deadlock by executing actions directly on the main thread.
@JZDesign JZDesign reopened this Dec 8, 2025
@JZDesign JZDesign changed the title Use a RecursiveLock to prevent deadlocks Prevent deadlocks in SynchronizedUserDefaults Dec 8, 2025
Comment on lines 39 to 43
// It didn't become unnecessary until iOS 12 and macOS 10.14 (Mojave):
// https://developer.apple.com/documentation/macos-release-notes/foundation-release-notes
// there are reports it is still needed if you save to defaults then immediately kill the app.
// Also, it has not been marked deprecated... yet.
$0.synchronize()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure I agree that this decision is still necessary.

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.

🐛 Deadlock with RC Paywalls Restore Main Thread Locking

5 participants