@@ -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
612725private 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