diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index de5d99b..ccfbdca 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -34,12 +34,47 @@ extension ConnectionState { var description: String { switch self { case .connecting: "Connecting" + case .reconnecting(let source, let attempt, _): + switch source { + case .system: "System reconnecting…" + case .library: "Reconnecting (attempt \(attempt ?? 0))" + } case .connected: "Connected" case .disconnecting: "Disconnecting" case .disconnected(let reason): "Disconnected\(reason.map { " (\($0))" } ?? "")" case .failed(let reason): "Failed\(reason.map { " (\($0))" } ?? "")" } } + + var nextRetryAt: Date? { + if case .reconnecting(_, _, let date) = self { return date } + return nil + } + + var color: Color { + switch self { + case .connected: .green + case .reconnecting: .yellow + case .disconnected(let reason): reason == nil ? .secondary : .orange + case .failed: .red + default: .orange + } + } + + /// True when a connection is active or in progress (including reconnect). + var isActiveConnection: Bool { + switch self { + case .connecting, .connected, .disconnecting, .reconnecting: + true + case .disconnected, .failed: + false + } + } + + /// Auto-reconnect can only be changed when initiating a fresh connect. + var canEditAutoReconnect: Bool { + !isActiveConnection + } } struct CentralView: View { @@ -140,37 +175,11 @@ struct CentralView: View { List { ForEach(devices, id: \.persistentModelID) { device in NavigationLink { - Group { - let currentState = viewModel.connectionStates[device.id] - VStack(spacing: 16) { - Text("ID: \(device.id)") - Text("Last seen: \(device.lastSeen?.formatted(date: .numeric, time: .standard) ?? "")") - - if let state = currentState { - Text("Connection: \(state.description)") - .foregroundStyle(state == .connected ? Color.green : Color.orange) - } else { - Text("Connection: unknown") - .foregroundStyle(.secondary) - } - - Button(action: { - if let state = currentState, state == .connected { - Task { try? await reliaBLE.disconnect(from: Peripheral(id: device.id)) } - } else { - Task { try? await reliaBLE.connect(to: Peripheral(id: device.id)) } - } - }) { - if let state = currentState, state == .connected { - Text("Disconnect") - } else { - Text("Connect") - } - } - .buttonStyle(.bordered) - } - .padding() - } + DeviceDetailView( + device: device, + viewModel: viewModel, + reliaBLE: reliaBLE + ) } label: { Text("\(device.name ?? "Unknown")") } @@ -201,10 +210,82 @@ struct CentralView: View { } } +private struct DeviceDetailView: View { + let device: Device + let viewModel: CentralViewModel + let reliaBLE: ReliaBLEManager + + @State private var autoReconnect = true + + private var connectionState: ConnectionState? { + viewModel.connectionStates[device.id] + } + + private var isActive: Bool { + connectionState?.isActiveConnection == true + } + + var body: some View { + VStack(spacing: 16) { + Text("ID: \(device.id)") + Text("Last seen: \(device.lastSeen?.formatted(date: .numeric, time: .standard) ?? "")") + + if let state = connectionState { + Text("Connection: \(state.description)") + .foregroundStyle(state.color) + + if let retryAt = state.nextRetryAt { + CountdownView(targetDate: retryAt) + } + } else { + Text("Connection: unknown") + .foregroundStyle(.secondary) + } + + Button(action: { + if isActive { + Task { try? await reliaBLE.disconnect(from: Peripheral(id: device.id)) } + } else { + Task { + try? await reliaBLE.connect( + to: Peripheral(id: device.id), + autoReconnect: autoReconnect + ) + } + } + }) { + Text(isActive ? "Disconnect" : "Connect") + } + .buttonStyle(.bordered) + + Toggle("Auto Reconnect:", isOn: $autoReconnect) + .disabled(connectionState.map { !$0.canEditAutoReconnect } ?? false) + } + .padding() + } +} + +private struct CountdownView: View { + let targetDate: Date + + @State private var now = Date() + + var body: some View { + let remaining = max(0, targetDate.timeIntervalSince(now)) + Text("Next retry in: \(Int(remaining))s") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in + now = Date() + } + } +} + #Preview { CentralView() .modelContainer( for: [Device.self, DiscoveryEvent.self], inMemory: true ) -} \ No newline at end of file +} diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift index e8af637..9b9e934 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift @@ -61,6 +61,14 @@ struct ReliaBLE_DemoApp: App { config.logQueue = DispatchQueue(label: "com.five3apps.relia-ble-demo.logging", qos: .utility) config.loggingEnabled = true + let defaults = UserDefaults.standard + var reconnectPolicy = ReconnectPolicy() + reconnectPolicy.maxAttempts = defaults.object(forKey: "reconnectPolicy.maxAttempts") as? Int ?? 5 + reconnectPolicy.initialDelay = defaults.object(forKey: "reconnectPolicy.initialDelay") as? Double ?? 1.0 + reconnectPolicy.maxDelay = defaults.object(forKey: "reconnectPolicy.maxDelay") as? Double ?? 30.0 + reconnectPolicy.jitter = defaults.object(forKey: "reconnectPolicy.jitter") as? Double ?? 0.2 + config.reconnectPolicy = reconnectPolicy + return ReliaBLEManager(config: config) }() diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift index 5b06a6c..5ecba83 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift @@ -32,10 +32,42 @@ struct SettingsView: View { @Environment(\.bleManager) private var reliaBLE @State private var isLoggingEnabled: Bool = false + @AppStorage("reconnectPolicy.maxAttempts") private var maxAttempts = 5 + @AppStorage("reconnectPolicy.initialDelay") private var initialDelay = 1.0 + @AppStorage("reconnectPolicy.maxDelay") private var maxDelay = 30.0 + @AppStorage("reconnectPolicy.jitter") private var jitter = 0.2 + var body: some View { NavigationView { Form { - Toggle("Enable Logging", isOn: $isLoggingEnabled) + Section("Logging") { + Toggle("Enable Logging", isOn: $isLoggingEnabled) + } + + Section { + Stepper("Max Attempts: \(maxAttempts)", value: $maxAttempts, in: 1...20) + + VStack(alignment: .leading, spacing: 4) { + Text("Initial Delay: \(String(format: "%.1f", initialDelay))s") + Slider(value: $initialDelay, in: 0.5...10, step: 0.5) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Max Delay: \(String(format: "%.0f", maxDelay))s") + Slider(value: $maxDelay, in: 5...120, step: 5) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Jitter: \(String(format: "%.2f", jitter))") + Slider(value: $jitter, in: 0...0.5, step: 0.05) + } + + Text("Changes take effect on next app launch.") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Reconnect Policy") + } } .navigationTitle("Settings") } diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index e897693..291e5eb 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -47,6 +47,7 @@ private struct DiscoveryPayload: @unchecked Sendable { private struct ConnectionPayload: @unchecked Sendable { let peripheral: CBPeripheral + let isReconnecting: Bool let error: Error? } @@ -130,6 +131,12 @@ actor BluetoothActor { private var connectionStateChangesContinuations: [UUID: AsyncStream.Continuation] = [:] + private var reconnectPolicy: ReconnectPolicy = ReconnectPolicy() + private var reconnectEnabled: Set = [] + private var intentionalDisconnects: Set = [] + private var reconnectAttempts: [String: Int] = [:] + private var reconnectTasks: [String: Task] = [:] + // MARK: - Initialization private init() {} @@ -281,14 +288,16 @@ actor BluetoothActor { /// the lazy-permission contract: the iOS prompt only appears when the integrating app calls /// ``ReliaBLEManager/authorizeBluetooth()``. The initial state is broadcast on first setup and /// whenever the manager is created, but not on every redundant call. - func ensureInitialized(log: LoggingService) { + func ensureInitialized(log: LoggingService, reconnectPolicy: ReconnectPolicy = ReconnectPolicy()) { let firstInitialization = !isInitialized if firstInitialization { isInitialized = true configure(log: log) + self.reconnectPolicy = reconnectPolicy } var createdManager = false + if centralManager == nil, CBCentralManager.authorization == .allowedAlways { setupCentralManager() createdManager = true @@ -618,6 +627,11 @@ actor BluetoothActor { // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. cbPeripherals.removeAll() connectionStates.removeAll() + for task in reconnectTasks.values { task.cancel() } + reconnectTasks.removeAll() + reconnectAttempts.removeAll() + reconnectEnabled.removeAll() + intentionalDisconnects.removeAll() broadcast(discoveredPeripherals, to: peripheralsContinuations) log?.debug("Invalidated all peripheral references") } @@ -642,6 +656,73 @@ actor BluetoothActor { } // MARK: - Connection + + /// Initiates a connection to the live peripheral backing the given snapshot `id`. + /// + /// Optimistically broadcasts `.connecting` before the CoreBluetooth call, then issues the + /// connection request. The actual `.connected` or `.failed` callback arrives later via the + /// delegate pipeline. + /// + /// - Parameter id: The ``Peripheral/id`` of a previously discovered peripheral. + /// - Parameter autoReconnect: When `true`, the OS auto-reconnect option is passed and the + /// library ladder may arm on failure. When `false`, reconnection is suppressed entirely. + /// - Throws: ``PeripheralError/notFound`` if no live `CBPeripheral` is registered for `id` (a stale snapshot). + /// - Throws: ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up. + func connect(id: String, autoReconnect: Bool = true) throws { + guard let centralManager else { + log?.warn(tags: [.peripheral(id)], "Attempted to connect without a central manager") + throw PeripheralError.bluetoothUnavailable + } + + guard let cbPeripheral = cbPeripherals[id] else { + throw PeripheralError.notFound + } + + if autoReconnect { + reconnectEnabled.insert(id) + } else { + reconnectEnabled.remove(id) + } + intentionalDisconnects.remove(id) + connectionStates[id] = .connecting + broadcast(ConnectionStateChange(peripheralId: id, state: .connecting), to: connectionStateChangesContinuations) + + var options: [String: Any]? + if #available(macOS 14.0, iOS 17.0, *) { + options = autoReconnect ? [CBConnectPeripheralOptionEnableAutoReconnect: true] : nil + } + centralManager.connect(cbPeripheral, options: options) + } + + /// Initiates a disconnection from the live peripheral backing the given snapshot `id`. + /// + /// Optimistically broadcasts `.disconnecting` before cancelling the connection. The actual + /// `.disconnected` callback arrives later via the delegate pipeline. + /// + /// - Parameter id: The ``Peripheral/id`` of a previously connected peripheral. + /// - Throws: ``PeripheralError/notFound`` if no live `CBPeripheral` is registered for `id`. + /// - Throws: ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up. + func disconnect(id: String) throws { + guard let centralManager else { + log?.warn(tags: [.peripheral(id)], "Attempted to disconnect without a central manager") + throw PeripheralError.bluetoothUnavailable + } + + guard let cbPeripheral = cbPeripherals[id] else { + throw PeripheralError.notFound + } + + // Reset auto-reconnect since this was an explicit disconnect + intentionalDisconnects.insert(id) + reconnectEnabled.remove(id) + reconnectTasks[id]?.cancel() + reconnectTasks[id] = nil + reconnectAttempts[id] = nil + + connectionStates[id] = .disconnecting + broadcast(ConnectionStateChange(peripheralId: id, state: .disconnecting), to: connectionStateChangesContinuations) + centralManager.cancelPeripheralConnection(cbPeripheral) + } /// Resolves a ``Peripheral/id`` from the live `CBPeripheral` reference using reverse object-identity lookup. /// @@ -658,6 +739,9 @@ actor BluetoothActor { log?.warn(tags: [.category(.connection)], "didConnect for unknown peripheral — dropped") return } + + clearReconnectState(for: id) + connectionStates[id] = .connected log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral connected") broadcast(ConnectionStateChange(peripheralId: id, state: .connected), to: connectionStateChangesContinuations) @@ -668,15 +752,48 @@ actor BluetoothActor { log?.warn(tags: [.category(.connection)], "didDisconnect for unknown peripheral — dropped") return } + + if intentionalDisconnects.remove(id) != nil { + // Contract: `.disconnected(reason:)` carries `nil` for a clean, app-initiated disconnect. + // CoreBluetooth can still deliver a benign cancellation-style error on-device for an + // explicit `cancelPeripheralConnection`, so we intentionally ignore `payload.error` here + // and always report a clean disconnect — otherwise the app/Demo would misclassify an + // intentional disconnect as an error drop. + let state: ConnectionState = .disconnected(reason: nil) + connectionStates[id] = state + log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected (explicit)") + + broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + + return + } + + if payload.isReconnecting { + // Defensively cancel any pending library ladder so Tier 0 (system) and Tier 1 + // (library) cannot overlap under odd callback ordering. + reconnectTasks[id]?.cancel() + reconnectTasks[id] = nil + + connectionStates[id] = .reconnecting(source: .system, attempt: nil, nextRetryAt: nil) + log?.info(tags: [.peripheral(id), .category(.connection)], "System auto-reconnect in progress") + + broadcast(ConnectionStateChange(peripheralId: id, state: .reconnecting(source: .system, attempt: nil, nextRetryAt: nil)), to: connectionStateChangesContinuations) + + return + } + let mappedError: PeripheralError? = payload.error.map { ($0 as? CBError).map(PeripheralError.fromCBError) ?? .unknown } let state: ConnectionState = .disconnected(reason: mappedError) connectionStates[id] = state + if let error = mappedError { log?.warn(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected with error: \(error)") } else { log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected") } + broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + armReconnect(id: id) } private func handleDidFailToConnect(_ payload: ConnectionPayload) { @@ -684,58 +801,139 @@ actor BluetoothActor { log?.warn(tags: [.category(.connection)], "didFailToConnect for unknown peripheral — dropped") return } + let mappedError: PeripheralError? = payload.error.map { ($0 as? CBError).map(PeripheralError.fromCBError) ?? .unknown } let state: ConnectionState = .failed(reason: mappedError) connectionStates[id] = state + log?.warn(tags: [.peripheral(id), .category(.connection)], "Peripheral connection failed with error: \(mappedError ?? .unknown)") + broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + armReconnect(id: id) } - /// Initiates a connection to the live peripheral backing the given snapshot `id`. - /// - /// Optimistically broadcasts `.connecting` before the CoreBluetooth call, then issues the - /// connection request. The actual `.connected` or `.failed` callback arrives later via the - /// delegate pipeline. - /// - /// - Parameter id: The ``Peripheral/id`` of a previously discovered peripheral. - /// - Throws: ``PeripheralError/notFound`` if no live `CBPeripheral` is registered for `id` (a stale snapshot). - /// - Throws: ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up. - func connect(id: String) throws { - guard let centralManager else { - log?.warn(tags: [.peripheral(id)], "Attempted to connect without a central manager") - throw PeripheralError.bluetoothUnavailable - } + // MARK: - Reconnection - guard let cbPeripheral = cbPeripherals[id] else { - throw PeripheralError.notFound + private func armReconnect(id: String) { + guard reconnectEnabled.contains(id) else { return } + + let attempts = reconnectAttempts[id] ?? 0 + guard reconnectPolicy.maxAttempts > 0, attempts < reconnectPolicy.maxAttempts else { + // Give-up clears in-flight ladder bookkeeping only. `reconnectEnabled` is deliberately + // retained so reconnection intent survives until an explicit `disconnect` — a later + // unexpected drop must start a fresh ladder from attempt 1. + clearReconnectState(for: id) + log?.info(tags: [.peripheral(id), .category(.connection)], "Reconnect attempts exhausted") + + return } - connectionStates[id] = .connecting - broadcast(ConnectionStateChange(peripheralId: id, state: .connecting), to: connectionStateChangesContinuations) - centralManager.connect(cbPeripheral, options: nil) + reconnectAttempts[id] = attempts + 1 + scheduleReconnect(id: id, attempt: attempts + 1) } - /// Initiates a disconnection from the live peripheral backing the given snapshot `id`. - /// - /// Optimistically broadcasts `.disconnecting` before cancelling the connection. The actual - /// `.disconnected` callback arrives later via the delegate pipeline. - /// - /// - Parameter id: The ``Peripheral/id`` of a previously connected peripheral. - /// - Throws: ``PeripheralError/notFound`` if no live `CBPeripheral` is registered for `id`. - /// - Throws: ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up. - func disconnect(id: String) throws { - guard let centralManager else { - log?.warn(tags: [.peripheral(id)], "Attempted to disconnect without a central manager") - throw PeripheralError.bluetoothUnavailable + private func scheduleReconnect(id: String, attempt: Int) { + reconnectTasks[id]?.cancel() + + // `ReconnectPolicy` is public and unvalidated; collapse any non-finite field (`nan`/`inf`) + // to a safe value here. Beyond the UInt64 conversion below, a non-finite `jitter` would also + // trap `Double.random(in: -jitter...jitter)` ("Range requires lowerBound <= upperBound"). + let initial = reconnectPolicy.initialDelay.isFinite ? max(0, reconnectPolicy.initialDelay) : 0 + let maxDelay = reconnectPolicy.maxDelay.isFinite ? max(initial, reconnectPolicy.maxDelay) : initial + let jitter = reconnectPolicy.jitter.isFinite ? min(max(reconnectPolicy.jitter, 0), 1) : 0 + + let baseDelay = min(initial * pow(2, Double(attempt - 1)), maxDelay) + let jittered = baseDelay * (1 + Double.random(in: -jitter...jitter)) + // `ReconnectPolicy` is public and its fields are unvalidated, so a caller could supply + // non-finite values (`nan`/`inf`). Collapse those to zero here so the `UInt64` nanosecond + // conversion below cannot trap at runtime. + let delaySeconds = jittered.isFinite ? max(0, jittered) : 0 + + // Clamp the nanosecond conversion to `UInt64` range to avoid an overflow trap for very + // large (but finite) configured delays. + let nanosDouble = (delaySeconds * 1_000_000_000).rounded() + let sleepNanos: UInt64 = nanosDouble >= Double(UInt64.max) ? .max : UInt64(nanosDouble) + + let nextRetryAt = Date().addingTimeInterval(delaySeconds) + connectionStates[id] = .reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt) + broadcast(ConnectionStateChange(peripheralId: id, state: .reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt)), to: connectionStateChangesContinuations) + + reconnectTasks[id] = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: sleepNanos) + } catch is CancellationError { + return + } catch { + return + } + guard let self, !Task.isCancelled else { + return + } + + await self.performReconnect(id: id, attempt: attempt) } + } - guard let cbPeripheral = cbPeripherals[id] else { - throw PeripheralError.notFound + private func performReconnect(id: String, attempt: Int) { + guard !Task.isCancelled, + reconnectAttempts[id] == attempt, + case .reconnecting(_, let currentAttempt?, _) = connectionStates[id], + currentAttempt == attempt + else { + return } + + do { + try connect(id: id, autoReconnect: true) + } catch { + let reason = (error as? PeripheralError) ?? .unknown + clearReconnectState(for: id) + let state: ConnectionState = .failed(reason: reason) + connectionStates[id] = state + log?.warn(tags: [.peripheral(id), .category(.connection)], "Reconnect attempt failed: \(reason)") + broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + } + } + + private func clearReconnectState(for id: String) { + reconnectTasks[id]?.cancel() + reconnectTasks[id] = nil + reconnectAttempts[id] = nil + intentionalDisconnects.remove(id) + } - connectionStates[id] = .disconnecting - broadcast(ConnectionStateChange(peripheralId: id, state: .disconnecting), to: connectionStateChangesContinuations) - centralManager.cancelPeripheralConnection(cbPeripheral) + /// Test-only hook + func setReconnectPolicy(_ policy: ReconnectPolicy) { + reconnectPolicy = policy + } + + /// Test-only hook: injects a disconnect event with the specified `isReconnecting` flag, + /// routing through the same `handleDidDisconnect(_:)` path as a real delegate callback. + /// + /// Needed because CoreBluetoothMock hardcodes `isReconnecting: true` after any connect + /// with `CBConnectPeripheralOptionEnableAutoReconnect`, making it impossible to simulate + /// an OS give-up (`isReconnecting: false`) through the mock's public API. Tests that need + /// `isReconnecting: false` (OS give-up → Tier-1 ladder, or unexpected drop without the + /// OS option) inject via this hook instead. + func testInjectDisconnect(for id: String, isReconnecting: Bool, error: Error? = nil) { + guard let cbPeripheral = cbPeripherals[id] else { return } + let payload = ConnectionPayload(peripheral: cbPeripheral, isReconnecting: isReconnecting, error: error) + handleDidDisconnect(payload) + } + + /// Test-only hook: runs the same peripheral invalidation as a Bluetooth reset/unauthorized path. + func testInvalidatePeripherals() { + invalidatePeripherals() + } + + /// Test-only hook: whether `id` is currently marked as an intentional disconnect. + func testContainsIntentionalDisconnect(_ id: String) -> Bool { + intentionalDisconnects.contains(id) + } + + /// Test-only hook: seeds intentional-disconnect intent without going through `disconnect(id:)`. + func testSeedIntentionalDisconnect(_ id: String) { + intentionalDisconnects.insert(id) } } @@ -777,17 +975,17 @@ final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - let payload = ConnectionPayload(peripheral: peripheral, error: nil) + let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: false, error: nil) eventContinuation.yield(.connected(payload)) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - let payload = ConnectionPayload(peripheral: peripheral, error: error) + let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: false, error: error) eventContinuation.yield(.connectFailed(payload)) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { - let payload = ConnectionPayload(peripheral: peripheral, error: error) + let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: isReconnecting, error: error) eventContinuation.yield(.disconnected(payload)) } } diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 0b4c493..b6c341d 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -143,7 +143,33 @@ If your app already knows a peripheral's identity ahead of time — for example, ## Connecting to a Peripheral -Use ``ReliaBLEManager/connect(to:)`` to initiate a connection to a discovered ``Peripheral``. Consume ``ReliaBLEManager/connectionStateChanges`` (an `AsyncStream`) to observe the full lifecycle for any peripheral; filter by `peripheralId` for a specific device. Call ``ReliaBLEManager/disconnect(from:)`` to end the session. +Use ``ReliaBLEManager/connect(to:autoReconnect:)`` to initiate a connection to a discovered ``Peripheral``. Consume ``ReliaBLEManager/connectionStateChanges`` (an `AsyncStream`) to observe the full lifecycle for any peripheral; filter by `peripheralId` for a specific device. Call ``ReliaBLEManager/disconnect(from:)`` to end the session. + +Reconnection is **on by default** via the `autoReconnect` parameter (default `true`). When active, ReliaBLE uses a **two-tier model**: + +1. **Tier 0 — System-managed (primary).** The connection request includes `CBConnectPeripheralOptionEnableAutoReconnect`, which asks the iOS daemon to re-establish the link itself after an unexpected drop. This is power-efficient, daemon-held, and keeps trying across app suspension. While the system retries, ReliaBLE emits ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)`` with ``ReconnectSource/system`` (`attempt` and `nextRetryAt` are both `nil` — iOS exposes neither). +2. **Tier 1 — Library-managed (supplement).** Covers what the OS option doesn't: initial-connect failures and drops where the OS gives up. The library arms an exponential-backoff ladder governed by ``ReconnectPolicy``, emitting ``ReconnectSource/library`` with populated `attempt` and `nextRetryAt` so your UI can show a countdown. + +To disable auto-reconnect for a one-shot connection (a sensor you pair with briefly, then disconnect), pass `autoReconnect: false`: + +```swift +try await bleManager.connect(to: peripheral, autoReconnect: false) +``` + +Tune the library backoff via ``ReliaBLEConfig/reconnectPolicy``: + +```swift +var config = ReliaBLEConfig() +config.reconnectPolicy.maxAttempts = 3 +config.reconnectPolicy.initialDelay = 1.0 // seconds before first retry +config.reconnectPolicy.maxDelay = 10.0 // cap exponential growth +config.reconnectPolicy.jitter = 0.2 // ±20% randomization +let bleManager = ReliaBLEManager(config: config) +``` + +> Important: `ReconnectPolicy` (and logging configuration) is applied only during the **first** `ReliaBLEManager` initialization (the first actor setup) behind the library's process-wide actor singleton. Constructing a second `ReliaBLEManager(config:)` in the same process will **not** update the already-stashed policy — set your desired config before creating the first manager instance. + +> Note: iOS's Tier-0 auto-reconnect give-up budget and timing (`CBConnectPeripheralOptionEnableAutoReconnect`) are not publicly documented by Apple. The exact retry duration and failure threshold still require on-device verification — this is deliberately deferred follow-up work. ```swift do { @@ -155,11 +181,18 @@ do { case .connected: print("Connected to \(peripheral.id)") return + case .reconnecting(let source, let attempt, let nextRetryAt): + switch source { + case .system: + print("System reconnecting…") + case .library: + print("Reconnecting attempt \(attempt ?? 0), next retry at \(nextRetryAt ?? .now)") + } case .disconnected(let reason): print("Disconnected", reason ?? "clean") return case .failed(let reason): - print("Failed", reason ?? "") + print("Failed", reason ?? "") return default: break @@ -168,7 +201,7 @@ do { } defer { observer.cancel() } - try await bleManager.connect(to: peripheral) + try await bleManager.connect(to: peripheral, autoReconnect: true) // Later, when you're done with the session: try await bleManager.disconnect(from: peripheral) @@ -181,4 +214,4 @@ do { } ``` -Each access to `connectionStateChanges` yields a fresh stream; it does not replay, so begin iteration before calling `connect(to:)`. The `ConnectionState` values are `.connecting`, `.connected`, `.disconnecting`, `.disconnected(reason:)`, and `.failed(reason:)`. Terminal states carry an optional ``PeripheralError`` reason. +Each access to `connectionStateChanges` yields a fresh stream; it does not replay, so begin iteration before calling ``ReliaBLEManager/connect(to:autoReconnect:)``. The `ConnectionState` values are ``ConnectionState/connecting``, ``ConnectionState/connected``, ``ConnectionState/disconnecting``, ``ConnectionState/disconnected(reason:)``, ``ConnectionState/failed(reason:)``, and ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)``. Terminal states carry an optional ``PeripheralError`` reason. diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index 1745eef..d13a9a8 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -41,7 +41,7 @@ All mutating actions are `async` and hop onto the Bluetooth actor for you: - ``ReliaBLEManager/authorizeBluetooth()`` - ``ReliaBLEManager/startScanning(services:)`` - ``ReliaBLEManager/stopScanning()`` -- ``ReliaBLEManager/connect(to:)`` +- ``ReliaBLEManager/connect(to:autoReconnect:)`` The current Bluetooth state is exposed as an `async` getter, ``ReliaBLEManager/currentState``: diff --git a/Sources/ReliaBLE/Models/ConnectionState.swift b/Sources/ReliaBLE/Models/ConnectionState.swift index d5f69a1..00566d2 100644 --- a/Sources/ReliaBLE/Models/ConnectionState.swift +++ b/Sources/ReliaBLE/Models/ConnectionState.swift @@ -22,6 +22,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import Foundation + /// The connection state of a single peripheral tracked by the library. /// /// ``ConnectionState`` is a finite, `Sendable` enumeration. Every non-terminal state change @@ -30,6 +32,13 @@ public enum ConnectionState: Sendable, Equatable, Hashable { /// A connection request has been issued and is in flight. case connecting + /// A reconnection is in progress. + /// + /// ``ReconnectSource/system`` means iOS is reconnecting at the daemon level; the library + /// is not involved and exposes no attempt count or next-retry time (both are `nil`). + /// ``ReconnectSource/library`` means the app-side exponential-backoff ladder has armed; + /// `attempt` and `nextRetryAt` are populated. + case reconnecting(source: ReconnectSource, attempt: Int?, nextRetryAt: Date?) /// The peripheral is currently connected. case connected /// A disconnection request has been issued and is in flight. @@ -46,6 +55,18 @@ public enum ConnectionState: Sendable, Equatable, Hashable { case failed(reason: PeripheralError?) } +/// Identifies which tier is driving a reconnection. +public enum ReconnectSource: Sendable, Equatable, Hashable { + /// The iOS daemon-level auto-reconnect (`CBConnectPeripheralOptionEnableAutoReconnect`) + /// is in control. The library does not schedule its own retries while the system is + /// attempting recovery. + case system + /// The library-side exponential-backoff ladder has armed. `attempt` and `nextRetryAt` + /// are populated on the ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)`` + /// case. + case library +} + /// A single connection-state transition emitted on ``ReliaBLEManager/connectionStateChanges``. /// /// Each element pairs the ``Peripheral/id`` of the peripheral with its new ``ConnectionState``. diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 2652307..c700a3a 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -30,7 +30,7 @@ import Foundation /// /// A `Peripheral` carries no reference to the underlying CoreBluetooth `CBPeripheral`. The live `CBPeripheral` is /// owned exclusively by the library in an `id`-keyed registry that never escapes its internal concurrency domain. -/// Operations that need the live peripheral (such as ``ReliaBLEManager/connect(to:)``) forward the snapshot's ``id``; +/// Operations that need the live peripheral (such as ``ReliaBLEManager/connect(to:autoReconnect:)``) forward the snapshot's ``id``; /// the actor looks up the live reference and throws ``PeripheralError/notFound`` if the snapshot has since gone stale. /// /// The integrating app can also construct a `Peripheral` from a known identifier *before* it has been discovered — diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift index cb88900..3a4d04f 100644 --- a/Sources/ReliaBLE/Models/PeripheralError.swift +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -27,7 +27,7 @@ import CoreBluetooth -/// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:)``. +/// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:autoReconnect:)``. public enum PeripheralError: Error, Sendable, Equatable { /// The peripheral is no longer known to the library. /// diff --git a/Sources/ReliaBLE/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 23161a2..2619dcd 100644 --- a/Sources/ReliaBLE/ReliaBLEConfig.swift +++ b/Sources/ReliaBLE/ReliaBLEConfig.swift @@ -47,8 +47,54 @@ public struct ReliaBLEConfig: Sendable { /// Whether or not logging is enabled. The default value is `false`. public var loggingEnabled = false + /// Policy controlling the behavior of the library-side exponential backoff supplement + /// for automatic reconnection. Enable/disable is a per-connect choice on + /// ``ReliaBLEManager/connect(to:autoReconnect:)``; this policy governs *how* the + /// library retries when auto-reconnect is active. + public var reconnectPolicy = ReconnectPolicy() + /// Initializes a new `ReliaBLEConfig` instance with the default values. public init() { } } + +/// Policy controlling the library-side exponential backoff supplement for automatic +/// reconnection. +/// +/// This struct governs *how* the library retries when auto-reconnect is active (the +/// enable/disable decision is a per-connect parameter, not global config). When the +/// library ladder arms, retry delays follow exponential growth (`initialDelay * 2^(attempt-1)`) +/// capped at ``maxDelay``, with ``jitter`` spread to avoid synchronized retries, and +/// bounded by ``maxAttempts``. +public struct ReconnectPolicy: Sendable { + /// The maximum number of consecutive reconnection attempts before giving up. The default value is `5`. + public var maxAttempts = 5 + + /// The base delay before the first reconnection attempt, in seconds. Subsequent attempts grow + /// exponentially from this value. The default value is `1.0`. + public var initialDelay: TimeInterval = 1.0 + + /// The maximum delay between reconnection attempts, in seconds, after exponential growth is + /// capped. The default value is `30.0`. + public var maxDelay: TimeInterval = 30.0 + + /// The fractional jitter (0...1) applied to each computed delay to avoid synchronized retries. + /// The default value is `0.2`. + public var jitter: Double = 0.2 + + /// Initializes a new `ReconnectPolicy` with the given values. + /// + /// All parameters default to the library's standard policy so `ReconnectPolicy()` remains valid. + public init( + maxAttempts: Int = 5, + initialDelay: TimeInterval = 1.0, + maxDelay: TimeInterval = 30.0, + jitter: Double = 0.2 + ) { + self.maxAttempts = maxAttempts + self.initialDelay = initialDelay + self.maxDelay = maxDelay + self.jitter = jitter + } +} diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index c06a4a3..d5896ec 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -59,7 +59,7 @@ public final class ReliaBLEManager: Sendable { // immediately after `init` from racing ahead of that setup, every public entry point funnels // through `ensureInitialized(log:)` (which is idempotent) before acting — so this eager call // is an optimization, not a correctness requirement. - Task { await BluetoothActor.shared.ensureInitialized(log: loggingService) } + Task { await BluetoothActor.shared.ensureInitialized(log: loggingService, reconnectPolicy: config.reconnectPolicy) } } // MARK: - State @@ -175,15 +175,16 @@ public final class ReliaBLEManager: Sendable { /// to the live CoreBluetooth peripheral held internally and requests a connection. /// /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. + /// - Parameter autoReconnect: When `true` (the default), the library passes + /// `CBConnectPeripheralOptionEnableAutoReconnect` to the system and arms the app-side + /// exponential-backoff ladder for cases the OS option doesn't cover. Set to `false` for + /// one-shot connections where reconnection is not desired. /// - Throws: ``PeripheralError/notFound`` if the peripheral's live reference has been invalidated (a stale /// snapshot), or ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up (for example, not /// yet authorized). - /// - /// - Note: This currently only initiates the connection request. The full connection lifecycle is deferred to a - /// later release. - public func connect(to peripheral: Peripheral) async throws { + public func connect(to peripheral: Peripheral, autoReconnect: Bool = true) async throws { await BluetoothActor.shared.ensureInitialized(log: log) - try await BluetoothActor.shared.connect(id: peripheral.id) + try await BluetoothActor.shared.connect(id: peripheral.id, autoReconnect: autoReconnect) } /// Initiates a disconnection from a previously connected peripheral. diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 43270c7..d9fb17c 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -670,6 +670,676 @@ struct ReliaBLEManagerTests { #expect(b2?.state == .connected) } + // MARK: - Reconnection + + /// A fast reconnect policy for tests: tiny delays, no jitter, small max attempts. + private static let testReconnectPolicy = ReconnectPolicy( + maxAttempts: 3, + initialDelay: 0.001, + maxDelay: 0.005, + jitter: 0 + ) + + @Test func systemReconnectOnUnexpectedDrop() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + + // Drain .connecting and .connected. + let c1 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + #expect(c1?.state == .connecting) + let c2 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + #expect(c2?.state == .connected) + + // Simulate an unexpected disconnect with the OS auto-reconnect option active. + Mock.connectionTestSpec.simulateDisconnection() + + // Tier 0: OS sends isReconnecting=true → library emits .system with nil metadata. + let c3 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .reconnecting(let source, let attempt, let nextRetryAt) = c3?.state else { + Issue.record("Expected .reconnecting, got \(String(describing: c3?.state))") + return + } + #expect(source == .system) + #expect(attempt == nil) + #expect(nextRetryAt == nil) + + // No library ladder should have been armed — verify no .library reconnecting events. + let remaining = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let libraryReconnects = remaining.filter { + if case .reconnecting(.library, _, _) = $0.state { return true } + return false + } + #expect(libraryReconnects.isEmpty, "Expected no .library reconnect events, got \(libraryReconnects.count)") + + // Cleanup: explicit disconnect to cancel any pending reconnect state. + try? await manager.disconnect(from: discovered) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func reconnectGivesUpAfterMaxAttempts() async throws { + let giveUpPolicy = ReconnectPolicy( + maxAttempts: 2, + initialDelay: 0.001, + maxDelay: 0.005, + jitter: 0 + ) + + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager(reconnectPolicy: giveUpPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(giveUpPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + // Force a clean disconnection to reset any lingering mock state. + Mock.connectionTestSpec.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) + + try await manager.connect(to: discovered) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) + let states = events.map { $0.state } + + #expect(states.count >= 6, "Expected at least 6 events, got \(states.count)") + + // Sequence: .connecting, .failed, .reconnecting(1), .connecting, .failed, .reconnecting(2), .connecting, .failed + #expect(states[0] == .connecting) + guard case .failed = states[1] else { + Issue.record("Expected .failed at index 1, got \(String(describing: states[1]))") + return + } + guard case .reconnecting(let source1, let a1, _) = states[2] else { + Issue.record("Expected .reconnecting at index 2, got \(String(describing: states[2]))") + return + } + #expect(source1 == .library) + #expect(a1 == 1) + #expect(states[3] == .connecting) + guard case .failed = states[4] else { + Issue.record("Expected .failed at index 4, got \(String(describing: states[4]))") + return + } + guard case .reconnecting(let source2, let a2, _) = states[5] else { + Issue.record("Expected .reconnecting at index 5, got \(String(describing: states[5]))") + return + } + #expect(source2 == .library) + #expect(a2 == 2) + + // The terminal state after give-up should be .failed, not .reconnecting. + if events.count >= 8 { + #expect(states[6] == .connecting) + guard case .failed = states[7] else { + Issue.record("Expected terminal .failed at index 7, got \(String(describing: states[7]))") + return + } + } + + // Verify no more .reconnecting events after the terminal state. + let reconnectingCount = states.filter { + if case .reconnecting = $0 { return true } + return false + }.count + #expect(reconnectingCount == 2, "Expected exactly 2 .reconnecting events, got \(reconnectingCount)") + + // Cleanup: the ladder has exhausted its attempts, but an explicit disconnect + // removes the id from reconnectEnabled so no stray event can re-arm it. + try? await manager.disconnect(from: discovered) + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await BluetoothActor.shared.setReconnectPolicy(cleanup) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func explicitDisconnectDoesNotReconnect() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + + // Drain .connecting and .connected. + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + + // Explicit disconnect. + try await manager.disconnect(from: discovered) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) + let states = events.map { $0.state } + + // Sequence: .disconnecting, .disconnected(reason: nil). No .reconnecting. + #expect(states.count == 2, "Expected 2 events, got \(states.count)") + #expect(states[0] == .disconnecting) + guard case .disconnected(let reason) = states[1] else { + Issue.record("Expected .disconnected at index 1, got \(String(describing: states[1]))") + return + } + #expect(reason == nil, "Expected nil reason for explicit disconnect, got \(String(describing: reason))") + + let hasReconnecting = states.contains { + if case .reconnecting = $0 { return true } + return false + } + #expect(!hasReconnecting, "Expected no .reconnecting events after explicit disconnect") + + // Cleanup: prevent further reconnect attempts from interfering with subsequent tests. + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await BluetoothActor.shared.setReconnectPolicy(cleanup) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func transientConnectFailureArmsReconnect() async throws { + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + // Force a clean disconnection to reset any lingering mock state. + Mock.connectionTestSpec.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) + + try await manager.connect(to: discovered) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) + let states = events.map { $0.state } + + #expect(states.count >= 3, "Expected at least 3 events, got \(states.count)") + + // Sequence: .connecting, .failed, .reconnecting(attempt: 1, ...) + #expect(states[0] == .connecting) + guard case .failed = states[1] else { + Issue.record("Expected .failed at index 1, got \(String(describing: states[1]))") + return + } + guard case .reconnecting(let source3, let attempt3, _) = states[2] else { + Issue.record("Expected .reconnecting at index 2, got \(String(describing: states[2]))") + return + } + #expect(source3 == .library) + #expect(attempt3 == 1) + + // Cleanup: cancel the pending reconnect task via explicit disconnect, then + // prevent further arming so no stray reconnect fires during subsequent tests. + try? await manager.disconnect(from: discovered) + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await BluetoothActor.shared.setReconnectPolicy(cleanup) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + /// CoreBluetoothMock hardcodes `isReconnecting: true` after any connect that passes + /// `CBConnectPeripheralOptionEnableAutoReconnect`, so `isReconnecting: false` scenarios + /// (OS give-up, or an unexpected drop after a non-auto-reconnect connect) must be injected + /// via ``BluetoothActor/testInjectDisconnect(for:isReconnecting:error:)`` rather than + /// driven through the mock's `simulateDisconnection()`. + + @Test func autoReconnectFalseDoesNotArmLadder() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + // Connect with autoReconnect: false — the OS option is NOT passed, and the + // library ladder is NOT armed. + try await manager.connect(to: discovered, autoReconnect: false) + + // Drain .connecting and .connected. + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + + // Inject an unexpected drop via the test hook (mock would emit isReconnecting: false anyway + // since the OS option wasn't passed, but we use the hook for explicitness). + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) + let states = events.map { $0.state } + + guard case .disconnected = states.first else { + Issue.record("Expected .disconnected, got \(states)") + return + } + + let hasReconnecting = states.contains { + if case .reconnecting = $0 { return true } + return false + } + #expect(!hasReconnecting, "Expected no .reconnecting events when autoReconnect is false") + + // Cleanup: explicit disconnect and prevent further reconnect attempts. + try? await manager.disconnect(from: discovered) + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await BluetoothActor.shared.setReconnectPolicy(cleanup) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func osGiveUpHandsOffToTier1() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + // Connect with autoReconnect: true (default). The library ladder is armed + // and the OS option is passed. We inject an OS give-up (isReconnecting: false) + // via the test hook to simulate the OS giving up on its own reconnect. + try await manager.connect(to: discovered) + + // Drain .connecting and .connected. + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + + // Inject OS give-up: isReconnecting: false unexpected disconnect. + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + // Observe .disconnected(reason:). + let c1 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .disconnected = c1?.state else { + Issue.record("Expected .disconnected, got \(String(describing: c1?.state))") + return + } + + // Tier 1 library ladder arms. + let c2 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .reconnecting(let source, let attempt, let nextRetryAt) = c2?.state else { + Issue.record("Expected .reconnecting, got \(String(describing: c2?.state))") + return + } + #expect(source == .library) + #expect(attempt == 1) + #expect(nextRetryAt != nil) + + // Cleanup: cancel the pending reconnect task via explicit disconnect. + try? await manager.disconnect(from: discovered) + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await BluetoothActor.shared.setReconnectPolicy(cleanup) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func explicitDisconnectCancelsPendingLibraryRetry() async throws { + // Long enough that we can issue an explicit disconnect while the ladder is mid-sleep, + // but short enough to keep suite time down (disconnect runs immediately after .reconnecting). + let slowPolicy = ReconnectPolicy( + maxAttempts: 3, + initialDelay: 0.5, + maxDelay: 0.5, + jitter: 0 + ) + + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: slowPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(slowPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + + // Arm the library ladder, then cancel it mid-sleep with an explicit disconnect. + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + let disconnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .disconnected = disconnected?.state else { + Issue.record("Expected .disconnected, got \(String(describing: disconnected?.state))") + return + } + + let reconnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .reconnecting(.library, let attempt, _) = reconnecting?.state else { + Issue.record("Expected .reconnecting(.library), got \(String(describing: reconnecting?.state))") + return + } + #expect(attempt == 1) + + try await manager.disconnect(from: discovered) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 1_000_000_000) + let states = events.map(\.state) + + #expect(states.contains(.disconnecting)) + #expect(states.contains(.disconnected(reason: nil))) + + let hasConnecting = states.contains(.connecting) + let hasLibraryRetry = states.contains { + if case .reconnecting(.library, _, _) = $0 { return true } + return false + } + #expect(!hasConnecting, "Expected no reconnect .connecting after explicit cancel, got \(states)") + #expect(!hasLibraryRetry, "Expected no further library retries after explicit cancel, got \(states)") + + await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func explicitDisconnectReportsCleanReasonDespiteUnderlyingError() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: ReconnectPolicy(maxAttempts: 0)) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected + + // Simulate an explicit disconnect that CoreBluetooth reports WITH a benign underlying error. + // The contract is that an app-initiated disconnect reports `reason: nil` regardless. + await BluetoothActor.shared.testSeedIntentionalDisconnect(discovered.id) + await BluetoothActor.shared.testInjectDisconnect( + for: discovered.id, + isReconnecting: false, + error: NSError(domain: "test.explicit", code: 1) + ) + + let disconnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + #expect( + disconnected?.state == .disconnected(reason: nil), + "Explicit disconnect must report a clean nil reason even when CoreBluetooth supplies an error, got \(String(describing: disconnected?.state))" + ) + + await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func nonFiniteReconnectPolicyDoesNotTrapScheduler() async throws { + // `ReconnectPolicy` is public and unvalidated: a caller could pass non-finite delay/jitter. + // Scheduling a reconnect with these must not trap the UInt64 nanosecond conversion. + let hostilePolicy = ReconnectPolicy( + maxAttempts: 2, + initialDelay: .infinity, + maxDelay: .nan, + jitter: .nan + ) + + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: hostilePolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(hostilePolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected + + // Unexpected drop arms the library ladder; scheduling must survive the non-finite delay. + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected + let reconnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .reconnecting(.library, let attempt, _) = reconnecting?.state else { + Issue.record("Expected .reconnecting(.library) without trapping, got \(String(describing: reconnecting?.state))") + return + } + #expect(attempt == 1) + + try await manager.disconnect(from: discovered) + await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func successfulLibraryReconnectResetsAttemptCounter() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + + // First unexpected drop → ladder attempt 1, then successful reconnect. + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected + let firstLadder = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .reconnecting(.library, let firstAttempt, _) = firstLadder?.state else { + Issue.record("Expected first .reconnecting(.library), got \(String(describing: firstLadder?.state))") + return + } + #expect(firstAttempt == 1) + + let reconnectConnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + #expect(reconnectConnecting?.state == .connecting) + + let reconnectConnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + #expect(reconnectConnected?.state == .connected) + + // Second unexpected drop must start a fresh ladder at attempt 1, not continue at 2. + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected + let secondLadder = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + guard case .reconnecting(.library, let secondAttempt, _) = secondLadder?.state else { + Issue.record("Expected second .reconnecting(.library), got \(String(describing: secondLadder?.state))") + return + } + #expect(secondAttempt == 1) + + try? await manager.disconnect(from: discovered) + await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + @Test func invalidateClearsIntentionalDisconnectIntent() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let id = "intentional-seed" + await BluetoothActor.shared.testSeedIntentionalDisconnect(id) + #expect(await BluetoothActor.shared.testContainsIntentionalDisconnect(id)) + + await BluetoothActor.shared.testInvalidatePeripherals() + #expect(!(await BluetoothActor.shared.testContainsIntentionalDisconnect(id))) + } + + @Test func giveUpThenUnexpectedDropRearmsFreshLadder() async throws { + let giveUpPolicy = ReconnectPolicy( + maxAttempts: 1, + initialDelay: 0.001, + maxDelay: 0.005, + jitter: 0 + ) + + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager(reconnectPolicy: giveUpPolicy) + await Mock.ensureReady(manager) + await BluetoothActor.shared.setReconnectPolicy(giveUpPolicy) + + var changes = manager.connectionStateChanges.makeAsyncIterator() + // Force an actor hop so registration completes before we connect. + _ = await manager.currentConnectionStates + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + Mock.connectionTestSpec.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) + + try await manager.connect(to: discovered) + + // Exhaust the single-attempt ladder: + // connecting → failed → reconnecting(1) → connecting → failed (give-up). + let s0 = await changes.next() + #expect(s0?.state == .connecting) + + let s1 = await changes.next() + guard case .failed = s1?.state else { + Issue.record("Expected .failed at index 1, got \(String(describing: s1?.state))") + return + } + + let s2 = await changes.next() + guard case .reconnecting(.library, let a1, _) = s2?.state else { + Issue.record("Expected .reconnecting(.library, 1), got \(String(describing: s2?.state))") + return + } + #expect(a1 == 1) + + let s3 = await changes.next() + #expect(s3?.state == .connecting) + + let s4 = await changes.next() + guard case .failed = s4?.state else { + Issue.record("Expected terminal .failed after give-up, got \(String(describing: s4?.state))") + return + } + + // Intent survives give-up: a later unexpected drop must arm a fresh ladder at attempt 1. + await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + + let s5 = await changes.next() + guard case .disconnected = s5?.state else { + Issue.record("Expected .disconnected after post-give-up drop, got \(String(describing: s5?.state))") + return + } + + let s6 = await changes.next() + guard case .reconnecting(.library, let a2, _) = s6?.state else { + Issue.record("Expected fresh .reconnecting(.library, attempt: 1), got \(String(describing: s6?.state))") + return + } + #expect(a2 == 1) + + try? await manager.disconnect(from: discovered) + await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + try? await Task.sleep(nanoseconds: 200_000_000) + } + // MARK: - Event Stream Broadcaster @Test func stateStreamReplaysToConcurrentSubscribers() async throws { @@ -800,11 +1470,21 @@ enum Mock { /// is pinned to `.notDetermined` **before** any central can be created — including by the maintainer's /// authorization tests, whose `.notDetermined` `authorize()` path itself creates a central. Use /// ``ensureReady(_:)`` afterwards to bring the shared central online. - static func makeManager(loggingEnabled: Bool = false) async -> ReliaBLEManager { + static func makeManager(loggingEnabled: Bool = false, reconnectPolicy: ReconnectPolicy? = nil) async -> ReliaBLEManager { await SimulationConfig.shared.ensureConfigured() var config = ReliaBLEConfig() config.loggingEnabled = loggingEnabled + if let reconnectPolicy { + config.reconnectPolicy = reconnectPolicy + } else { + // Disable the library ladder by default so non-reconnect tests don't + // accidentally arm it (default autoReconnect: true on connect(to:) still + // passes the OS option, but the library ladder won't schedule retries). + var defaultPolicy = ReconnectPolicy() + defaultPolicy.maxAttempts = 0 + config.reconnectPolicy = defaultPolicy + } return ReliaBLEManager(config: config) } @@ -989,3 +1669,26 @@ func firstConnectionStateChange( return first } } + +/// Drains all connection-state changes from `stream` within `nanoseconds`, returning them in arrival order. +func drainConnectionStateChanges( + from stream: AsyncStream, + withinNanoseconds nanoseconds: UInt64 +) async -> [ConnectionStateChange] { + await withTaskGroup(of: [ConnectionStateChange].self) { group in + group.addTask { + var events: [ConnectionStateChange] = [] + for await change in stream { + events.append(change) + } + return events + } + group.addTask { + try? await Task.sleep(nanoseconds: nanoseconds) + return [] + } + _ = await group.next() + group.cancelAll() + return await group.next() ?? [] + } +} diff --git a/docs/plans/auto-reconnect-backoff-2026-07-05.md b/docs/plans/auto-reconnect-backoff-2026-07-05.md new file mode 100644 index 0000000..988fc4c --- /dev/null +++ b/docs/plans/auto-reconnect-backoff-2026-07-05.md @@ -0,0 +1,104 @@ +# Automatic Reconnection with Exponential Backoff: Plan +*Issue #37 · 2026-07-05* + +## Goal + +Deliver ReliaBLE's headline reliability feature: when a connected peripheral drops unexpectedly (or an initial connect fails transiently), reconnect automatically instead of forcing the integrating app to retry. **Lean on iOS's own daemon-level auto-reconnect first** (`CBConnectPeripheralOptionEnableAutoReconnect`, always available on this iOS 18+ library) and **supplement it with app-side exponential backoff** only where the OS doesn't help. Enable/disable is a per-connect choice; backoff tuning lives in config. Surface progress — system-driven vs. library-driven, with attempt/next-retry timing where known — through the existing `connectionStateChanges` stream. Ship a Demo that makes it observable. + +Covers **FR-1.2** (auto-reconnect with exponential backoff) and the reconnection half of **FR-1.3.1** (connection-stability status). Builds directly on #36. Background operation (state restoration + `bluetooth-central`) is a deliberate **follow-up**, not in scope here. + +## Background + +#36 is fully merged (commit `e8d3a4e`, "Added full connection handling and state reporting"). All state below verified by exploration. + +### Library — connection lifecycle (`Sources/ReliaBLE/`) +- All BLE state lives in `@globalActor actor BluetoothActor` (`BluetoothActor.swift:81`). No `BluetoothManager.swift` — that name in AGENTS.md refers to this actor. +- Connect / disconnect entry points: `connect(id:)` (`BluetoothActor.swift:703`) sets `.connecting`, broadcasts, calls `centralManager.connect(cbPeripheral, options: nil)` — **no connect options today**. `disconnect(id:)` (:730) sets `.disconnecting`, broadcasts, calls `cancelPeripheralConnection`. +- Public connect surface is `ReliaBLEManager.connect(to:)` — a single-argument signature that this plan changes. +- Delegate shim (`BluetoothDelegateShim`, :775–792) yields `DelegateEvent`s drained by one long-lived `delegateEventTask` through `process(_:)` (:342). Handlers: + - `handleDidConnect` (:656) → `.connected` + - `handleDidDisconnect` (:666) → `.disconnected(reason:)`. Receives the 5-param `didDisconnectPeripheral:timestamp:isReconnecting:error:`; **`isReconnecting` is currently ignored**, `error` is mapped `CBError? → PeripheralError?`. + - `handleDidFailToConnect` (:682) → `.failed(reason:)` +- **iOS auto-reconnect (iOS 17+, always available here).** `CBConnectPeripheralOptionEnableAutoReconnect` asks the system to re-establish a link that drops **after a successful connection** (not initial-connect failures, not app-initiated cancels). When active, the drop arrives via the 5-param delegate with `isReconnecting == true`, and a later `didConnect` follows if the system succeeds; the system's give-up is a *second* `didDisconnect` with `isReconnecting == false`. The OS give-up budget/timing is not publicly documented (verify on-device). The library passes this option nowhere today and always runs its own logic. +- `ConnectionState` (`Models/ConnectionState.swift:30`): `.connecting`, `.connected`, `.disconnecting`, `.disconnected(reason: PeripheralError?)`, `.failed(reason: PeripheralError?)`. **No `.reconnecting` case; no attempt/retry metadata.** `ConnectionStateChange { peripheralId: String; state: ConnectionState }` (:54). +- Stream: `connectionStateChangesStream()` (:190) → shared `AsyncStream`, **no replay**. Snapshot: `currentConnectionStates: [String: ConnectionState]` (actor :247, façade `ReliaBLEManager.swift:105`). Public property `connectionStateChanges` (`ReliaBLEManager.swift:98`). +- Peripheral tracking: live refs `cbPeripherals: [String: CBPeripheral]` (:116, never escape actor); state registry `connectionStates: [String: ConnectionState]` (:129), same key. +- **Config does not reach the actor today.** `ReliaBLEManager.init` (`ReliaBLEManager.swift:52`) passes only a `LoggingService` in via `Task { await BluetoothActor.shared.ensureInitialized(log:) }` (:63). `ReliaBLEConfig` (`ReliaBLEConfig.swift:35`) is a flat `Sendable` struct of `public var` fields + no-arg `init()`; backoff policy will need a new path into the actor. +- No `Task.sleep`, `Timer`, or `Clock` exists inside `@BluetoothActor` today — only continuations (`withCheckedThrowingContinuation` for auth) and the delegate drain `Task`. + +### Tests / mock harness (`Tests/ReliaBLETests/`, `Sources/ReliaBLEMock/`) +- `@Suite(.serialized)` (`ReliaBLEManagerTests.swift:56`) — process-wide singleton state. `Mock.makeManager()` → `SimulationConfig.ensureConfigured()` (:806) does one-time `simulateInitialState(.poweredOn)` / `simulatePeripherals` / `simulateAuthorization`. +- Unexpected-drop primitive already used: `Mock.connectionTestSpec.simulateDisconnection()` (:568, 580, 588, 594) triggers the `didDisconnect` path. Connect outcome controlled by `ConnectionTestDelegate.connectionResult: Result` (:686). +- `CoreBluetoothMock` exposes `CBConnectPeripheralOptionEnableAutoReconnect` as an alias (`Sources/ReliaBLEMock/CoreBluetoothMockAliases.swift`), but whether it **simulates** the OS auto-reconnect state machine (`isReconnecting: true → didConnect`, or `true → false` give-up) is unconfirmed — a prerequisite check for Tier-0 test coverage. +- **No injected clock.** Timed behavior is observed with real-time `Task.sleep` and `pollUntil(timeout: 3.0)` (:929). Mock connection callback fires on a ~0.045 s async timer (:599). `Tests/ReliaBLETests/Mocks/` is empty. + +### Demo (`Demo/ReliaBLE Demo/`) +- Separate project — read `Demo/CLAUDE.md` first, use XcodeBuildMCP. #36 added connect/disconnect + connection-state consumption in the Central detail UI (commit `b6371b8`). The `connect(to:)` call site and `SettingsView.swift` are the touch points. + +## Approach + +**Two-tier model: OS-managed reconnect is primary; the library supplements.** iOS 18+ means `CBConnectPeripheralOptionEnableAutoReconnect` is always available, so we defer to the daemon-level reconnect first and add app-side backoff only where the OS option doesn't reach. + +- **Tier 0 — OS-managed (primary).** A connection requested with auto-reconnect on passes `[CBConnectPeripheralOptionEnableAutoReconnect: true]`. After an unexpected drop, iOS re-establishes the link itself — daemon-held, power-efficient, and it keeps trying across app suspension. `handleDidDisconnect` **must honor `isReconnecting`** (ignored today): `true` → emit `.reconnecting(source: .system, …)` and **do not** arm the library ladder (a later `didConnect` is coming); `false` + unexpected → the OS gave up → hand off to Tier 1. +- **Tier 1 — library-managed (supplement).** Exponential backoff covers what the OS option does not: initial `connect()` failures (`handleDidFailToConnect`; the OS option only applies after a link existed) and post-OS-give-up drops. This is where `maxAttempts`, the terminal give-up state, and the UI countdown live. It's the deterministic, observable safety net beneath the OS's undocumented budget. + +**Enable/disable is per-connect; behavior is config.** Reconnection *intent* is a property of a connection (a persistent lock vs. a one-shot sensor), and CoreBluetooth models it per-`connect()`, so it belongs on the public API rather than global config: + +- Public API: **`connect(to peripheral: Peripheral, autoReconnect: Bool = true)`** (`ReliaBLEManager.swift`). Default `true` delivers the headline promise; opt out per call. **Breaking change** to the existing `connect(to:)` signature (pre-1.0, acceptable). The flag gates **both** tiers for that peripheral: whether the OS option is passed *and* whether the library ladder may arm. + +**Config — behavior only.** Nested `ReconnectPolicy: Sendable` beside `ReliaBLEConfig` (`ReliaBLEConfig.swift`) holds *how* the supplement retries: `maxAttempts`, `initialDelay: TimeInterval`, `maxDelay: TimeInterval`, `jitter: Double`, with sane defaults. `ReliaBLEConfig` gains `var reconnectPolicy = ReconnectPolicy()`. **No `enabled` field** (superseded by the per-connect flag) and **no OS/library strategy toggle** — the tiers compose, and library-only has no use case. Plumb the policy into the actor by extending `ensureInitialized` (today it carries only `LoggingService`). + +**State.** Add one non-terminal public case: `ConnectionState.reconnecting(source: ReconnectSource, attempt: Int?, nextRetryAt: Date?)` with `public enum ReconnectSource { case system, library }` (`Models/ConnectionState.swift`). OS-driven reconnects are `.system` with `nil` attempt/date (iOS exposes neither); library-driven are `.library` with both populated (`Date` gives the Demo a countdown target). Stays `Sendable`/`Equatable`/`Hashable`. Breaking change the Demo's `switch` handles. + +**Scheduler.** On `@BluetoothActor`, add isolated state: `reconnectPolicy: ReconnectPolicy`, `reconnectEnabled: Set` (per-connect intent — the Tier-1 gate), `reconnectAttempts: [String: Int]` (attempt counter across delegate events), `reconnectTasks: [String: Task]` (cancellable pending retry). + +- **`connect(id:autoReconnect:)`.** If `autoReconnect`: insert `id` into `reconnectEnabled` and pass `[CBConnectPeripheralOptionEnableAutoReconnect: true]`; else remove `id` and pass `options: nil`. +- **`handleDidDisconnect` (:666).** Honor `isReconnecting`: `true` → emit `.reconnecting(source: .system, attempt: nil, nextRetryAt: nil)`, return (OS owns it). `false` → if `id ∈ intentionalDisconnects`, clear it and stop; else the OS is done → `armReconnect(id:)`. +- **`handleDidFailToConnect` (:682).** Initial-connect failure (OS option never applies) → `armReconnect(id:)`. +- **`armReconnect(id:)` — single choke point (Tier 1).** Return early if `id ∉ reconnectEnabled`, or `reconnectAttempts[id] >= maxAttempts` (give-up: leave the terminal state, clear `reconnectAttempts[id]`). Otherwise increment `reconnectAttempts[id]` and call `scheduleReconnect(id:attempt:)`. +- **`scheduleReconnect(id:attempt:)`.** Cancel+replace any `reconnectTasks[id]`; compute the backoff delay; emit `.reconnecting(source: .library, attempt: attempt, nextRetryAt: now + delay)`; store a `Task` that `Task.sleep`s, then — only if `!Task.isCancelled` and `cbPeripherals[id]` still exists — calls `connect(id:autoReconnect: true)` (re-passing the OS option so a successful retry re-arms Tier 0). Actor isolation + cancel-before-replace + post-sleep guards close the double-schedule and stale-fire races. +- **Recovery.** `handleDidConnect` (:656) clears `reconnectAttempts[id]` and `reconnectTasks[id]`, discards stale `intentionalDisconnects[id]`. Leaves `reconnectEnabled` intact — intent persists across drops. +- **Explicit disconnect.** `disconnect(id:)` (:730) inserts `id` into `intentionalDisconnects`, removes it from `reconnectEnabled`, cancels+removes `reconnectTasks[id]`/`reconnectAttempts[id]`, then sets `.disconnecting`. +- **Teardown.** `invalidatePeripherals` (:619, clears `connectionStates` today) also cancels all `reconnectTasks` and clears `reconnectEnabled`/`reconnectAttempts`. + +**Backoff math** — exponential `initialDelay * 2^(attempt-1)` capped at `maxDelay`, then `±jitter` via `Double.random`; two lines, not elaborated. + +**Tests.** Mock-driven where possible. **First verify CoreBluetoothMock capability**: does it honor `CBConnectPeripheralOptionEnableAutoReconnect` and emit the `isReconnecting: true → didConnect` / `true → false` give-up sequence? If not, drive Tier-0 by injecting delegate events directly (or accept it as device/manual-only). Tier 1, intentional-disconnect, and initial-failure paths stay fully mock-testable. Use a tiny `ReconnectPolicy` (Q4) and assert emission *sequence and count*, not exact timing. Cover: initial-failure ladder → recovery; give-up after `maxAttempts` (terminal, no further `.reconnecting`); explicit `disconnect` does **not** reconnect; `isReconnecting: true` suppresses the library ladder; OS give-up (`isReconnecting: false`) hands off to Tier 1. + +**Demo (separate project — read `Demo/CLAUDE.md`, delegate).** v1: render `.reconnecting` in device detail, distinguishing `.system` ("System reconnecting…") from `.library` (attempt + `nextRetryAt` countdown); update the `connect(to:)` call site for the new `autoReconnect` parameter; add a `SettingsView` section binding the `ReconnectPolicy` fields. The explicit-disconnect vs. unexpected-drop distinction then falls out visibly. List-row badge optional/stretch. + +**DocC.** Extend the connection-stream example in `GettingStarted.md` for the OS/library tiers, the `.reconnecting` shape, and `connect(to:autoReconnect:)`. + +## Work Items + +> **Orchestration status (2026-07-06):** Items 1–4 ✅ DONE — migrated the earlier single-tier implementation to the two-tier model. `ReconnectPolicy.enabled` removed; `ConnectionState.reconnecting(source:attempt:nextRetryAt:)` + `ReconnectSource{.system,.library}` added; `connect(to:autoReconnect:)` public API; `BluetoothActor` now threads `isReconnecting` through `ConnectionPayload`, emits `.system` reconnecting on OS-driven drops, gates Tier-1 ladder on `reconnectEnabled: Set`, passes `CBConnectPeripheralOptionEnableAutoReconnect`, re-arms Tier 0 on retry. Library `swift build` clean (both targets). Item 5: `GettingStarted.md` documents both tiers, `.reconnecting` shape, and `connect(to:autoReconnect:)`. Item 6: tests migrated to new shapes + new coverage (`autoReconnectFalseDoesNotArmLadder`, `osGiveUpHandsOffToTier1`, `systemReconnectOnUnexpectedDrop`); added internal `testInjectDisconnect(for:isReconnecting:error:)` hook because CoreBluetoothMock hardcodes `isReconnecting: true` for auto-reconnect connects (documented finding). Item 7 (Demo): renders `.system` vs `.library` reconnecting, removed the `enabled` toggle from `SettingsView`, builds clean. Item 8: **all 38 tests pass**, `forceMock`/lazy-init/`id(for:)` untouched. **ALL ITEMS ✅ COMPLETE.** +> +> **Review-fix pass (2026-07-06):** Must-fix 1 — `invalidatePeripherals` now clears `intentionalDisconnects`. Must-fix 2 — `performReconnect` maps a thrown `connect` to `.failed(reason:)` + clears state (no stuck `.reconnecting`). Must-fix 3 — give-up deliberately RETAINS `reconnectEnabled` (product decision: intent survives until explicit disconnect), documented + covered by `giveUpThenUnexpectedDropRearmsFreshLadder`. Suggestions applied: `.system` branch defensively cancels pending Tier-1 task; public memberwise `ReconnectPolicy.init(maxAttempts:initialDelay:maxDelay:jitter:)`; DocC notes on the process-wide-singleton policy limitation and the undocumented OS give-up budget + `.failed` indentation fix. New tests bring the suite to **42/42 passing**. Upstream mock fidelity + on-device Tier-0 verification split to **issue #40** (retires the `testInjectDisconnect` hook). + +1. `ReliaBLEConfig.swift` — add behavioral `ReconnectPolicy` (`maxAttempts`, `initialDelay`, `maxDelay`, `jitter`) + `reconnectPolicy` property with defaults. Independent, compiles alone. +2. `Models/ConnectionState.swift` — add `ReconnectSource` + `.reconnecting(source:attempt:nextRetryAt:)`. Independent, compiles alone. +3. `ReliaBLEManager.swift` — change `connect(to:)` → `connect(to:autoReconnect:)` (breaking public API); forward `config.reconnectPolicy` into `ensureInitialized`. Lands with #4. +4. `BluetoothActor.swift` — core work item: add `reconnectPolicy`/`reconnectEnabled`/`reconnectAttempts`/`reconnectTasks`; pass the OS option in `connect(id:autoReconnect:)`; honor `isReconnecting` in `handleDidDisconnect`; add `armReconnect(id:)` + `scheduleReconnect(id:attempt:)`; edit `handleDidFailToConnect`, `handleDidConnect`, `disconnect`, `invalidatePeripherals`; extend `ensureInitialized` to stash the policy. Lands atomically with #3. +5. `Documentation.docc/GettingStarted.md` — document the tiers, the `.reconnecting` shape, and `connect(to:autoReconnect:)`. +6. `ReliaBLEManagerTests.swift` — first verify mock auto-reconnect capability; then add the coverage above using a fast test policy. +7. Demo — render `.reconnecting` (system vs. library), update the `connect` call site, add `SettingsView` backoff controls. Delegate to a sub-agent told to read `Demo/CLAUDE.md` first. +8. `swift build && swift test`; confirm `forceMock`, lazy central init, and the FR-8.5 `id(for:)` breadcrumb are untouched. + +## Decisions (resolved) + +- **Enable/disable is per-connect.** `connect(to:autoReconnect: Bool = true)` — reconnection intent lives on the API (mirroring CoreBluetooth's per-connect option), default on. Config carries behavior only. +- **OS auto-reconnect is primary (Tier 0); library supplements (Tier 1).** Pass `CBConnectPeripheralOptionEnableAutoReconnect` on every auto-reconnect connect; `isReconnecting` gates whether the library ladder arms. The library covers initial-connect failures and post-OS-give-up drops. +- **Trigger scope & intent.** The library ladder arms on initial-connect failures and post-OS-give-up unexpected drops. Clean vs. unexpected is decided by an explicit `intentionalDisconnects` set, not the `error` value. +- **`.reconnecting(source: ReconnectSource, attempt: Int?, nextRetryAt: Date?)`.** `.system` carries `nil` metadata (iOS exposes none); `.library` carries the attempt count and next-retry `Date`. +- **`ReconnectPolicy` = behavior only** — `maxAttempts`, `initialDelay`, `maxDelay`, `jitter`. No `enabled`, no OS/library strategy toggle. +- **Real-time tests.** No injected `Clock`; tiny test policy, assert emission *sequence and count* via the existing `firstConnectionStateChange` / `pollUntil` helpers. Verify CoreBluetoothMock honors the OS option before relying on it for Tier-0 coverage. + +## Deferred / follow-up +- **Background reconnection** — daemon-held standing connects, `CBCentralManagerOptionRestoreIdentifierKey` + `willRestoreState:`, `bluetooth-central` background mode, and connect options like `NotifyOnConnection`/`NotifyOnDisconnection`. The Tier-1 scheduler's `Task.sleep` does not survive app suspension/termination; the OS tier (Tier 0) partially covers backgrounded reconnects but is weaker than full state restoration. Own issue. + +## References +- Issue #37; builds on #36 (`docs/plans/connection-lifecycle-stream-2026-06-30.md`). +- PRD: FR-1.2, FR-1.3.1. +- Apple: `CBConnectPeripheralOptionEnableAutoReconnect` (iOS 17+) + `didDisconnectPeripheral:timestamp:isReconnecting:error:`; WWDC23 "What's new in Core Bluetooth". **OS auto-reconnect give-up budget/timing is undocumented — verify on-device.** +- Architecture: root `AGENTS.md` (`@BluetoothActor`, three-target mock harness, `ReliaBLEConfig` lazy init). +- Demo: `Demo/CLAUDE.md`; `Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift`. diff --git a/docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md b/docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md new file mode 100644 index 0000000..648787c --- /dev/null +++ b/docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md @@ -0,0 +1,29 @@ +# Critique: Automatic Reconnection with Exponential Backoff Plan +*For `docs/plans/auto-reconnect-backoff-2026-07-05.md` · 2026-07-05* + +## Context/Scope +This review covers only the three named seams (event-driven retry loop, `reconnectTasks`/`Task.sleep`/re-entrancy, `intentionalDisconnects` clearing), contradictions/missing dependencies, over-planning risks, and order-affecting questions. Spot-check limited to `BluetoothActor.swift:656-737` (current handlers) per instructions. No broader exploration or plan rewrite. + +## Findings + +### 1. Top 3 Under-Specified Seams +- **Attempt counter across delegate events (BluetoothActor.swift:666,682)**: `scheduleReconnect(id:attempt:)` receives `attempt`, but no storage for a per-peripheral attempt counter is declared alongside `reconnectPolicy`/`reconnectTasks`. `handleDidDisconnect` and `handleDidFailToConnect` both arm the ladder; nothing specifies how the counter is read/incremented on the second+ failure (e.g., after a `.reconnecting` sleep yields a failed `connect`). `handleDidConnect:656` clears it, but the initial value and increment path are undefined. +- **`reconnectTasks` Task + `Task.sleep` + actor re-entrancy (Approach §Scheduler)**: The stored `Task` sleeps then calls `connect(id:)`. Actor re-entrancy on suspension is safe, but concurrent `disconnect`/`invalidatePeripherals` cancellation while sleeping, or a second `scheduleReconnect` racing the first sleep, is unspecified. No mention of checking task existence before overwriting or draining on give-up. +- **`intentionalDisconnects` clearing on all paths (Approach §Scheduler, Q2)**: Inserted in `disconnect:730`; cleared in `handleDidDisconnect`. But `handleDidFailToConnect` (transient connect failure path) and give-up (maxAttempts exceeded) have no clear instruction. Connect-success path also unmentioned; a stale entry could suppress future reconnects. + +### 2. Contradictions / Missing Dependencies +- "Clear the attempt counter" (handleDidConnect) and "under `maxAttempts`" (scheduleReconnect) reference an attempt counter that is never added to the stored state list. +- `ReconnectPolicy.enabled` (default true) has no guard location specified; plan states handlers "arm the ladder" without showing where the check occurs. +- `invalidatePeripherals` is listed for task cancellation, but the current actor (lines 650-737) has no such method—plan assumes it exists or will be added. + +### 3. Risk of Over-Planning +- Detailed backoff math + jitter formula in Approach can be cut; implementation will be a few lines once policy fields exist. +- Full Demo work item (SettingsView bindings, list-row badge, visual disconnect semantics) is large relative to library changes; consider trimming to "surface `.reconnecting` in detail view only" for v1. + +### 4. Questions Affecting Implementation Order +- Where will the attempt counter live (separate `[String:Int]` map, or embedded in `reconnectTasks` value type)? Answer determines state shape before any handler edits. +- Does the `enabled` check live in `scheduleReconnect`, the two handlers, or `connect` itself? Affects whether Work Item 3 can land without Item 1. +- Must `intentionalDisconnects` be cleared on give-up and on `handleDidConnect` success, or only on explicit `disconnect`? Changes the set's lifecycle and test surface. + +## Recommendations +Clarify the three seams and the attempt-counter storage question first; everything else (policy plumbing, state enum, tests) follows. Delete the detailed scheduler pseudocode and trim Demo scope to reduce plan surface. Report path: `docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md`. \ No newline at end of file diff --git a/prompt-exports/oracle-review-2026-07-05-184401-new-chat-bd21ef-3cd5.md b/prompt-exports/oracle-review-2026-07-05-184401-new-chat-bd21ef-3cd5.md new file mode 100644 index 0000000..fe7c018 --- /dev/null +++ b/prompt-exports/oracle-review-2026-07-05-184401-new-chat-bd21ef-3cd5.md @@ -0,0 +1,74 @@ +# Oracle Review + + + +## Summary + +The reconnect changes add a `ReconnectPolicy`, a public `.reconnecting(attempt:nextRetryAt:)` connection state, actor-side retry scheduling, and four tests intended to validate unexpected-drop recovery, max-attempt give-up, explicit disconnect suppression, and transient connect-failure retry behavior. The test direction is good, but the described helper and setup have a few race/flakiness risks, especially around `AsyncStream` draining, singleton actor state, and bypassing public configuration plumbing. + +## Findings + +### P1 + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` — `drainConnectionStateChanges` can hang or return the timeout result instead of collected events** + + If the helper uses `withTaskGroup` with one child looping over `manager.connectionStateChanges` and another child sleeping for the timeout, be careful: task groups wait for outstanding children before returning. Since `connectionStateChanges` is an open-ended `AsyncStream`, the collector child may never complete unless explicitly cancelled. A common buggy pattern is: + + ```swift + let result = await group.next() + return result + ``` + + If the timeout task wins, this either returns an empty timeout result while discarding already-collected events, or hangs while the group waits for the stream collector. + + **Suggestion:** make the timeout task only signal cancellation, then `cancelAll()` and await the collector’s accumulated events. Use an enum result to distinguish timeout from collected values, and ensure the collector exits on cancellation. Alternatively avoid a task group and use a single collector `Task`, sleep for the timeout, cancel the collector, then await its value. + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` / `Sources/ReliaBLE/ReliaBLEManager.swift` — tests bypass the public config path** + + The tests set reconnect behavior directly through: + + ```swift + await BluetoothActor.shared.setReconnectPolicy(...) + ``` + + That verifies the actor scheduler, but it does not verify that `ReliaBLEConfig.reconnectPolicy` is actually honored by `ReliaBLEManager`. In the provided diff, `ensureInitialized` accepts a policy, but no `ReliaBLEManager` forwarding change is shown. Because `Mock.makeManager` now defaults to disabled and tests enable reconnect through the actor singleton, the tests could pass while production configuration remains broken. + + **Suggestion:** add at least one test that enables reconnect through `ReliaBLEConfig` passed into `Mock.makeManager` / `ReliaBLEManager`, with no direct actor setter. Also ensure `ReliaBLEManager.init` forwards `config.reconnectPolicy` into `ensureInitialized`. + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` — cleanup by setting `enabled = false` does not cancel existing reconnect tasks** + + The described cleanup sets the policy to disabled and sleeps briefly. But `BluetoothActor.setReconnectPolicy(_:)` only assigns the policy; it does not cancel already-scheduled `reconnectTasks` or clear `reconnectAttempts`. A pending retry can still wake up after the test body and call `connect(id:)`, leaking `.connecting` / `.failed` / `.connected` events into the next serialized test. + + **Suggestion:** either update `setReconnectPolicy` so disabling reconnect cancels and clears pending reconnect state, or add a test-only cleanup path that explicitly cancels reconnect tasks. Sleeping is not a reliable synchronization mechanism here. + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` — event collection must start before triggering state changes** + + `connectionStateChanges` has no replay. Any test that calls `connect`, `disconnect`, or `simulateDisconnection()` before starting `drainConnectionStateChanges` can miss synchronously-broadcast states like `.connecting` or `.disconnecting`. + + **Suggestion:** start the drain task before triggering the action under test, then await the drain result. For current-state assertions, use `currentConnectionStates`; for transition assertions, ensure the stream subscription is already active. + +### P2 + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` — give-up and transient-failure expectations should account for `.connecting`** + + The actor’s retry path calls `connect(id:)`, which broadcasts `.connecting` before the mock later emits `.failed` or `.connected`. The described give-up sequence: + + ```text + .failed → .reconnecting(1) → .failed → .reconnecting(2) → .failed + ``` + + omits those `.connecting` transitions. + + **Suggestion:** either include `.connecting` in exact sequence assertions or explicitly filter it out before comparing the failure/reconnect ladder. + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` — avoid exact `Date` comparisons for `.reconnecting`** + + `.reconnecting(attempt:nextRetryAt:)` includes a runtime-generated `Date`. Exact equality is fragile and not meaningful for these tests. + + **Suggestion:** pattern-match the case, assert the attempt number, and optionally assert `nextRetryAt` is within a reasonable window based on the tiny test policy. + +- **`Tests/ReliaBLETests/ReliaBLEManagerTests.swift` — reset mock connection result explicitly between tests** + + Since `ConnectionTestDelegate.connectionResult` controls async connect callbacks and tests share a serialized singleton-style mock setup, stale success/failure settings can affect later tests if a delayed callback fires. + + **Suggestion:** set `ConnectionTestDelegate.connectionResult` to a known default in teardown/setup for every reconnect test, not only in the test body. \ No newline at end of file