[codex] Extract BLE link state store (#1310)

* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Refine BLE ingress fanout

* Rediscover BLE service after invalidation

* Extract BLE notification retry buffer

* Extract BLE inbound write buffer

* Extract BLE fragment assembly buffer

* Tidy secure log handling from device run

* Extract BLE outbound fragment scheduler

* Harden app CI media tests

* Redact BLE message content from logs

* Extract BLE Noise session queues

* Fix BLE read receipt UI updates

* Allow self-authored RSR ingress replies

* Harden read receipt queue test timing

* Extract BLE outbound policy and incoming file storage

* Avoid duplicate BLE link snapshots during send

* Canonicalize Nostr relay URLs

* Extract BLE link state store

* Extract BLE connection scheduler

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-05-31 14:24:09 +02:00
committed by GitHub
co-authored by jack
parent 9e84f5e822
commit ffa0d7aa4f
5 changed files with 835 additions and 368 deletions
@@ -0,0 +1,271 @@
import Foundation
struct BLEConnectionCandidate<Peripheral> {
let peripheral: Peripheral
let peripheralID: String
let rssi: Int
let name: String
let isConnectable: Bool
let discoveredAt: Date
}
struct BLEExistingConnectionState {
let isConnecting: Bool
let isConnected: Bool
let lastConnectionAttempt: Date?
}
enum BLEPeripheralConnectionState {
case disconnected
case connecting
case connected
}
enum BLEDiscoveryDecision: Equatable {
case ignore
case queued
case scheduleRetry(after: TimeInterval)
case cancelStaleConnection
case connectNow
}
enum BLEConnectionQueueDecision<Peripheral> {
case none
case retryAfter(TimeInterval)
case connect(BLEConnectionCandidate<Peripheral>)
}
final class BLEConnectionScheduler<Peripheral> {
private let maxCentralLinks: Int
private let connectRateLimitInterval: TimeInterval
private let candidateCap: Int
private let weakLinkCooldownSeconds: TimeInterval
private let weakLinkRSSICutoff: Int
private let recentTimeoutWindowSeconds: TimeInterval
private let recentTimeoutCountThreshold: Int
private var lastGlobalConnectAttempt: Date = .distantPast
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
private var failureCounts: [String: Int] = [:]
private var recentConnectTimeouts: [String: Date] = [:]
private var lastIsolatedAt: Date?
private let initialDynamicRSSIThreshold: Int
private(set) var dynamicRSSIThreshold: Int
var candidateCount: Int {
candidates.count
}
init(
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
) {
self.maxCentralLinks = maxCentralLinks
self.connectRateLimitInterval = connectRateLimitInterval
self.candidateCap = candidateCap
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
self.weakLinkRSSICutoff = weakLinkRSSICutoff
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
self.dynamicRSSIThreshold = dynamicRSSIThreshold
}
func handleDiscovery(
_ candidate: BLEConnectionCandidate<Peripheral>,
connectedOrConnectingCount: Int,
existingState: BLEExistingConnectionState?,
peripheralState: BLEPeripheralConnectionState,
now: Date
) -> BLEDiscoveryDecision {
guard candidate.isConnectable else { return .ignore }
if candidate.rssi <= dynamicRSSIThreshold {
enqueue(candidate)
return .queued
}
if connectedOrConnectingCount >= maxCentralLinks {
enqueue(candidate)
return .queued
}
if let retryDelay = rateLimitRetryDelay(now: now) {
enqueue(candidate)
return .scheduleRetry(after: retryDelay)
}
if let existingState {
if existingState.isConnected || existingState.isConnecting {
return .ignore
}
if let lastAttempt = existingState.lastConnectionAttempt,
now.timeIntervalSince(lastAttempt) < 2.0 {
return .ignore
}
}
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
now.timeIntervalSince(lastTimeout) < 15 {
return .ignore
}
switch peripheralState {
case .disconnected:
return .connectNow
case .connecting, .connected:
return .cancelStaleConnection
}
}
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
candidates[existingIndex] = candidate
} else {
candidates.append(candidate)
}
candidates.sort {
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
return $0.discoveredAt < $1.discoveredAt
}
if candidates.count > candidateCap {
candidates.removeLast(candidates.count - candidateCap)
}
}
func nextCandidate(
connectedOrConnectingCount: Int,
isAlreadyConnectingOrConnected: (String) -> Bool,
now: Date
) -> BLEConnectionQueueDecision<Peripheral> {
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
if let retryDelay = rateLimitRetryDelay(now: now) {
return .retryAfter(retryDelay)
}
while !candidates.isEmpty {
candidates.sort { score($0, now: now) > score($1, now: now) }
let candidate = candidates.removeFirst()
guard candidate.isConnectable else { continue }
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
continue
}
return .connect(candidate)
}
return .none
}
func recordConnectionAttempt(at now: Date) {
lastGlobalConnectAttempt = now
}
func recordConnectionSuccess(peripheralID: String) {
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
failureCounts[peripheralID, default: 0] += 1
}
func recordDisconnectError(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
}
func recordConnectionTimeout(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
recordConnectionFailure(peripheralID: peripheralID)
}
func pruneConnectionTimeouts(before cutoff: Date) {
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
}
func reset() {
lastGlobalConnectAttempt = .distantPast
candidates.removeAll()
failureCounts.removeAll()
recentConnectTimeouts.removeAll()
lastIsolatedAt = nil
dynamicRSSIThreshold = initialDynamicRSSIThreshold
}
@discardableResult
func updateRSSIThreshold(
connectedCount: Int,
connectedOrConnectingLinkCount: Int,
now: Date
) -> Int {
if connectedCount == 0 {
if lastIsolatedAt == nil { lastIsolatedAt = now }
let isolatedAt = lastIsolatedAt ?? now
let elapsed = now.timeIntervalSince(isolatedAt)
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
? TransportConfig.bleRSSIIsolatedRelaxed
: TransportConfig.bleRSSIIsolatedBase
return dynamicRSSIThreshold
}
lastIsolatedAt = nil
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
threshold = TransportConfig.bleRSSIConnectedThreshold
}
let recentTimeouts = recentConnectTimeouts.filter {
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
}.count
if recentTimeouts >= recentTimeoutCountThreshold {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
}
dynamicRSSIThreshold = threshold
return threshold
}
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
guard elapsed < connectRateLimitInterval else { return nil }
return connectRateLimitInterval - elapsed + 0.05
}
private func weakLinkRetryDelay(
for candidate: BLEConnectionCandidate<Peripheral>,
now: Date
) -> TimeInterval? {
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
let elapsed = now.timeIntervalSince(lastTimeout)
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
let remaining = weakLinkCooldownSeconds - elapsed
return min(max(2.0, remaining), 15.0)
}
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
let failures = failureCounts[candidate.peripheralID] ?? 0
let penalty = min(20, 1 << min(4, failures))
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
now.timeIntervalSince($0) < 60 ? 10 : 0
} ?? 0
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
return base + recency - penalty - timeoutBias
}
}
@@ -0,0 +1,196 @@
import BitFoundation
import CoreBluetooth
import Foundation
struct BLEPeripheralLinkState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
var assembler: NotificationStreamAssembler
}
struct BLEDirectLinkState: Equatable {
let hasPeripheral: Bool
let hasCentral: Bool
}
struct BLESubscribedCentralSnapshot {
let centrals: [CBCentral]
let peerIDsByCentralUUID: [String: PeerID]
func central(for peerID: PeerID) -> CBCentral? {
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
}
}
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
var peripheralStates: [BLEPeripheralLinkState] {
Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
subscribedCentrals.count
}
var connectedOrConnectingPeripheralCount: Int {
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
}
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
peripherals[peripheralID]
}
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
peripherals[peripheralID] = state
}
@discardableResult
func updatePeripheral(
_ peripheralID: String,
_ update: (inout BLEPeripheralLinkState) -> Void
) -> BLEPeripheralLinkState? {
guard var state = peripherals[peripheralID] else { return nil }
update(&state)
peripherals[peripheralID] = state
return state
}
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: date,
assembler: NotificationStreamAssembler()
),
for: peripheral.identifier.uuidString
)
}
func markConnected(_ peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: peripheralID
)
}
}
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
updatePeripheral(peripheralID) {
$0.characteristic = characteristic
}
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = peerToPeripheralUUID[peerID] {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
centralToPeerID[centralUUID] = peerID
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
}
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
return peerID
}
func clearPeripherals() -> [PeerID] {
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
return peerIDs
}
func clearCentrals() -> [PeerID] {
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
return peerIDs
}
func clearAll() {
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
}
}
+166 -368
View File
@@ -34,23 +34,9 @@ final class BLEService: NSObject {
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections)
// 1. Consolidated Peripheral Tracking
private struct PeripheralState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool = false
var isConnected: Bool = false
var lastConnectionAttempt: Date? = nil
var assembler = NotificationStreamAssembler()
}
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
private var peerToPeripheralUUID: [PeerID: String] = [:] // PeerID -> Peripheral UUID
// 2. BLE Centrals (when acting as peripheral)
private var subscribedCentrals: [CBCentral] = []
private var centralToPeerID: [String: PeerID] = [:] // Central UUID -> Peer ID mapping
// 1. Consolidated BLE link tracking for both central and peripheral roles.
private var linkStateStore = BLELinkStateStore()
// BCH-01-004: Rate-limiting for subscription-triggered announces
// Tracks subscription attempts per central to prevent enumeration attacks
@@ -85,8 +71,6 @@ final class BLEService: NSObject {
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
private let incomingFileStore = BLEIncomingFileStore()
// Backoff for peripherals that recently timed out connecting
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
// Simple announce throttling
private var lastAnnounceSent = Date.distantPast
@@ -156,20 +140,7 @@ final class BLEService: NSObject {
private var maintenanceCounter = 0 // Track maintenance cycles
// MARK: - Connection budget & scheduling (central role)
private let maxCentralLinks = TransportConfig.bleMaxCentralLinks
private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval
private var lastGlobalConnectAttempt: Date = .distantPast
private struct ConnectionCandidate {
let peripheral: CBPeripheral
let rssi: Int
let name: String
let isConnectable: Bool
let discoveredAt: Date
}
private var connectionCandidates: [ConnectionCandidate] = []
private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures
private var lastIsolatedAt: Date? = nil
private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
// MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer?
@@ -348,7 +319,7 @@ final class BLEService: NSObject {
bleQueue.sync {
pendingWriteBuffers.removeAll()
recentConnectTimeouts.removeAll()
connectionScheduler.reset()
}
recentDisconnectNotifies.removeAll()
@@ -506,7 +477,7 @@ final class BLEService: NSObject {
// Snapshot BLE state under bleQueue to avoid races with delegate callbacks
let (peripheralStates, centralsCount, char) = bleQueue.sync {
(Array(peripherals.values), subscribedCentrals.count, characteristic)
(linkStateStore.peripheralStates, linkStateStore.subscribedCentralCount, characteristic)
}
// Send to peripherals we're connected to as central
@@ -543,7 +514,7 @@ final class BLEService: NSObject {
peripheralManager?.stopAdvertising()
// Disconnect all peripherals (synchronized access)
let peripheralsToDisconnect = bleQueue.sync { Array(peripherals.values) }
let peripheralsToDisconnect = bleQueue.sync { linkStateStore.peripheralStates }
for state in peripheralsToDisconnect {
centralManager?.cancelPeripheralConnection(state.peripheral)
}
@@ -573,10 +544,8 @@ final class BLEService: NSObject {
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
bleQueue.sync {
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
linkStateStore.clearAll()
connectionScheduler.reset()
centralSubscriptionRateLimits.removeAll()
}
meshTopology.reset()
@@ -888,16 +857,8 @@ final class BLEService: NSObject {
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
// Per-link limits for the specific peer
let directPeripheralState: PeripheralState? = {
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return peerToPeripheralUUID[recipientPeerID].flatMap { peripherals[$0] }
}
return bleQueue.sync {
peerToPeripheralUUID[recipientPeerID].flatMap { peripherals[$0] }
}
}()
let (centrals, mapping) = snapshotSubscribedCentrals()
let recipientCentral = centrals.first { mapping[$0.identifier.uuidString] == recipientPeerID }
let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID)
let recipientCentral = snapshotSubscribedCentrals().central(for: recipientPeerID)
if let peripheralMaxLen = directPeripheralState?.peripheral.maximumWriteValueLength(for: .withoutResponse),
data.count > peripheralMaxLen {
@@ -987,7 +948,7 @@ final class BLEService: NSObject {
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
}
let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().0
let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals
let minNotifyLen = subscribedCentrals.map { $0.maximumUpdateValueLength }.min()
// Avoid re-fragmenting fragment packets
@@ -1378,14 +1339,14 @@ extension BLEService: CBCentralManagerDelegate {
for peripheral in restoredPeripherals {
let identifier = peripheral.identifier.uuidString
peripheral.delegate = self
let existing = peripherals[identifier]
let existing = linkStateStore.state(forPeripheralID: identifier)
let assembler = existing?.assembler ?? NotificationStreamAssembler()
let characteristic = existing?.characteristic
let peerID = existing?.peerID
let wasConnecting = existing?.isConnecting ?? false
let wasConnected = existing?.isConnected ?? false
let restoredState = PeripheralState(
let restoredState = BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: characteristic,
peerID: peerID,
@@ -1394,7 +1355,7 @@ extension BLEService: CBCentralManagerDelegate {
lastConnectionAttempt: existing?.lastConnectionAttempt,
assembler: assembler
)
peripherals[identifier] = restoredState
linkStateStore.setPeripheralState(restoredState, for: identifier)
}
captureBluetoothStatus(context: "central-restore")
@@ -1418,12 +1379,12 @@ extension BLEService: CBCentralManagerDelegate {
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
central.stopScan()
// Mark all peripheral connections as disconnected (they are now invalid)
let peerIDs: [PeerID] = peripherals.compactMap { $0.value.peerID }
for state in peripherals.values {
let peripheralStates = linkStateStore.peripheralStates
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
for state in peripheralStates {
central.cancelPeripheralConnection(state.peripheral)
}
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
_ = linkStateStore.clearPeripherals()
// Notify UI of disconnections
for peerID in peerIDs {
notifyUI { [weak self] in
@@ -1435,8 +1396,7 @@ extension BLEService: CBCentralManagerDelegate {
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
_ = linkStateStore.clearPeripherals()
case .unsupported:
// Device doesn't support BLE
@@ -1481,160 +1441,47 @@ extension BLEService: CBCentralManagerDelegate {
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "")
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
let rssiValue = RSSI.intValue
// Skip if peripheral is not connectable (per advertisement data)
guard isConnectable else { return }
// Skip immediate connect if signal too weak for current conditions; enqueue instead
if rssiValue <= dynamicRSSIThreshold {
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
// Keep list tidy
connectionCandidates.sort { (a, b) in
if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt
}
if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return
}
// Budget: limit simultaneous central links (connected + connecting)
let currentCentralLinks = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if currentCentralLinks >= maxCentralLinks {
// Enqueue as candidate; we'll attempt later as slots open
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
// Keep candidate list tidy: prefer stronger RSSI, then recency; cap list
connectionCandidates.sort { (a, b) in
if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt
}
if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return
}
let candidate = BLEConnectionCandidate(
peripheral: peripheral,
peripheralID: peripheralID,
rssi: rssiValue,
name: String(advertisedName),
isConnectable: isConnectable,
discoveredAt: Date()
)
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
// Rate limit global connect attempts
let sinceLast = Date().timeIntervalSince(lastGlobalConnectAttempt)
if sinceLast < connectRateLimitInterval {
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
connectionCandidates.sort { (a, b) in
if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt
}
// Schedule a deferred attempt after rate-limit interval
let delay = connectRateLimitInterval - sinceLast + 0.05
switch connectionScheduler.handleDiscovery(
candidate,
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
existingState: existingState,
peripheralState: peripheral.state.connectionSchedulerState,
now: candidate.discoveredAt
) {
case .ignore, .queued:
return
case .scheduleRetry(let delay):
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.tryConnectFromQueue()
}
return
}
// Check if we already have this peripheral
if let state = peripherals[peripheralID] {
if state.isConnected || state.isConnecting {
return // Already connected or connecting
}
// Add backoff for reconnection attempts
if let lastAttempt = state.lastConnectionAttempt {
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceLastAttempt < 2.0 {
return // Wait at least 2 seconds between connection attempts
}
}
}
// Backoff if this peripheral recently timed out connection within the last 15 seconds
if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 {
return
}
// Check peripheral state - but cancel if stale
if peripheral.state == .connecting || peripheral.state == .connected {
// iOS might have stale state - force disconnect and retry
case .cancelStaleConnection:
central.cancelPeripheralConnection(peripheral)
// Will retry on next discovery
return
}
// Only log when we're actually attempting connection
// Discovered BLE peripheral
// Store the peripheral and mark as connecting
peripherals[peripheralID] = PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: Date(),
assembler: NotificationStreamAssembler()
)
peripheral.delegate = self
// Connect to the peripheral with options for faster connection
SecureLogger.debug("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", category: .session)
// Use connection options for faster reconnection
let options: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date()
// Set a timeout for the connection attempt (slightly longer for reliability)
// Use BLE queue to mutate BLE-related state consistently
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
guard let self = self,
let state = self.peripherals[peripheralID],
state.isConnecting && !state.isConnected else { return }
// Double-check actual CBPeripheral state to avoid canceling a just-connected peripheral
// This prevents a race where connection completes just as timeout fires
guard peripheral.state != .connected else {
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(advertisedName)", category: .session)
return
}
// Connection timed out - cancel it
SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date()
self.failureCounts[peripheralID, default: 0] += 1
// Try next candidate if any
self.tryConnectFromQueue()
case .connectNow:
beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect")
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
// Update state to connected
if var state = peripherals[peripheralID] {
state.isConnecting = false
state.isConnected = true
peripherals[peripheralID] = state
} else {
// Create new state if not found
peripherals[peripheralID] = PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
}
linkStateStore.markConnected(peripheral)
// Reset backoff state on success
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
connectionScheduler.recordConnectionSuccess(peripheralID: peripheralID)
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
@@ -1646,22 +1493,18 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
let peripheralID = peripheral.identifier.uuidString
// Find the peer ID if we have it
let peerID = peripherals[peripheralID]?.peerID
let peerID = linkStateStore.peerID(forPeripheralID: peripheralID)
SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
recentConnectTimeouts[peripheralID] = Date()
connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
}
// Clean up references
peripherals.removeValue(forKey: peripheralID)
// Clean up peer mappings
// Clean up references and peer mappings
_ = linkStateStore.removePeripheral(peripheralID)
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
// Do not remove peer; mark as not connected but retain for reachability
collectionsQueue.sync(flags: .barrier) {
if var info = peers[peerID] {
@@ -1703,74 +1546,72 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
let peripheralID = peripheral.identifier.uuidString
// Clean up the references
peripherals.removeValue(forKey: peripheralID)
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
failureCounts[peripheralID, default: 0] += 1
connectionScheduler.recordConnectionFailure(peripheralID: peripheralID)
// Try next candidate
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
}
}
// MARK: - Connection scheduling helpers
private extension BLEExistingConnectionState {
init(_ state: BLEPeripheralLinkState) {
self.init(
isConnecting: state.isConnecting,
isConnected: state.isConnected,
lastConnectionAttempt: state.lastConnectionAttempt
)
}
}
private extension CBPeripheralState {
var connectionSchedulerState: BLEPeripheralConnectionState {
switch self {
case .connected:
return .connected
case .connecting:
return .connecting
case .disconnected, .disconnecting:
return .disconnected
@unknown default:
return .disconnected
}
}
}
extension BLEService {
private func tryConnectFromQueue() {
guard let central = centralManager, central.state == .poweredOn else { return }
// Check budget and rate limit
let current = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
guard current < maxCentralLinks else { return }
let delta = Date().timeIntervalSince(lastGlobalConnectAttempt)
guard delta >= connectRateLimitInterval else {
let delay = connectRateLimitInterval - delta + 0.05
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
return
}
// Pull best candidate by composite score
guard !connectionCandidates.isEmpty else { return }
// compute score: connectable> RSSI > recency, with backoff penalty
func score(_ c: ConnectionCandidate) -> Int {
let uuid = c.peripheral.identifier.uuidString
// Penalty if recently timed out (exponential)
let fails = failureCounts[uuid] ?? 0
let penalty = min(20, (1 << min(4, fails))) // 1,2,4,8,16 cap 16-20
let timeoutRecent = recentConnectTimeouts[uuid]
let timeoutBias = (timeoutRecent != nil && Date().timeIntervalSince(timeoutRecent!) < 60) ? 10 : 0
let base = (c.isConnectable ? 1000 : 0) + (c.rssi + 100) * 2
let rec = -Int(Date().timeIntervalSince(c.discoveredAt) * 10)
return base + rec - penalty - timeoutBias
}
connectionCandidates.sort { score($0) > score($1) }
let candidate = connectionCandidates.removeFirst()
guard candidate.isConnectable else { return }
let peripheral = candidate.peripheral
let peripheralID = peripheral.identifier.uuidString
// Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries
if let lastTO = recentConnectTimeouts[peripheralID] {
let elapsed = Date().timeIntervalSince(lastTO)
if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff {
// Requeue the candidate and try again later
connectionCandidates.append(candidate)
let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed
let delay = min(max(2.0, remaining), 15.0)
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
return
}
}
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
// Already in progress; skip
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
return
}
// Initiate connection
peripherals[peripheralID] = PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: Date(),
assembler: NotificationStreamAssembler()
let decision = connectionScheduler.nextCandidate(
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in
let state = linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
},
now: Date()
)
switch decision {
case .none:
return
case .retryAfter(let delay):
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
case .connect(let candidate):
beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect")
}
}
private func beginCentralConnection(
_ candidate: BLEConnectionCandidate<CBPeripheral>,
using central: CBCentralManager,
logPrefix: String
) {
let peripheral = candidate.peripheral
let peripheralID = candidate.peripheralID
linkStateStore.beginConnecting(to: peripheral, at: Date())
peripheral.delegate = self
let options: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
@@ -1778,8 +1619,25 @@ extension BLEService {
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date()
SecureLogger.debug("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
connectionScheduler.recordConnectionAttempt(at: Date())
SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
guard let self = 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)
return
}
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
}
}
}
@@ -1906,10 +1764,7 @@ extension BLEService: CBPeripheralDelegate {
// Store characteristic in our consolidated structure
let peripheralID = peripheral.identifier.uuidString
if var state = peripherals[peripheralID] {
state.characteristic = characteristic
peripherals[peripheralID] = state
}
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
// Subscribe for notifications
if characteristic.properties.contains(.notify) {
@@ -1944,7 +1799,7 @@ extension BLEService: CBPeripheralDelegate {
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
let peripheralUUID = peripheral.identifier.uuidString
var state = peripherals[peripheralUUID] ?? PeripheralState(
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
@@ -1957,7 +1812,7 @@ extension BLEService: CBPeripheralDelegate {
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
peripherals[peripheralUUID] = state
linkStateStore.setPeripheralState(state, for: peripheralUUID)
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
@@ -1969,7 +1824,7 @@ extension BLEService: CBPeripheralDelegate {
// Codex review identified TOCTOU in this patch.
// Enforce per-link sender binding immediately within the same notification batch.
// NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce
// NOTE: `processNotificationPacket` may bind the stored peer ID when an announce
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
var boundPeerID: PeerID? = state.peerID
@@ -2001,7 +1856,7 @@ extension BLEService: CBPeripheralDelegate {
packet.ttl == messageTTL {
boundPeerID = claimedSenderID
state.peerID = claimedSenderID
peripherals[peripheralUUID] = state
linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID)
}
if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) {
@@ -2025,11 +1880,7 @@ extension BLEService: CBPeripheralDelegate {
if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL {
if var state = peripherals[peripheralUUID] {
state.peerID = senderID
peripherals[peripheralUUID] = state
}
peerToPeripheralUUID[senderID] = peripheralUUID
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
refreshLocalTopology()
}
@@ -2065,10 +1916,9 @@ extension BLEService: CBPeripheralDelegate {
guard shouldRediscover else { return }
let peripheralID = peripheral.identifier.uuidString
if var state = peripherals[peripheralID] {
state.characteristic = nil
state.assembler = NotificationStreamAssembler()
peripherals[peripheralID] = state
linkStateStore.updatePeripheral(peripheralID) {
$0.characteristic = nil
$0.assembler = NotificationStreamAssembler()
}
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
@@ -2123,9 +1973,7 @@ extension BLEService: CBPeripheralManagerDelegate {
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
peripheral.stopAdvertising()
// Clear subscribed centrals (they are now invalid)
let centralPeerIDs = centralToPeerID.values.map { $0 }
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
let centralPeerIDs = linkStateStore.clearCentrals()
centralSubscriptionRateLimits.removeAll()
characteristic = nil
// Notify UI of disconnections
@@ -2139,8 +1987,7 @@ extension BLEService: CBPeripheralManagerDelegate {
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
_ = linkStateStore.clearCentrals()
centralSubscriptionRateLimits.removeAll()
characteristic = nil
@@ -2204,7 +2051,7 @@ extension BLEService: CBPeripheralManagerDelegate {
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
let centralUUID = central.identifier.uuidString
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))", category: .session)
subscribedCentrals.append(central)
linkStateStore.addSubscribedCentral(central)
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
let now = Date()
@@ -2283,7 +2130,7 @@ extension BLEService: CBPeripheralManagerDelegate {
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString.prefix(8))", category: .session)
subscribedCentrals.removeAll { $0.identifier == central.identifier }
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false {
@@ -2292,8 +2139,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
// Find and disconnect the peer associated with this central
let centralUUID = central.identifier.uuidString
if let peerID = centralToPeerID[centralUUID] {
if let peerID = removedPeerID {
// Mark peer as not connected; retain for reachability
collectionsQueue.sync(flags: .barrier) {
if var info = peers[peerID] {
@@ -2302,8 +2148,6 @@ extension BLEService: CBPeripheralManagerDelegate {
}
}
// Clean up mappings
centralToPeerID.removeValue(forKey: centralUUID)
refreshLocalTopology()
// Update UI immediately
@@ -2439,7 +2283,7 @@ extension BLEService: CBPeripheralManagerDelegate {
let context = makeIngressPacketContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: centralToPeerID[centralUUID],
boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID),
linkDescription: "Central \(centralUUID.prefix(8))"
)
guard let context else { return }
@@ -2452,13 +2296,11 @@ extension BLEService: CBPeripheralManagerDelegate {
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))", category: .session)
}
if !subscribedCentrals.contains(central) {
subscribedCentrals.append(central)
}
linkStateStore.addSubscribedCentral(central)
if packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
centralToPeerID[centralUUID] = claimedSenderID
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
refreshLocalTopology()
}
@@ -2537,7 +2379,7 @@ extension BLEService {
(
connected: peers.values.filter { $0.isConnected }.count,
known: peers.count,
candidates: connectionCandidates.count
candidates: connectionScheduler.candidateCount
)
}
@@ -2662,39 +2504,12 @@ extension BLEService {
/// Safely fetch the current direct-link state for a peer using the BLE queue.
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
let computeState = { () -> (Bool, Bool) in
let peripheralUUID = self.peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { self.peripherals[$0]?.isConnected } ?? false
let hasCentral = self.centralToPeerID.values.contains(peerID)
return (hasPeripheral, hasCentral)
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return computeState()
} else {
return bleQueue.sync { computeState() }
}
let state = readLinkState { $0.directLinkState(for: peerID) }
return (state.hasPeripheral, state.hasCentral)
}
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
guard let peerID else { return [] }
let computeLinks = { () -> Set<BLEIngressLinkID> in
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = self.peerToPeripheralUUID[peerID] {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in self.centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return computeLinks()
} else {
return bleQueue.sync { computeLinks() }
}
readLinkState { $0.links(to: peerID) }
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
@@ -2751,20 +2566,25 @@ extension BLEService {
}
// MARK: Link capability snapshots (thread-safe via bleQueue)
private func snapshotPeripheralStates() -> [PeripheralState] {
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return Array(peripherals.values)
return body(linkStateStore)
} else {
return bleQueue.sync { Array(peripherals.values) }
return bleQueue.sync { body(linkStateStore) }
}
}
private func snapshotSubscribedCentrals() -> ([CBCentral], [String: PeerID]) {
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return (self.subscribedCentrals, self.centralToPeerID)
} else {
return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }
}
private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
readLinkState { $0.directPeripheralState(for: peerID) }
}
private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] {
readLinkState(\.peripheralStates)
}
private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot {
readLinkState(\.subscribedCentralSnapshot)
}
// MARK: Helpers: IDs, selection, and write backpressure
@@ -2802,7 +2622,7 @@ extension BLEService {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
// Atomically take all pending items from the queue to avoid race conditions
// where new items could be enqueued between read and update
@@ -2841,7 +2661,7 @@ extension BLEService {
private func drainAllPendingWrites() {
let uuids = collectionsQueue.sync { pendingPeripheralWrites.peripheralIDs }
for uuid in uuids {
guard let state = peripherals[uuid], state.isConnected else { continue }
guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue }
drainPendingWrites(for: state.peripheral)
}
}
@@ -4022,7 +3842,7 @@ extension BLEService {
// Clean old connection timeout backoff entries (> window)
let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }
connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff)
// Clean up stale scheduled relays that somehow persisted (> 2s)
collectionsQueue.async(flags: .barrier) { [weak self] in
@@ -4117,32 +3937,10 @@ extension BLEService {
}
private func updateRSSIThreshold(connectedCount: Int) {
// Adjust RSSI threshold based on connectivity, candidate pressure, and failures
if connectedCount == 0 {
// Isolated: relax floor slowly to hunt for distant nodes
if lastIsolatedAt == nil { lastIsolatedAt = Date() }
let iso = lastIsolatedAt ?? Date()
let elapsed = Date().timeIntervalSince(iso)
if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds {
dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed
} else {
dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase
}
return
}
lastIsolatedAt = nil
// Base threshold when connected
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
// If we're at budget or queue is large, prefer closer peers
let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
threshold = TransportConfig.bleRSSIConnectedThreshold
}
// If we have many recent timeouts, raise further
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count
if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
}
dynamicRSSIThreshold = threshold
connectionScheduler.updateRSSIThreshold(
connectedCount: connectedCount,
connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
now: Date()
)
}
}
@@ -0,0 +1,156 @@
import Foundation
import Testing
@testable import bitchat
struct BLEConnectionSchedulerTests {
@Test
func discoveryQueuesWeakSignalCandidates() {
let scheduler = BLEConnectionScheduler<String>(dynamicRSSIThreshold: -80)
let now = Date()
let candidate = makeCandidate(id: "p1", rssi: -85, now: now)
let decision = scheduler.handleDiscovery(
candidate,
connectedOrConnectingCount: 0,
existingState: nil,
peripheralState: .disconnected,
now: now
)
#expect(decision == .queued)
#expect(scheduler.candidateCount == 1)
}
@Test
func discoveryQueuesAndSchedulesRetryWhenRateLimited() {
let scheduler = BLEConnectionScheduler<String>(connectRateLimitInterval: 1.0)
let now = Date()
scheduler.recordConnectionAttempt(at: now)
let decision = scheduler.handleDiscovery(
makeCandidate(id: "p1", rssi: -50, now: now),
connectedOrConnectingCount: 0,
existingState: nil,
peripheralState: .disconnected,
now: now.addingTimeInterval(0.25)
)
guard case .scheduleRetry(let delay) = decision else {
Issue.record("Expected scheduleRetry, got \(decision)")
return
}
#expect(delay > 0)
#expect(scheduler.candidateCount == 1)
}
@Test
func nextCandidateSelectsBestScoredCandidate() {
let scheduler = BLEConnectionScheduler<String>()
let now = Date()
scheduler.enqueue(makeCandidate(id: "weak", rssi: -88, now: now))
scheduler.enqueue(makeCandidate(id: "strong", rssi: -60, now: now.addingTimeInterval(1)))
let decision = scheduler.nextCandidate(
connectedOrConnectingCount: 0,
isAlreadyConnectingOrConnected: { _ in false },
now: now.addingTimeInterval(2)
)
guard case .connect(let candidate) = decision else {
Issue.record("Expected connect decision")
return
}
#expect(candidate.peripheralID == "strong")
}
@Test
func enqueueReplacesExistingPeripheralCandidate() {
let scheduler = BLEConnectionScheduler<String>()
let now = Date()
scheduler.enqueue(makeCandidate(id: "same", rssi: -90, now: now))
scheduler.enqueue(makeCandidate(id: "same", rssi: -55, now: now.addingTimeInterval(1)))
let decision = scheduler.nextCandidate(
connectedOrConnectingCount: 0,
isAlreadyConnectingOrConnected: { _ in false },
now: now.addingTimeInterval(2)
)
guard case .connect(let candidate) = decision else {
Issue.record("Expected connect decision")
return
}
#expect(scheduler.candidateCount == 0)
#expect(candidate.peripheralID == "same")
#expect(candidate.rssi == -55)
}
@Test
func weakTimedOutCandidateIsRequeuedWithRetryDelay() {
let scheduler = BLEConnectionScheduler<String>(
weakLinkCooldownSeconds: 30,
weakLinkRSSICutoff: -90
)
let now = Date()
scheduler.recordConnectionTimeout(peripheralID: "weak", at: now)
scheduler.enqueue(makeCandidate(id: "weak", rssi: -95, now: now))
let decision = scheduler.nextCandidate(
connectedOrConnectingCount: 0,
isAlreadyConnectingOrConnected: { _ in false },
now: now.addingTimeInterval(5)
)
guard case .retryAfter(let delay) = decision else {
Issue.record("Expected retryAfter decision")
return
}
#expect(delay == 15)
#expect(scheduler.candidateCount == 1)
}
@Test
func rssiThresholdTightensAfterRepeatedRecentTimeouts() {
let scheduler = BLEConnectionScheduler<String>()
let now = Date()
scheduler.recordConnectionTimeout(peripheralID: "p1", at: now)
scheduler.recordConnectionTimeout(peripheralID: "p2", at: now)
scheduler.recordConnectionTimeout(peripheralID: "p3", at: now)
let threshold = scheduler.updateRSSIThreshold(
connectedCount: 1,
connectedOrConnectingLinkCount: 1,
now: now.addingTimeInterval(1)
)
#expect(threshold == TransportConfig.bleRSSIHighTimeoutThreshold)
#expect(scheduler.dynamicRSSIThreshold == TransportConfig.bleRSSIHighTimeoutThreshold)
}
@Test
func rssiThresholdTightensWhenCandidateQueueIsFull() {
let scheduler = BLEConnectionScheduler<String>(candidateCap: 2)
let now = Date()
scheduler.enqueue(makeCandidate(id: "p1", rssi: -65, now: now))
scheduler.enqueue(makeCandidate(id: "p2", rssi: -66, now: now))
let threshold = scheduler.updateRSSIThreshold(
connectedCount: 1,
connectedOrConnectingLinkCount: 1,
now: now
)
#expect(threshold == TransportConfig.bleRSSIConnectedThreshold)
}
}
private func makeCandidate(id: String, rssi: Int, now: Date) -> BLEConnectionCandidate<String> {
BLEConnectionCandidate(
peripheral: id,
peripheralID: id,
rssi: rssi,
name: id,
isConnectable: true,
discoveredAt: now
)
}
@@ -0,0 +1,46 @@
import BitFoundation
import Testing
@testable import bitchat
struct BLELinkStateStoreTests {
@Test
func centralBindingExposesDirectLinkStateAndLinks() {
let store = BLELinkStateStore()
let peerID = PeerID(str: "1122334455667788")
store.bindCentral("central-a", to: peerID)
#expect(store.peerID(forCentralUUID: "central-a") == peerID)
#expect(store.directLinkState(for: peerID) == BLEDirectLinkState(hasPeripheral: false, hasCentral: true))
#expect(store.links(to: peerID) == [.central("central-a")])
}
@Test
func linksReturnsAllCentralBindingsForPeer() {
let store = BLELinkStateStore()
let peerID = PeerID(str: "1122334455667788")
let otherPeerID = PeerID(str: "8899aabbccddeeff")
store.bindCentral("central-a", to: peerID)
store.bindCentral("central-b", to: peerID)
store.bindCentral("central-c", to: otherPeerID)
#expect(store.links(to: peerID) == [.central("central-a"), .central("central-b")])
}
@Test
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
let store = BLELinkStateStore()
let firstPeerID = PeerID(str: "1122334455667788")
let secondPeerID = PeerID(str: "8899aabbccddeeff")
store.bindCentral("central-a", to: firstPeerID)
store.bindCentral("central-b", to: secondPeerID)
let removedPeerIDs = Set(store.clearCentrals())
#expect(removedPeerIDs == Set([firstPeerID, secondPeerID]))
#expect(store.peerID(forCentralUUID: "central-a") == nil)
#expect(store.links(to: firstPeerID).isEmpty)
}
}