diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index 79440a64..c286ffcf 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -8,6 +8,7 @@ struct BLEPeripheralLinkState { var isConnecting: Bool var isConnected: Bool var lastConnectionAttempt: Date? + var attemptToken: UInt64 = 0 /// When didConnect last fired for this link. Nil for links restored /// already-connected (their connect predates this process), which is /// exactly the signal redundant-link consolidation needs: a restored @@ -17,6 +18,23 @@ struct BLEPeripheralLinkState { var assembler: NotificationStreamAssembler } +struct BLEConnectTimeoutPolicy { + /// Pure decision: given a captured attempt token and current link state attributes, + /// returns true if the timeout closure should act on this peripheral attempt. + static func shouldExecuteConnectTimeout( + capturedAttemptToken: UInt64, + isConnecting: Bool, + isConnected: Bool, + currentAttemptToken: UInt64, + isPeripheralConnected: Bool + ) -> Bool { + guard isConnecting && !isConnected else { return false } + guard !isPeripheralConnected else { return false } + guard currentAttemptToken == capturedAttemptToken else { return false } + return true + } +} + struct BLEDirectLinkState: Equatable { let hasPeripheral: Bool let hasCentral: Bool @@ -104,7 +122,10 @@ final class BLELinkStateStore { return state } - func beginConnecting(to peripheral: CBPeripheral, at date: Date) { + @discardableResult + func beginConnecting(to peripheral: CBPeripheral, at date: Date = Date()) -> UInt64 { + let peripheralID = peripheral.identifier.uuidString + let nextAttemptToken = (peripherals[peripheralID]?.attemptToken ?? 0) + 1 setPeripheralState( BLEPeripheralLinkState( peripheral: peripheral, @@ -112,10 +133,12 @@ final class BLELinkStateStore { isConnecting: true, isConnected: false, lastConnectionAttempt: date, + attemptToken: nextAttemptToken, assembler: NotificationStreamAssembler() ), - for: peripheral.identifier.uuidString + for: peripheralID ) + return nextAttemptToken } func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) { diff --git a/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift index cf9d6c2d..880622fe 100644 --- a/bitchat/Services/BLE/BLERadioController.swift +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -231,7 +231,9 @@ final class BLERadioController { guard delegate?.radioIsPanicSuspended() == false else { return } let peripheral = candidate.peripheral let peripheralID = candidate.peripheralID - linkStateStore.beginConnecting(to: peripheral, at: Date()) + // Captured monotonically increasing attempt token so the timeout closure + // below can tell this specific attempt apart from a later one. + let attemptToken = linkStateStore.beginConnecting(to: peripheral, at: Date()) peripheral.delegate = peripheralDelegate let options: [String: Any] = [ CBConnectPeripheralOptionNotifyOnConnectionKey: true, @@ -243,12 +245,25 @@ final class BLERadioController { SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in - guard let self, - let state = self.linkStateStore.state(forPeripheralID: peripheralID), - state.isConnecting && !state.isConnected else { return } + guard let self else { return } + let state = self.linkStateStore.state(forPeripheralID: peripheralID) - guard peripheral.state != .connected else { - SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session) + // A disconnect + immediate reconnect between this attempt and now + // starts a new attempt with an incremented attemptToken. Without + // this check, this now-stale timeout would cancel that newer, + // still-live attempt out from under it and apply a + // connection-timeout penalty to a peer that never actually timed out. + guard let state, + BLEConnectTimeoutPolicy.shouldExecuteConnectTimeout( + capturedAttemptToken: attemptToken, + isConnecting: state.isConnecting, + isConnected: state.isConnected, + currentAttemptToken: state.attemptToken, + isPeripheralConnected: peripheral.state == .connected + ) else { + if let state, state.attemptToken != attemptToken { + SecureLogger.debug("⏱️ Timeout fired for a superseded connection attempt, ignoring: \(candidate.name)", category: .session) + } return } diff --git a/bitchatTests/Services/BLEConnectTimeoutPolicyTests.swift b/bitchatTests/Services/BLEConnectTimeoutPolicyTests.swift new file mode 100644 index 00000000..50f0a8dd --- /dev/null +++ b/bitchatTests/Services/BLEConnectTimeoutPolicyTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEConnectTimeoutPolicyTests { + @Test + func supersededAttemptTokenIgnoresTimeout() { + let capturedToken: UInt64 = 1 + let currentToken: UInt64 = 2 + + let result = BLEConnectTimeoutPolicy.shouldExecuteConnectTimeout( + capturedAttemptToken: capturedToken, + isConnecting: true, + isConnected: false, + currentAttemptToken: currentToken, + isPeripheralConnected: false + ) + + #expect(!result) + } + + @Test + func matchingAttemptTokenExecutesTimeout() { + let token: UInt64 = 5 + + let result = BLEConnectTimeoutPolicy.shouldExecuteConnectTimeout( + capturedAttemptToken: token, + isConnecting: true, + isConnected: false, + currentAttemptToken: token, + isPeripheralConnected: false + ) + + #expect(result) + } + + @Test + func connectedStateIgnoresTimeout() { + let token: UInt64 = 5 + + let result = BLEConnectTimeoutPolicy.shouldExecuteConnectTimeout( + capturedAttemptToken: token, + isConnecting: false, + isConnected: true, + currentAttemptToken: token, + isPeripheralConnected: true + ) + + #expect(!result) + } + + @Test + func peripheralConnectedStateIgnoresTimeout() { + let token: UInt64 = 5 + + let result = BLEConnectTimeoutPolicy.shouldExecuteConnectTimeout( + capturedAttemptToken: token, + isConnecting: true, + isConnected: false, + currentAttemptToken: token, + isPeripheralConnected: true + ) + + #expect(!result) + } +}