Stabilize synchronous Noise restart tests

This commit is contained in:
jack
2026-07-26 12:21:38 +02:00
committed by jack
parent 52e753eee2
commit ad714f6796
5 changed files with 178 additions and 63 deletions
+21 -4
View File
@@ -645,14 +645,25 @@ struct BLEServiceCoreTests {
let mallory = NoiseEncryptionService(keychain: MockKeychain()) let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID) // Establish BLE as responder so the following inbound reconnect is
// not intentionally coalesced by the initiator-completion grace path.
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
let message2 = try #require( let message2 = try #require(
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1) try ble._test_noiseProcessHandshakeMessage(
from: alicePeerID,
message: message1
)
) )
let message3 = try #require( let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2) try alice.processHandshakeMessage(
from: ble.myPeerID,
message: message2
)
)
_ = try ble._test_noiseProcessHandshakeMessage(
from: alicePeerID,
message: message3
) )
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
await ble._test_drainNoiseMessagePipeline() await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID)) #expect(ble.canDeliverSecurely(to: alicePeerID))
@@ -674,6 +685,9 @@ struct BLEServiceCoreTests {
{ {
outbound.snapshot().contains { outbound.snapshot().contains {
$0.type == MessageType.noiseHandshake.rawValue $0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
} }
}, },
timeout: TestConstants.longTimeout timeout: TestConstants.longTimeout
@@ -682,6 +696,9 @@ struct BLEServiceCoreTests {
let forgedMessage2 = try #require( let forgedMessage2 = try #require(
outbound.snapshot().first { outbound.snapshot().first {
$0.type == MessageType.noiseHandshake.rawValue $0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}?.payload }?.payload
) )
#expect(!ble.canDeliverSecurely(to: alicePeerID)) #expect(!ble.canDeliverSecurely(to: alicePeerID))
@@ -12,6 +12,7 @@ import Testing
@testable import BitFoundation // to avoid unnecessary public's @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests { struct IntegrationTests {
private var helper = TestNetworkHelper() private var helper = TestNetworkHelper()
@@ -272,8 +273,18 @@ struct IntegrationTests {
// Re-establish Noise handshake explicitly via managers // Re-establish Noise handshake explicitly via managers
do { do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! let m2 = try #require(
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
from: helper.nodes["Bob"]!.peerID,
message: m1
)
)
let m3 = try #require(
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
from: helper.nodes["Alice"]!.peerID,
message: m2
)
)
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch { } catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)") Issue.record("Failed to re-establish Noise session after restart: \(error)")
@@ -8,6 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
node.mockNickname = name node.mockNickname = name
nodes[name] = node nodes[name] = node
// Create/replace Noise manager for this node // This synchronous helper directly drives all three XX messages and
// has no transport callback loop for delayed collision recovery.
let key = Curve25519.KeyAgreement.PrivateKey() let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
return node return node
} }
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
let peer2ID = nodes[node2]?.peerID else { return } let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID) let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! let msg2 = try #require(
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! try manager2.handleIncomingHandshake(
from: peer1ID,
message: msg1
)
)
let msg3 = try #require(
try manager1.handleIncomingHandshake(
from: peer2ID,
message: msg2
)
)
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
+57 -18
View File
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
@Test func peerRestartDetection() throws { @Test func peerRestartDetection() throws {
// Establish initial sessions // Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // This test explicitly drives the three synchronous XX messages and
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session) // Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake( let newHandshake2 = try #require(
from: alicePeerID, message: newHandshake1) try aliceManager.handleIncomingHandshake(
#expect(newHandshake2 != nil) from: alicePeerID,
message: newHandshake1
)
)
// Complete the new handshake // Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( let newHandshake3 = try #require(
from: bobPeerID, message: newHandshake2!) try bobManagerRestarted.handleIncomingHandshake(
#expect(newHandshake3 != nil) from: bobPeerID,
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) message: newHandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake3
)
// Should be able to exchange messages with new sessions // Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8) let testMessage = Data("After restart".utf8)
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
@Test func nonceDesynchronizationCausesRehandshake() throws { @Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake // Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // This test explicitly drives the three synchronous XX messages and
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID) let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session // Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake( let rehandshake2 = try #require(
from: alicePeerID, message: rehandshake1) try aliceManager.handleIncomingHandshake(
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync") from: alicePeerID,
message: rehandshake1
),
"Alice should accept handshake to fix desync"
)
// Complete handshake // Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake( let rehandshake3 = try #require(
from: bobPeerID, message: rehandshake2!) try bobManager.handleIncomingHandshake(
#expect(rehandshake3 != nil) from: bobPeerID,
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) message: rehandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake3
)
// Verify communication works again // Verify communication works again
let testResynced = Data("Resynced".utf8) let testResynced = Data("Resynced".utf8)
@@ -666,15 +666,17 @@ struct NoiseEncryptionServiceTests {
@Test("Claim gives an attempt a full on-wire timeout window") @Test("Claim gives an attempt a full on-wire timeout window")
func handshakeClaimRearmsDeadline() async throws { func handshakeClaimRearmsDeadline() async throws {
let timeoutInterval: TimeInterval = 1
let service = NoiseEncryptionService( let service = NoiseEncryptionService(
keychain: MockKeychain(), keychain: MockKeychain(),
ordinaryHandshakeTimeout: 0.08 ordinaryHandshakeTimeout: timeoutInterval
) )
let peerID = PeerID(str: "1021324354657687") let peerID = PeerID(str: "1021324354657687")
let recorder = HandshakeStartRecorder() let recorder = HandshakeStartRecorder()
service.onHandshakeRecoveryRequired = { [weak service] request in service.onHandshakeRecoveryRequired = { [weak service] request in
let firedAt = DispatchTime.now().uptimeNanoseconds
service?.cancelHandshakeRecovery(request) service?.cancelHandshakeRecovery(request)
recorder.recordTimeout() recorder.recordTimeout(at: firedAt)
} }
let attempt = try #require( let attempt = try #require(
@@ -683,19 +685,24 @@ struct NoiseEncryptionServiceTests {
retryOnTimeout: true retryOnTimeout: true
) )
) )
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 500_000_000)
#expect( let claimed = service.claimHandshakeInitiation(attempt, for: peerID)
service.claimHandshakeInitiation(attempt, for: peerID) let claimedAt = DispatchTime.now().uptimeNanoseconds
== attempt.payload #expect(claimed == attempt.payload)
)
try? await Task.sleep(nanoseconds: 45_000_000)
#expect(service.hasSession(with: peerID))
#expect(recorder.timeoutCount == 0)
let expired = await TestHelpers.waitUntil( let expired = await TestHelpers.waitUntil(
{ recorder.timeoutCount == 1 }, { recorder.timeoutCount == 1 },
timeout: 1 timeout: 5
) )
#expect(expired) #expect(expired)
let firedAt = try #require(recorder.firstTimeoutUptimeNanoseconds)
try #require(firedAt >= claimedAt)
let elapsed = TimeInterval(firedAt - claimedAt) / 1_000_000_000
// A non-rearmed deadline would fire roughly 0.5 seconds after the
// claim. Measure on the timeout queue instead of relying on a task to
// resume inside a narrow pre-deadline window under parallel CI load.
#expect(elapsed >= timeoutInterval * 0.75)
#expect(!service.hasSession(with: peerID))
#expect(recorder.timeoutCount == 1)
} }
@Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown") @Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown")
@@ -723,7 +730,8 @@ struct NoiseEncryptionServiceTests {
message: spoofedMessage1 message: spoofedMessage1
) )
) )
try? await Task.sleep(nanoseconds: 35_000_000) // Exercise replacement before yielding: the test runner may resume a
// short sleep after the fixed responder deadline under parallel load.
_ = try #require( _ = try #require(
try bob.processHandshakeMessage( try bob.processHandshakeMessage(
from: alicePeerID, from: alicePeerID,
@@ -820,26 +828,20 @@ struct NoiseEncryptionServiceTests {
@Test("Deterministic responder recovers once from an always-yield peer") @Test("Deterministic responder recovers once from an always-yield peer")
func yieldedResponderRecoversFromLegacyDoubleYield() async throws { func yieldedResponderRecoversFromLegacyDoubleYield() async throws {
let timeoutInterval: TimeInterval = 1
let endpoints = orderedServices( let endpoints = orderedServices(
ordinaryHandshakeTimeout: 0.08, ordinaryHandshakeTimeout: timeoutInterval,
ordinaryResponderHandshakeTimeout: 0.08 ordinaryResponderHandshakeTimeout: timeoutInterval
) )
let modern = endpoints.higher let modern = endpoints.higher
let legacy = endpoints.lower let legacy = endpoints.lower
let recovery = HandshakeStartRecorder() let recovery = HandshakeStartRecorder()
modern.onHandshakeRecoveryRequired = { [weak modern] request in modern.onHandshakeRecoveryRequired = { request in
guard let modern else { return } // Preparing here would arm the retry before the test task can
do { // forward message 1. Record the token so preparation and the
recovery.recordTimeout() // simulated on-wire exchange remain synchronous.
recovery.record( recovery.recordTimeout()
message: try claimPreparedRecoveryPayload( recovery.record(request: request)
modern,
request: request
)
)
} catch {
recovery.record(error: error)
}
} }
let modernAttempt = try #require( let modernAttempt = try #require(
@@ -903,12 +905,18 @@ struct NoiseEncryptionServiceTests {
// Expected; this side did not own retry intent. // Expected; this side did not own retry intent.
} }
let didRecover = await TestHelpers.waitUntil( let recoveryRequested = await TestHelpers.waitUntil(
{ recovery.messages.count == 1 }, { recovery.requests.count == 1 },
timeout: 1 timeout: 5
)
#expect(recoveryRequested)
let recoveryRequest = try #require(recovery.requests.first)
let retryMessage1 = try #require(
try claimPreparedRecoveryPayload(
modern,
request: recoveryRequest
)
) )
#expect(didRecover)
let retryMessage1 = try #require(recovery.messages.first)
let retryMessage2 = try #require( let retryMessage2 = try #require(
try legacy.processHandshakeMessage( try legacy.processHandshakeMessage(
from: endpoints.higherPeerID, from: endpoints.higherPeerID,
@@ -925,9 +933,10 @@ struct NoiseEncryptionServiceTests {
from: endpoints.higherPeerID, from: endpoints.higherPeerID,
message: retryMessage3 message: retryMessage3
) )
try? await Task.sleep(nanoseconds: 120_000_000) try? await Task.sleep(
nanoseconds: UInt64(timeoutInterval * 1_200_000_000)
)
#expect(recovery.timeoutCount == 1) #expect(recovery.timeoutCount == 1)
#expect(recovery.errorCount == 0)
let ciphertext = try modern.encrypt( let ciphertext = try modern.encrypt(
Data("legacy converged".utf8), Data("legacy converged".utf8),
for: endpoints.lowerPeerID for: endpoints.lowerPeerID
@@ -1503,8 +1512,10 @@ private final class HandshakeInitiationRecorder: @unchecked Sendable {
private final class HandshakeStartRecorder: @unchecked Sendable { private final class HandshakeStartRecorder: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var storedMessages: [Data] = [] private var storedMessages: [Data] = []
private var storedRequests: [NoiseHandshakeRecoveryRequest] = []
private var storedErrorCount = 0 private var storedErrorCount = 0
private var storedTimeoutCount = 0 private var storedTimeoutCount = 0
private var storedTimeoutUptimes: [UInt64] = []
var messages: [Data] { var messages: [Data] {
lock.lock() lock.lock()
@@ -1512,6 +1523,12 @@ private final class HandshakeStartRecorder: @unchecked Sendable {
return storedMessages return storedMessages
} }
var requests: [NoiseHandshakeRecoveryRequest] {
lock.lock()
defer { lock.unlock() }
return storedRequests
}
var errorCount: Int { var errorCount: Int {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
@@ -1524,6 +1541,12 @@ private final class HandshakeStartRecorder: @unchecked Sendable {
return storedTimeoutCount return storedTimeoutCount
} }
var firstTimeoutUptimeNanoseconds: UInt64? {
lock.lock()
defer { lock.unlock() }
return storedTimeoutUptimes.first
}
func record(message: Data?) { func record(message: Data?) {
guard let message else { return } guard let message else { return }
lock.lock() lock.lock()
@@ -1531,15 +1554,24 @@ private final class HandshakeStartRecorder: @unchecked Sendable {
lock.unlock() lock.unlock()
} }
func record(request: NoiseHandshakeRecoveryRequest) {
lock.lock()
storedRequests.append(request)
lock.unlock()
}
func record(error _: Error) { func record(error _: Error) {
lock.lock() lock.lock()
storedErrorCount += 1 storedErrorCount += 1
lock.unlock() lock.unlock()
} }
func recordTimeout() { func recordTimeout(
at uptimeNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds
) {
lock.lock() lock.lock()
storedTimeoutCount += 1 storedTimeoutCount += 1
storedTimeoutUptimes.append(uptimeNanoseconds)
lock.unlock() lock.unlock()
} }
} }