Skip to content

Commit a062a6f

Browse files
committed
Move userDefault update to a separate serial queue
1 parent 9651db5 commit a062a6f

File tree

2 files changed

+138
-3
lines changed

2 files changed

+138
-3
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.async { [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.async { [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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
126126

127127
// When
128128
errorHandler.flagSiteAsUnsupported(for: siteID, flow: .apiRequest, cause: .majorError, error: NetworkError.notFound(response: nil))
129+
waitForUserDefaultsOperations()
129130

130131
// Then
131132
XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.keys.contains(String(siteID)))
@@ -139,6 +140,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
139140

140141
// When
141142
errorHandler.flagSiteAsUnsupported(for: newSiteID, flow: .apiRequest, cause: .majorError, error: NetworkError.notFound(response: nil))
143+
waitForUserDefaultsOperations()
142144

143145
// Then
144146
XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.keys.contains(String(existingSiteID)))
@@ -267,6 +269,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
267269

268270
// Then - no crashes should occur and all sites should be flagged
269271
wait(for: [expectation], timeout: 3.0)
272+
waitForUserDefaultsOperations()
270273

271274
// Verify all sites were added (though order may vary due to concurrency)
272275
let unsupportedList = userDefaults.applicationPasswordUnsupportedList
@@ -387,6 +390,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
387390

388391
// When
389392
let isFlagged = errorHandler.siteFlaggedAsUnsupported(siteID: siteID, unsupportedList: userDefaults.applicationPasswordUnsupportedList)
393+
waitForUserDefaultsOperations()
390394

391395
// Then
392396
XCTAssertFalse(isFlagged)
@@ -415,6 +419,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
415419

416420
// When
417421
let isFlagged = errorHandler.siteFlaggedAsUnsupported(siteID: siteID, unsupportedList: userDefaults.applicationPasswordUnsupportedList)
422+
waitForUserDefaultsOperations()
418423

419424
// Then
420425
XCTAssertFalse(isFlagged)
@@ -441,6 +446,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
441446
XCTAssertTrue(errorHandler.siteFlaggedAsUnsupported(siteID: siteID1, unsupportedList: list))
442447
XCTAssertFalse(errorHandler.siteFlaggedAsUnsupported(siteID: siteID2, unsupportedList: userDefaults.applicationPasswordUnsupportedList))
443448
XCTAssertTrue(errorHandler.siteFlaggedAsUnsupported(siteID: siteID3, unsupportedList: userDefaults.applicationPasswordUnsupportedList))
449+
waitForUserDefaultsOperations()
444450

445451
// Verify expired flag was cleared but others remain
446452
XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.keys.contains(String(siteID1)))
@@ -525,6 +531,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
525531
originalRequest: jetpackRequest,
526532
failure: nil
527533
)
534+
waitForUserDefaultsOperations()
528535

529536
// Then
530537
XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.keys.contains(String(siteID)))
@@ -556,6 +563,7 @@ final class AlamofireNetworkErrorHandlerTests: XCTestCase {
556563
originalRequest: jetpackRequest,
557564
failure: nil
558565
)
566+
waitForUserDefaultsOperations()
559567

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

611724
// MARK: - Helper Methods
612725
private extension AlamofireNetworkErrorHandlerTests {
726+
/// Waits briefly for async UserDefaults operations to complete
727+
/// Since flagSiteAsUnsupported and clearUnsupportedFlag use userDefaultsQueue.async,
728+
/// tests need to wait for those operations to finish before checking UserDefaults state
729+
func waitForUserDefaultsOperations() {
730+
let expectation = XCTestExpectation(description: "UserDefaults operations complete")
731+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
732+
expectation.fulfill()
733+
}
734+
wait(for: [expectation], timeout: 1.0)
735+
}
736+
613737
func createWPComCredentials() -> Credentials {
614738
return Credentials.wpcom(username: "test", authToken: "token", siteAddress: "https://example.com")
615739
}

0 commit comments

Comments
 (0)