Skip to content

Commit 7696da4

Browse files
authored
Fix deadlock crash when updating UserDefaults (#16256)
2 parents 5e22f37 + fc11138 commit 7696da4

File tree

2 files changed

+117
-3
lines changed

2 files changed

+117
-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.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

0 commit comments

Comments
 (0)