BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)

iOS cancels nothing for us: pending CBCentralManager connects never expire,
complete whenever the peer reappears in range, and relaunch the app via the
existing state-restoration path. Use that as the wake-on-proximity mechanism:

- BLERecentPeripheralCache: retains handles to recently seen/dropped
  peripherals (LRU 16, 15 min max age to respect BLE address rotation)
- On backgrounding, arm indefinite pending connects to cached peripherals
  within a slot budget (2 of 6 central slots reserved for live background
  discovery); armed entries carry lastConnectionAttempt == nil so a quick
  background/foreground bounce can't strand them as connecting
- The 8s app-level connect timeout defers while backgrounded so
  discovery-driven background connects also stay pending
- Foreground return cancels stale pending connects (including connecting
  entries rebuilt by state restoration after a relaunch) and hands control
  back to the scanner/scheduler
- A link dropped while backgrounded re-arms after the disconnect-settle
  window, so a peer walking away and returning wakes us again
- Packet ingress while backgrounded triggers a catch-up maintenance pass
  (announce/flush/drain) since the maintenance timer is suspended with the
  app; rate-limited to the normal 5s cadence

Battery cost ~0: pending connects live in the controller's allowlist (no
scanning, no app CPU), and the catch-up pass only runs inside wake windows
the radio already granted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 18:18:41 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent bbe5e1ef4e
commit b043324035
4 changed files with 304 additions and 3 deletions
@@ -0,0 +1,53 @@
import Foundation
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
/// links) so the service can arm pending background connections against them
/// when the app leaves the foreground. Generic over the peripheral type so
/// the eviction/expiry logic is testable without CoreBluetooth.
final class BLERecentPeripheralCache<Peripheral> {
private struct Entry {
let peripheral: Peripheral
var lastSeen: Date
}
private var entries: [String: Entry] = [:]
private let capacity: Int
private let maxAge: TimeInterval
init(
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
) {
self.capacity = capacity
self.maxAge = maxAge
}
var count: Int { entries.count }
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
guard entries.count > capacity else { return }
// Inserts overshoot capacity by at most one; evict the stalest entry
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
entries.removeValue(forKey: stalest.key)
}
}
/// Most-recently-seen peripherals eligible for a pending background
/// connect, freshest first, capped at `limit`. Expired entries are
/// pruned as a side effect.
func reconnectTargets(
now: Date,
limit: Int,
excluding: (String) -> Bool
) -> [(peripheralID: String, peripheral: Peripheral)] {
let cutoff = now.addingTimeInterval(-maxAge)
entries = entries.filter { $0.value.lastSeen >= cutoff }
guard limit > 0 else { return [] }
return entries
.filter { !excluding($0.key) }
.sorted { $0.value.lastSeen > $1.value.lastSeen }
.prefix(limit)
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
}
}
+139
View File
@@ -197,6 +197,7 @@ final class BLEService: NSObject {
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles
private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes
/// Whether real CoreBluetooth managers were initialized. When false (unit
/// tests), periodic mesh background work is not started the maintenance
/// timer and the gossip-sync timers only drain BLE writes/notifications,
@@ -207,6 +208,9 @@ final class BLEService: NSObject {
// MARK: - Connection budget & scheduling (central role)
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects (bleQueue-confined, like the link state store)
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer?
@@ -1609,6 +1613,9 @@ extension BLEService: CBCentralManagerDelegate {
isConnectable: isConnectable,
discoveredAt: Date()
)
if isConnectable {
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
}
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
switch connectionScheduler.handleDiscovery(
@@ -1636,6 +1643,14 @@ extension BLEService: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
// A connect completing while backgrounded is the wake-on-proximity
// path doing its job worth an info line for field verification.
if !isAppActive {
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
}
#endif
// Update state to connected
linkStateStore.markConnected(peripheral)
@@ -1661,6 +1676,23 @@ extension BLEService: CBCentralManagerDelegate {
connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
}
// Retain the handle: a dropped link is the best wake-on-proximity
// candidate if the app backgrounds before the peer returns.
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date())
#if os(iOS)
// Link lost while backgrounded (peer walked away): re-arm a pending
// connect during this wake window so the peer's return wakes us again.
// Delayed past the disconnect-settle window to avoid reconnect thrash
// at range edge.
if !isAppActive {
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
guard let self, !self.isAppActive else { return }
self.armPendingBackgroundConnects()
}
}
#endif
// Clean up references and peer mappings
_ = linkStateStore.removePeripheral(peripheralID)
if let peerID {
@@ -1788,6 +1820,18 @@ extension BLEService {
return
}
#if os(iOS)
if !self.isAppActive {
// Backgrounded: leave the connect pending. iOS never expires
// it the controller completes it whenever the peer comes
// back into range, waking the app (state restoration relaunches
// us if we were terminated). Foreground return cancels stale
// pendings via cancelStalePendingConnects().
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
return
}
#endif
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
_ = self.linkStateStore.removePeripheral(peripheralID)
@@ -3407,6 +3451,7 @@ extension BLEService {
centralManager?.stopScan()
startScanning()
}
cancelStalePendingConnects()
logBluetoothStatus("became-active")
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
// No Local Name; nothing to refresh for advertising policy
@@ -3419,6 +3464,7 @@ extension BLEService {
centralManager?.stopScan()
startScanning()
}
armPendingBackgroundConnects()
// Backgrounding may precede a kill; flush the public-history archive
// outside its 30s maintenance cadence.
gossipSyncManager?.persistNow()
@@ -3426,6 +3472,76 @@ extension BLEService {
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
// No Local Name; nothing to refresh for advertising policy
}
/// Issue indefinite `connect()` requests to recently seen peripherals on
/// backgrounding. Pending connects live in the Bluetooth controller's
/// allowlist no scanning and no app CPU and complete whenever a peer
/// comes into range, waking (or relaunching) the app. A couple of central
/// slots stay reserved for connects driven by live background discovery.
private func armPendingBackgroundConnects() {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
let budget = TransportConfig.bleMaxCentralLinks
- TransportConfig.bleBackgroundPendingConnectSlotReserve
- self.linkStateStore.connectedOrConnectingPeripheralCount
let now = Date()
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
}
guard !targets.isEmpty else { return }
for target in targets {
// lastConnectionAttempt stays nil: an indefinite pending connect
// has no attempt clock, and nil marks it always-stale so
// cancelStalePendingConnects() reclaims it on foreground even
// after a quick backgroundforeground bounce.
self.linkStateStore.setPeripheralState(
BLEPeripheralLinkState(
peripheral: target.peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: target.peripheralID
)
target.peripheral.delegate = self
central.connect(target.peripheral, options: [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
])
}
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
}
}
/// Foreground restores normal connection management: pending connects
/// older than the connect timeout (including ones rebuilt by state
/// restoration after a relaunch) are cancelled so live scanning and the
/// scheduler take over. Anything still nearby is rediscovered within
/// seconds by the allow-duplicates foreground scan.
private func cancelStalePendingConnects() {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager else { return }
let now = Date()
var cancelled = 0
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
let peripheralID = state.peripheral.identifier.uuidString
central.cancelPeripheralConnection(state.peripheral)
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
if cancelled > 0 {
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
self.tryConnectFromQueue()
}
}
}
#endif
// MARK: Private Message Handling
@@ -3774,6 +3890,16 @@ extension BLEService {
}
}
#if os(iOS)
// The maintenance timer is suspended with the app, so a packet arriving
// while backgrounded means the radio woke us use the wake window to
// run the announce/flush/drain pass the timer would have run.
if !isAppActive {
bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
}
#endif
// Process by type
switch context.messageType {
case .announce:
@@ -4270,6 +4396,7 @@ extension BLEService {
private func performMaintenance() {
maintenanceCounter += 1
lastMaintenanceAt = Date()
let now = Date()
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
@@ -4334,6 +4461,18 @@ extension BLEService {
}
}
#if os(iOS)
/// Catch-up maintenance for background wake windows (bleQueue-confined).
/// Rate-limited to the normal maintenance cadence so a burst of inbound
/// packets during one wake still runs at most one extra pass.
private func performBackgroundWakeMaintenanceIfStale() {
guard meshBackgroundEnabled,
!isAppActive,
Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
performMaintenance()
}
#endif
private func checkPeerConnectivity() {
let now = Date()
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
+10
View File
@@ -242,6 +242,16 @@ enum TransportConfig {
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
// Background wake-on-proximity (iOS). Pending connects issued on
// backgrounding never expire at the OS level: the Bluetooth controller
// completes them whenever the peer reappears in range and relaunches the
// app via state restoration. Entries older than the BLE address-rotation
// window no longer map to a reachable address, so the cache prunes them.
static let bleRecentPeripheralCacheCap: Int = 16
static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60
// Central slots kept free for connects driven by live background discovery
static let bleBackgroundPendingConnectSlotReserve: Int = 2
// Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90
@@ -0,0 +1,99 @@
//
// BLERecentPeripheralCacheTests.swift
// bitchatTests
//
// Eviction, expiry, and reconnect-target selection for the background
// wake-on-proximity peripheral cache.
//
import Testing
import Foundation
@testable import bitchat
struct BLERecentPeripheralCacheTests {
private let base = Date(timeIntervalSince1970: 1_700_000_000)
private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache<String> {
BLERecentPeripheralCache<String>(capacity: capacity, maxAge: maxAge)
}
@Test
func recordUpsertsByPeripheralID() {
let cache = makeCache()
cache.record("p1", peripheralID: "A", at: base)
cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10))
#expect(cache.count == 1)
let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false }
#expect(targets.map(\.peripheral) == ["p1-updated"])
}
@Test
func overCapacityEvictsStalestEntry() {
let cache = makeCache(capacity: 2)
cache.record("p1", peripheralID: "A", at: base)
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2))
#expect(cache.count == 2)
let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false }
#expect(targets.map(\.peripheralID) == ["C", "B"])
}
@Test
func refreshingAnEntryProtectsItFromEviction() {
let cache = makeCache(capacity: 2)
cache.record("p1", peripheralID: "A", at: base)
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
// A becomes the freshest again; adding C must evict B, not A
cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2))
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3))
let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false }
#expect(targets.map(\.peripheralID) == ["C", "A"])
}
@Test
func expiredEntriesArePruned() {
let cache = makeCache(maxAge: 100)
cache.record("p1", peripheralID: "A", at: base)
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50))
let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false }
#expect(targets.map(\.peripheralID) == ["B"])
#expect(cache.count == 1)
}
@Test
func targetsAreFreshestFirstAndCappedAtLimit() {
let cache = makeCache(capacity: 8)
for (index, id) in ["A", "B", "C", "D"].enumerated() {
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
}
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false }
#expect(targets.map(\.peripheralID) == ["D", "C"])
}
@Test
func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() {
let cache = makeCache(capacity: 8)
for (index, id) in ["A", "B", "C"].enumerated() {
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
}
// C (freshest) is already connected; the two slots go to B and A
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" }
#expect(targets.map(\.peripheralID) == ["B", "A"])
}
@Test
func nonPositiveLimitReturnsNothing() {
let cache = makeCache()
cache.record("p1", peripheralID: "A", at: base)
#expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty)
#expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty)
}
}