Skip to content

Commit 9507472

Browse files
authored
Merge release/23.5 into trunk (#16266)
2 parents 26a1be1 + 705ac49 commit 9507472

38 files changed

+1302
-19473
lines changed

Modules/Sources/NetworkingCore/Network/AlamofireNetworkErrorHandler.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import Alamofire
44
/// Thread-safe handler for network error tracking and retry logic
55
final class AlamofireNetworkErrorHandler {
66
private let queue = DispatchQueue(label: "com.networkingcore.errorhandler", attributes: .concurrent)
7+
/// Serial queue for UserDefaults operations to prevent race conditions while avoiding deadlocks
8+
private let userDefaultsQueue = DispatchQueue(label: "com.networkingcore.errorhandler.userdefaults")
79
private let userDefaults: UserDefaults
810
private let credentials: Credentials?
911
private let notificationCenter: NotificationCenter
@@ -162,7 +164,11 @@ final class AlamofireNetworkErrorHandler {
162164
}
163165

164166
func flagSiteAsUnsupported(for siteID: Int64, flow: RequestFlow, cause: AppPasswordFlagCause, error: Error) {
165-
queue.sync(flags: .barrier) {
167+
// Use dedicated serial queue for UserDefaults operations to:
168+
// 1. Prevent race conditions where concurrent writes overwrite each other
169+
// 2. Avoid deadlock by not using the main queue that KVO observers may need
170+
userDefaultsQueue.sync { [weak self] in
171+
guard let self else { return }
166172
var currentList = userDefaults.applicationPasswordUnsupportedList
167173
currentList[String(siteID)] = Date()
168174
userDefaults.applicationPasswordUnsupportedList = currentList
@@ -233,11 +239,16 @@ private extension AlamofireNetworkErrorHandler {
233239
}
234240

235241
func clearUnsupportedFlag(for siteID: Int64) {
236-
queue.sync(flags: .barrier) {
242+
// Use dedicated serial queue for UserDefaults operations to:
243+
// 1. Prevent race conditions where concurrent writes overwrite each other
244+
// 2. Avoid deadlock by not using the main queue that KVO observers may need
245+
userDefaultsQueue.sync { [weak self] in
246+
guard let self else { return }
237247
let currentList = userDefaults.applicationPasswordUnsupportedList
238-
userDefaults.applicationPasswordUnsupportedList = currentList.filter { flag in
248+
let filteredList = currentList.filter { flag in
239249
flag.key != String(siteID)
240250
}
251+
userDefaults.applicationPasswordUnsupportedList = filteredList
241252
}
242253
}
243254

Modules/Tests/NetworkingTests/Network/AlamofireNetworkErrorHandlerTests.swift

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,109 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
606606
// Then - no crashes should occur (especially no EXC_BREAKPOINT from array index out of bounds)
607607
wait(for: [expectation], timeout: 5.0)
608608
}
609+
610+
// MARK: - Deadlock Regression Test
611+
612+
func test_no_deadlock_when_kvo_observer_triggers_during_flagSiteAsUnsupported() {
613+
// This test reproduces the exact deadlock scenario from the production crash:
614+
// 1. flagSiteAsUnsupported writes to UserDefaults
615+
// 2. UserDefaults triggers KVO notification synchronously
616+
// 3. KVO observer calls prepareAppPasswordSupport
617+
// 4. prepareAppPasswordSupport accesses appPasswordFailures
618+
//
619+
// BEFORE FIX: This would deadlock because:
620+
// - flagSiteAsUnsupported used queue.sync(flags: .barrier) around UserDefaults write
621+
// - KVO fired synchronously during the barrier
622+
// - prepareAppPasswordSupport tried queue.sync while barrier was still active
623+
//
624+
// AFTER FIX: No deadlock because:
625+
// - flagSiteAsUnsupported uses userDefaultsQueue.async for UserDefaults write
626+
// - KVO fires on userDefaultsQueue, not the main queue
627+
// - prepareAppPasswordSupport can safely use queue.sync on the main queue
628+
629+
// Given - Set up KVO observer to simulate the production scenario
630+
let siteID: Int64 = 12345
631+
let kvoTriggered = XCTestExpectation(description: "KVO observer triggered")
632+
let preparePasswordSupportCalled = XCTestExpectation(description: "prepareAppPasswordSupport called from KVO")
633+
let operationCompleted = XCTestExpectation(description: "Operation completed without deadlock")
634+
635+
var kvoObservation: NSKeyValueObservation?
636+
kvoObservation = userDefaults.observe(\.applicationPasswordUnsupportedList, options: [.new]) { [weak self] _, _ in
637+
kvoTriggered.fulfill()
638+
639+
// Simulate what happens in production:
640+
// AlamofireNetwork.observeSelectedSite gets triggered by KVO
641+
// and calls prepareAppPasswordSupport
642+
self?.errorHandler.prepareAppPasswordSupport(for: siteID)
643+
preparePasswordSupportCalled.fulfill()
644+
}
645+
646+
// When - Trigger the scenario that caused the deadlock
647+
DispatchQueue.global().async {
648+
// This will write to UserDefaults, triggering KVO
649+
self.errorHandler.flagSiteAsUnsupported(
650+
for: siteID,
651+
flow: .apiRequest,
652+
cause: .majorError,
653+
error: NetworkError.notFound(response: nil)
654+
)
655+
operationCompleted.fulfill()
656+
}
657+
658+
// Then - All expectations should complete without timing out (no deadlock)
659+
// The timeout of 2 seconds is generous - if there's a deadlock, this will timeout
660+
let result = XCTWaiter.wait(
661+
for: [kvoTriggered, preparePasswordSupportCalled, operationCompleted],
662+
timeout: 2.0,
663+
enforceOrder: false
664+
)
665+
666+
XCTAssertEqual(result, .completed, "Test should complete without deadlock. If this times out, the deadlock bug has returned!")
667+
668+
// Cleanup
669+
kvoObservation?.invalidate()
670+
}
671+
672+
func test_no_deadlock_with_concurrent_kvo_observers_and_flag_operations() {
673+
// This test creates even more stress by having multiple KVO observers
674+
// and concurrent flag operations to ensure the fix is robust
675+
676+
let completionExpectation = XCTestExpectation(description: "All operations complete")
677+
completionExpectation.expectedFulfillmentCount = 10 // 10 flag operations
678+
679+
var observations: [NSKeyValueObservation] = []
680+
681+
// Set up multiple KVO observers (simulating multiple parts of the app observing)
682+
for i in 1...3 {
683+
let observation = userDefaults.observe(\.applicationPasswordUnsupportedList, options: [.new]) { [weak self] _, _ in
684+
let siteID = Int64(1000 + i)
685+
// Each observer tries to access the error handler
686+
self?.errorHandler.prepareAppPasswordSupport(for: siteID)
687+
}
688+
observations.append(observation)
689+
}
690+
691+
// When - Perform multiple concurrent operations that trigger KVO
692+
for i in 0..<10 {
693+
DispatchQueue.global().async {
694+
let siteID = Int64(i)
695+
self.errorHandler.flagSiteAsUnsupported(
696+
for: siteID,
697+
flow: .apiRequest,
698+
cause: .majorError,
699+
error: NetworkError.notFound(response: nil)
700+
)
701+
completionExpectation.fulfill()
702+
}
703+
}
704+
705+
// Then - Should complete without deadlock
706+
let result = XCTWaiter.wait(for: [completionExpectation], timeout: 3.0)
707+
XCTAssertEqual(result, .completed, "Concurrent operations with multiple KVO observers should not deadlock")
708+
709+
// Cleanup
710+
observations.forEach { $0.invalidate() }
711+
}
609712
}
610713

611714
// MARK: - Helper Methods

WooCommerce/Classes/Authentication/SiteCredentialLoginUseCase.swift

Lines changed: 35 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,16 @@ enum SiteCredentialLoginError: LocalizedError {
100100
/// This use case handles site credential login without the need to use XMLRPC API.
101101
/// Steps for login:
102102
/// - Make a request to the site wp-login.php with a redirect to the nonce retrieval URL.
103-
/// - Upon redirect, cancel the request and verify if the redirect URL is the nonce retrieval URL.
104-
/// - If it is, make a request to retrieve nonce at that URL, the login succeeds if this is successful.
103+
/// - If the redirect succeeds with a nonce in the response, login is successful.
104+
/// - If the request does not redirect or the redirect fails, login fails.
105+
/// Ref: pe5sF9-1iQ-p2
105106
///
106107
final class SiteCredentialLoginUseCase: NSObject, SiteCredentialLoginProtocol {
107108
private let siteURL: String
108109
private let cookieJar: HTTPCookieStorage
109110
private var successHandler: (() -> Void)?
110111
private var errorHandler: ((SiteCredentialLoginError) -> Void)?
111-
private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
112+
private lazy var session = URLSession(configuration: .default)
112113

113114
init(siteURL: String,
114115
cookieJar: HTTPCookieStorage = HTTPCookieStorage.shared) {
@@ -134,10 +135,9 @@ final class SiteCredentialLoginUseCase: NSObject, SiteCredentialLoginProtocol {
134135
Task { @MainActor in
135136
do {
136137
try await startLogin(with: loginRequest)
138+
successHandler?()
137139
} catch let error as SiteCredentialLoginError {
138140
errorHandler?(error)
139-
} catch let nsError as NSError where nsError.domain == NSURLErrorDomain && nsError.code == -999 {
140-
/// login request is cancelled upon redirect, ignore this error
141141
} catch {
142142
errorHandler?(.genericFailure(underlyingError: error as NSError))
143143
}
@@ -160,10 +160,26 @@ private extension SiteCredentialLoginUseCase {
160160
throw SiteCredentialLoginError.invalidLoginResponse
161161
}
162162

163-
switch response.statusCode {
164-
case 404:
163+
/// The login request comes with a redirect header to nonce retrieval URL.
164+
/// If we get a response from this URL, that means the redirect is successful.
165+
/// We need to check the result of this redirect first to determine if login is successful.
166+
let isNonceUrl = response.url?.absoluteString.hasSuffix(Constants.wporgNoncePath) == true
167+
168+
switch (isNonceUrl, response.statusCode) {
169+
case (true, 200):
170+
if let nonceString = String(data: data, encoding: .utf8),
171+
nonceString.isValidNonce() {
172+
// success!
173+
return
174+
} else {
175+
throw SiteCredentialLoginError.invalidLoginResponse
176+
}
177+
case (true, 404):
178+
throw SiteCredentialLoginError.inaccessibleAdminPage
179+
case (false, 404):
165180
throw SiteCredentialLoginError.inaccessibleLoginPage
166-
case 200:
181+
case (false, 200):
182+
// 200 for the login URL, which means a failure
167183
guard let html = String(data: data, encoding: .utf8) else {
168184
throw SiteCredentialLoginError.invalidLoginResponse
169185
}
@@ -180,39 +196,6 @@ private extension SiteCredentialLoginUseCase {
180196
}
181197
}
182198

183-
@MainActor
184-
func checkRedirect(url: URL?) async {
185-
guard let url, url.absoluteString.hasSuffix(Constants.wporgNoncePath),
186-
let nonceRetrievalURL = URL(string: siteURL + Constants.adminPath + Constants.wporgNoncePath) else {
187-
errorHandler?(.invalidLoginResponse)
188-
return
189-
}
190-
do {
191-
let nonceRequest = try URLRequest(url: nonceRetrievalURL, method: .get)
192-
try await checkAdminPageAccess(with: nonceRequest)
193-
successHandler?()
194-
} catch let error as SiteCredentialLoginError {
195-
errorHandler?(error)
196-
} catch {
197-
errorHandler?(.genericFailure(underlyingError: error as NSError))
198-
}
199-
}
200-
201-
func checkAdminPageAccess(with nonceRequest: URLRequest) async throws {
202-
let (_, response) = try await session.data(for: nonceRequest)
203-
guard let response = response as? HTTPURLResponse else {
204-
throw SiteCredentialLoginError.invalidLoginResponse
205-
}
206-
switch response.statusCode {
207-
case 200:
208-
return // success 🎉
209-
case 404:
210-
throw SiteCredentialLoginError.inaccessibleAdminPage
211-
default:
212-
throw SiteCredentialLoginError.unacceptableStatusCode(code: response.statusCode)
213-
}
214-
}
215-
216199
func buildLoginRequest(username: String, password: String) -> URLRequest? {
217200
guard let loginURL = URL(string: siteURL + Constants.loginPath) else {
218201
return nil
@@ -239,18 +222,6 @@ private extension SiteCredentialLoginUseCase {
239222
}
240223
}
241224

242-
extension SiteCredentialLoginUseCase: URLSessionDataDelegate {
243-
func urlSession(_ session: URLSession,
244-
task: URLSessionTask,
245-
willPerformHTTPRedirection response: HTTPURLResponse,
246-
newRequest request: URLRequest) async -> URLRequest? {
247-
// Disables redirect and check if the redirect is correct
248-
task.cancel()
249-
await checkRedirect(url: request.url)
250-
return nil
251-
}
252-
}
253-
254225
extension SiteCredentialLoginUseCase {
255226
enum Constants {
256227
static let loginPath = "/wp-login.php"
@@ -310,4 +281,15 @@ private extension String {
310281
func hasInvalidCredentialsPattern() -> Bool {
311282
contains("document.querySelector('form').classList.add('shake')")
312283
}
284+
285+
/// Validates if the string matches the expected nonce format.
286+
/// A valid nonce should contain at least 2 alphanumeric characters.
287+
///
288+
func isValidNonce() -> Bool {
289+
guard let regex = try? Regex("^[0-9a-zA-Z]{2,}$") else {
290+
DDLogError("⚠️ Invalid regex pattern")
291+
return false
292+
}
293+
return wholeMatch(of: regex) != nil
294+
}
313295
}

WooCommerce/Classes/System/SessionManager.swift

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ final class SessionManager: SessionManagerProtocol {
5454
///
5555
private let imageCache: ImageCache
5656

57+
/// Serial queue for thread-safe credentials access
58+
///
59+
private let credentialsQueue = DispatchQueue(label: "com.woocommerce.session.credentials", qos: .userInitiated)
60+
5761
/// Makes sure the credentials are in sync with the watch session.
5862
///
5963
private lazy var watchDependenciesSynchronizer = {
@@ -76,20 +80,31 @@ final class SessionManager: SessionManagerProtocol {
7680
///
7781
var defaultCredentials: Credentials? {
7882
get {
79-
return loadCredentials()
83+
credentialsQueue.sync {
84+
loadCredentials()
85+
}
8086
}
8187
set {
82-
guard newValue != defaultCredentials else {
83-
return
84-
}
88+
let shouldUpdateWatchSync = credentialsQueue.sync { () -> Bool in
89+
let currentCredentials = loadCredentials()
90+
guard newValue != currentCredentials else {
91+
return false
92+
}
8593

86-
removeCredentials()
94+
removeCredentials()
8795

88-
if let credentials = newValue {
89-
saveCredentials(credentials)
96+
if let credentials = newValue {
97+
saveCredentials(credentials)
98+
}
99+
100+
return true
90101
}
91102

92-
watchDependenciesSynchronizer.credentials = newValue
103+
// Update watch synchronizer outside the sync block to avoid potential deadlocks
104+
// from @Published property triggering Combine subscribers that might read credentials
105+
if shouldUpdateWatchSync {
106+
watchDependenciesSynchronizer.credentials = newValue
107+
}
93108
}
94109
}
95110

@@ -235,7 +250,7 @@ final class SessionManager: SessionManagerProtocol {
235250
/// Deletes application password
236251
///
237252
func deleteApplicationPassword(using creds: Credentials?, locally: Bool) {
238-
let useCase: ApplicationPasswordUseCase? = {
253+
let useCase: ApplicationPasswordUseCase? = credentialsQueue.sync {
239254
let credentials = creds ?? loadCredentials()
240255
switch credentials {
241256
case let .wporg(username, password, siteAddress):
@@ -253,7 +268,7 @@ final class SessionManager: SessionManagerProtocol {
253268
case .none:
254269
return nil
255270
}
256-
}()
271+
}
257272
guard let useCase else {
258273
return
259274
}

0 commit comments

Comments
 (0)