Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions bitchat/Services/BLE/BLELinkStateStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -104,18 +122,23 @@ 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,
characteristic: nil,
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()) {
Expand Down
29 changes: 22 additions & 7 deletions bitchat/Services/BLE/BLERadioController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 peripheral.state != .connected else {
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session)
guard let self else { return }
let state = self.linkStateStore.state(forPeripheralID: peripheralID)

// 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
}

Expand Down
66 changes: 66 additions & 0 deletions bitchatTests/Services/BLEConnectTimeoutPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading