diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index e8609aed..6e2b3c23 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -1028,6 +1028,27 @@ final class NoiseSessionManager { .cancel() } + #if DEBUG + /// Fires a pending suppressed-initiation recovery immediately instead of + /// waiting out the completion-grace timer, so tests can inject a grace + /// period too large to lose against a starved runner and still exercise + /// the recovery path deterministically. + func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) { + managerQueue.sync(flags: .barrier) { + guard let pending = suppressedInitiationRecoveryTimeouts + .removeValue(forKey: peerID) else { + return + } + pending.cancel() + guard let current = sessions[peerID], + current.isEstablished() else { + return + } + requestHandshakeRecovery(for: peerID) + } + } + #endif + private func requestHandshakeRecovery( for peerID: PeerID, after delay: TimeInterval = 0 diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 1bf2b574..5ee7608d 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -1089,6 +1089,10 @@ final class NoiseEncryptionService { func _test_initiateAutomaticRekey(for peerID: PeerID) throws { try initiateAutomaticRekey(for: peerID) } + + func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) { + sessionManager._test_fireSuppressedInitiationRecovery(for: peerID) + } #endif deinit { diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index a2031983..c0cd293f 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -962,15 +962,20 @@ struct NoiseEncryptionServiceTests { @Test("Immediate legacy restart during completion grace converges once") func immediateLegacyRestartDuringCompletionGrace() async throws { + // The grace period must still be open when the restart initiation + // arrives below. A small value races the wall clock on a starved + // runner, so inject one no test run can outlive; the recovery half + // is then fired explicitly instead of waiting out the timer. + let unlosableGracePeriod: TimeInterval = 600 let firstKeychain = MockKeychain() let secondKeychain = MockKeychain() let first = NoiseEncryptionService( keychain: firstKeychain, - recentInitiatorCompletionGracePeriod: 0.03 + recentInitiatorCompletionGracePeriod: unlosableGracePeriod ) let second = NoiseEncryptionService( keychain: secondKeychain, - recentInitiatorCompletionGracePeriod: 0.03 + recentInitiatorCompletionGracePeriod: unlosableGracePeriod ) let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) @@ -1035,6 +1040,7 @@ struct NoiseEncryptionServiceTests { ) #expect(lower.hasEstablishedSession(with: higherPeerID)) + lower._test_fireSuppressedInitiationRecovery(for: higherPeerID) let requested = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, timeout: TestConstants.longTimeout