diff --git a/AGENTS.md b/AGENTS.md index 29fa97c..a551301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ The library is built with Swift 6 and **complete concurrency checking**. The `Re ## Notes for editing - **This library is in pre-release development stage.** Breaking changes are expected. Do not reference behavior history in any library documentation or code comments (noting in planning docs is acceptable and expected). Do not waste time thinking about mitigating breaking changes. Focus on the current design and implementation. -- Public API on `ReliaBLEManager` is the supported surface for external consumers. Adding/removing methods there is a breaking change. -- `forceMock: true` is currently passed to `CBCentralManagerFactory.instance(...)` in `BluetoothActor`. The production factory ignores this parameter; the mock factory honors it. Don't "clean it up" — it's load-bearing for the test target. +- Public API on `ReliaBLEManager` is the supported surface for external consumers. Adding/removing methods there is a breaking change that likely requires updating Demo app. +- Do not reference specific sections or decision identifiers ("F5.2", "D-restore", "Option B", etc) from `./docs` or agent turns in the library's public API documentation or code comments. The library's public API is the supported surface for external consumers; `./docs` is for internal design and planning only. It *is* OK to reference these in planning docs or chat sessions, but not in the library's public API documentation or code comments. +- Be sure to add log messages to all new code, and to update existing log messages when the code changes. Observability is critical for BLE debugging in production. Use `LoggingService`, log levels, and the `tags` parameter to categorize logs appropriately. - DocC catalog lives at `Sources/ReliaBLE/Documentation.docc/`. The `swift-docc-plugin` is a package dep so `swift package generate-documentation` works. This documentation **must** be kept up to date with the public API on `ReliaBLEManager` and the overall architecture and usage patterns. - **Generated DocC output goes to `./user-docs` (gitignored), never `./docs`.** `./docs` is AI agent managed plans, designs, investigations, and reviews. \ No newline at end of file diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index f96a8db..3157afa 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -37,7 +37,14 @@ extension ConnectionState { case .reconnecting(let source, let attempt, _): switch source { case .system: "System reconnecting…" - case .library: "Reconnecting (attempt \(attempt ?? 0))" + case .library: + // `attempt == nil` is the library AwaitingRadio projection (radio off / not yet + // usable), not ladder attempt 0. + if attempt == nil { + "Waiting for Bluetooth…" + } else { + "Reconnecting (attempt \(attempt, default: "0"))" + } } case .connected: "Connected" case .disconnecting: "Disconnecting" @@ -109,6 +116,13 @@ struct CentralView: View { NavigationSplitView { Text("ReliaBLE state: \(viewModel.currentState.description)") + if let scanError = viewModel.scanError { + Text(scanError) + .foregroundStyle(.red) + .font(.caption) + .padding(.horizontal) + } + if case BluetoothState.unauthorized(let authState) = viewModel.currentState, authState == .notDetermined { Button("Authorize Bluetooth") { viewModel.authorizeBluetooth() @@ -190,6 +204,10 @@ struct CentralView: View { } } group.addTask { + // Seed first so force-quit relaunch / restore that already linked is visible + // immediately; `connectionStateChanges` does not replay current state. + let initial = await reliaBLE.currentConnectionStates + await viewModel.seedConnectionStates(initial) for await change in reliaBLE.connectionStateChanges { await viewModel.updateConnectionState(change) } @@ -251,6 +269,9 @@ private struct DeviceDetailView: View { let reliaBLE: ReliaBLEManager @State private var autoReconnect = true + /// Surfaces thrown errors from `connect` / `disconnect` (e.g. `bluetoothPoweredOff`). + /// Stream-driven connection captions do not carry those fail-fast throws. + @State private var actionError: String? private var connectionState: ConnectionState? { viewModel.connectionStates[device.id] @@ -277,12 +298,36 @@ private struct DeviceDetailView: View { .foregroundStyle(.secondary) } + if let actionError { + Text(actionError) + .foregroundStyle(.red) + .font(.caption) + .multilineTextAlignment(.center) + } + Button(action: { let handle = reliaBLE.peripheral(id: device.id) + actionError = nil if isActive { - Task { try? await handle.disconnect() } + Task { + do { + try await handle.disconnect() + } catch { + await MainActor.run { + actionError = "Disconnect Failed: \(error)" + } + } + } } else { - Task { try? await handle.connect(autoReconnect: autoReconnect) } + Task { + do { + try await handle.connect(autoReconnect: autoReconnect) + } catch { + await MainActor.run { + actionError = "Connect Failed: \(error)" + } + } + } } }) { Text(isActive ? "Disconnect" : "Connect") @@ -300,6 +345,20 @@ private struct DeviceDetailView: View { } } .padding() + .task(id: device.id) { + // Detail can open before the Central stream task seeds; pull current state for this id. + let states = await reliaBLE.currentConnectionStates + if let state = states[device.id] { + viewModel.seedConnectionStates([device.id: state]) + } + } + .onChange(of: connectionState?.isActiveConnection) { _, isActiveConnection in + // A later stream transition to an in-progress/linked state means the radio recovered + // (or a new attempt started) — drop a stale fail-fast caption from an earlier tap. + if isActiveConnection == true { + actionError = nil + } + } } } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index 0174f79..f1f7a11 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -34,6 +34,7 @@ import ReliaBLE var currentState: BluetoothState = .unknown /// Defaults to the Demo peripheral service UUID so background scans have a required filter. var servicesInput = "12345678-90AB-CDEF-1234-567890ABCDEF" + var scanError: String? var connectionStates: [String: ConnectionState] = [:] private var deviceStore: DeviceStoreActor? @@ -59,13 +60,32 @@ import ReliaBLE connectionStates[change.peripheralId] = change.state } + /// Seeds captions from ``ReliaBLEManager/currentConnectionStates`` so restore/reconnect that + /// already completed (or is in flight) is visible before the next stream event. Stream updates + /// still win afterward via ``updateConnectionState``. + @MainActor + func seedConnectionStates(_ states: [String: ConnectionState]) { + for (id, state) in states { + connectionStates[id] = state + } + } + func authorizeBluetooth() { Task { try? await reliaBLE?.authorizeBluetooth() } } func startScanning() { let services = parseServices(from: servicesInput) - Task { await reliaBLE?.startScanning(services: services) } + scanError = nil + Task { + do { + try await reliaBLE?.startScanning(services: services) + } catch { + await MainActor.run { + scanError = String(describing: error) + } + } + } } func stopScanning() { diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift index f4ea9f0..80bad12 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift @@ -69,14 +69,35 @@ actor DeviceStoreActor: ModelActor { assertWritesOffMainThread() do { - let allDevices = try modelContext.fetch(FetchDescriptor()) + var allDevices = try modelContext.fetch(FetchDescriptor()) for peripheral in peripherals { - if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) { + let cbUUID = peripheral.cbIdentifier?.uuidString + + // Prefer exact library id match; else same radio via CoreBluetooth UUID (name-derived + // id often drifts: "ReliaBLE Demo" / UUID-string / "iPhone" for one peripheral). + let existingDevice = + allDevices.first(where: { $0.id == peripheral.id }) + ?? cbUUID.flatMap { uuid in allDevices.first(where: { $0.cbUUID == uuid }) } + + if let existingDevice { + // Identity drift: keep one SwiftData row, adopt the live library id for connect(). + if existingDevice.id != peripheral.id { + existingDevice.id = peripheral.id + } + if let cbUUID { + existingDevice.cbUUID = cbUUID + } existingDevice.name = peripheral.name existingDevice.lastSeen = peripheral.lastSeen } else { - let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: peripheral.lastSeen) + let newDevice = Device( + id: peripheral.id, + name: peripheral.name, + lastSeen: peripheral.lastSeen, + cbUUID: cbUUID + ) modelContext.insert(newDevice) + allDevices.append(newDevice) } } try modelContext.save() diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/Models/Device.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/Models/Device.swift index ab56c95..bcf556b 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/Models/Device.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/Models/Device.swift @@ -27,13 +27,18 @@ import SwiftData @Model final class Device { + /// Library ``Peripheral/id`` (name-derived today). May change across launches when GAP/local + /// name availability changes; ``cbUUID`` is the stable merge key. var id: String + /// CoreBluetooth `CBPeripheral.identifier` when known — stable per central for the same radio. + var cbUUID: String? var name: String? var lastSeen: Date? - init(id: String, name: String?, lastSeen: Date?) { + init(id: String, name: String?, lastSeen: Date?, cbUUID: String? = nil) { self.id = id self.name = name self.lastSeen = lastSeen + self.cbUUID = cbUUID } } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift index 7bdf154..3c65011 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift @@ -80,6 +80,8 @@ struct ReliaBLE_DemoApp: App { reconnectPolicy.jitter = defaults.object(forKey: "reconnectPolicy.jitter") as? Double ?? 0.2 config.reconnectPolicy = reconnectPolicy + config.idleDisconnectInterval = defaults.object(forKey: "idleDisconnectInterval") as? TimeInterval ?? 5.0 + 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 39d695d..65db669 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift @@ -38,6 +38,7 @@ struct SettingsView: View { @AppStorage("reconnectPolicy.initialDelay") private var initialDelay = 1.0 @AppStorage("reconnectPolicy.maxDelay") private var maxDelay = 30.0 @AppStorage("reconnectPolicy.jitter") private var jitter = 0.2 + @AppStorage("idleDisconnectInterval") private var idleDisconnectInterval = 5.0 var body: some View { NavigationView { @@ -72,6 +73,19 @@ struct SettingsView: View { } header: { Text("Reconnect Policy") } + + Section { + VStack(alignment: .leading, spacing: 4) { + Text("Idle Disconnect: \(String(format: "%.1f", idleDisconnectInterval))s") + Slider(value: $idleDisconnectInterval, in: 0...30, step: 0.5) + } + + Text("Changes take effect on next app launch.") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Connection Lifecycle") + } } .navigationTitle("Settings") } diff --git a/PRD.md b/PRD.md index 43123a9..0d6cecf 100644 --- a/PRD.md +++ b/PRD.md @@ -45,12 +45,13 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- ### Connection model (work-driven primary) - **Primary path:** work drives the link. A non-empty per-`Peripheral` command queue causes auto-connect (and discovery to *ready* when required). When the queue is empty and there is no manual-connect hold, start **idle disconnect** (global config, **default 5 seconds**). -- **Manual connect:** `Peripheral.connect(autoReconnect:)` / `disconnect()` set a manual-connect hold that suppresses idle teardown while held. Documented as Advanced; expected to be rare. Same ensure-linked path as work-driven connect—not a second connection stack or either-or mode enum. +- **Manual connect:** `Peripheral.connect(autoReconnect:)` / `disconnect()` set a manual-connect hold that suppresses idle teardown while held. Documented as Advanced; expected to be rare. Same ensure-linked path as work-driven connect—not a second connection stack or either-or mode enum. Holds are durable across relaunch when state restoration is configured; work is process-scoped and never restored (FR-11.6.3). - **Reconnect (Approach B):** - **Tier-0** (OS `CBConnectPeripheralOptionEnableAutoReconnect`): enabled on work-driven connects while the link is up; **ended** when idle teardown or intentional disconnect cancels the connection. - **Tier-1** (library exponential-backoff ladder): armed on unexpected disconnect **only while** the command queue is non-empty (or a manual-connect hold with reconnect desired). Disarmed when the queue is empty and there is no such hold. - Accepted gap: during the idle grace window, Tier-0 may reconnect once with an empty queue; if still quiet and no manual-connect hold, cancel again. - **PoweredOn:** work submission (scan, connect, command/`run`) **awaits** a usable radio (`PoweredOn`) rather than silently no-op’ing. Terminal states (unauthorized, unsupported, powered off per policy) **fail** promptly with typed errors. Bluetooth state remains observable for UI gating. +- **Demand across outages:** a radio power cycle invalidates live peripheral references but preserves demand; demanded links re-establish when the radio returns, and the waiting period is visible as `reconnecting` without attempt values (FR-11.6). - Manager-level `connect(to:)` as the primary app API is a **refactor target**: connect/disconnect/run/discovery belong on **`Peripheral`**. ### Implementation sequencing (v1) @@ -71,12 +72,12 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- 1. Reliability of Communication: - FR-1.1: Implement error detection and correction mechanisms for each BLE transaction (command/watchdog layer; builds on FR-4/FR-5 after FR-10). -- FR-1.2: Ensure automatic reconnection per the connection model (Approach B: Tier-0 while linked on work-driven connects; Tier-1 ladder with exponential backoff while work is pending or a manual-connect hold requests reconnect). On reconnection, services and characteristics must be re-discovered rather than reused, as part of returning to a discovery-*ready* state (FR-10.6, FR-10.3); a re-established link alone is not sufficient to resume characteristic I/O. Command-layer reconnect-and-rerun (FR-4/FR-5) depends on this ready transition rather than treating "connected again" as enough. (Tier-1 backoff substrate exists; queue/hold gating, idle cancel of Tier-0, and discovery re-run remain open.) +- FR-1.2: Ensure automatic reconnection per the connection model (Approach B: Tier-0 while linked on work-driven connects; Tier-1 ladder with exponential backoff while work is pending or a manual-connect hold requests reconnect). On reconnection, services and characteristics must be re-discovered rather than reused, as part of returning to a discovery-*ready* state (FR-10.6, FR-10.3); a re-established link alone is not sufficient to resume characteristic I/O. Command-layer reconnect-and-rerun (FR-4/FR-5) depends on this ready transition rather than treating "connected again" as enough. (Both tiers are gated on demand—pending work or a reconnect-desiring manual-connect hold—and idle teardown cancels the connection, which ends Tier-0. Discovery re-run on reconnection remains open, pending FR-10.) - FR-1.3: Provide status updates on connection stability and data transmission integrity. - ✅ FR-1.3.1: Provide status updates on connection stability (e.g. connected, disconnected, reconnecting), exposed in a device-centric way on `Peripheral` (and/or equivalent streams) as the type model lands. - FR-1.3.2: Provide status updates on data transmission integrity (command/transaction layer). -- FR-1.4: **PoweredOn gating for work:** Scan, connect, and command submission must await `PoweredOn` (or equivalent usable state) instead of silently no-op’ing when the radio is not ready. Terminal unusable states fail with typed errors. Observability of Bluetooth state for UI remains required. -- FR-1.5: **Idle disconnect:** When a `Peripheral` has no pending/queued commands and no manual-connect hold, disconnect after a configurable idle interval. Default interval is **5 seconds**. Configuration is **global** (not per-peripheral) unless a future requirement explicitly adds per-device overrides. +- ✅ FR-1.4: **PoweredOn gating for work:** Scan, connect, and command submission must await `PoweredOn` (or equivalent usable state) instead of silently no-op’ing when the radio is not ready. Terminal unusable states fail with typed errors. Observability of Bluetooth state for UI remains required. Transient states (`resetting`, `unknown`, and the pre-first-state window) await; `poweredOff`, `unsupported`, and `unauthorized` are terminal and fail immediately. Scan and connect satisfy this today; command submission inherits the same await when FR-4/FR-5 land, and the internal work-acquisition path must adopt it at that point. +- ✅ FR-1.5: **Idle disconnect:** When a `Peripheral` has no pending/queued commands and no manual-connect hold, disconnect after a configurable idle interval. Default interval is **5 seconds**. Configuration is **global** (not per-peripheral) unless a future requirement explicitly adds per-device overrides. 2. Public Interface for Easy Integration: @@ -120,7 +121,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - FR-4.2.3: Read-write (both read from and write to peripherals). - FR-4.2.4: Write-only (send data to peripherals). - FR-4.3: Implement parsing of responses from peripherals into a usable Swift data structure (app-supplied decode as appropriate). -- FR-4.4: **Work-driven link:** Enqueueing/running a command on a disconnected `Peripheral` must auto-connect (and run discovery to ready as needed) without requiring a prior Manual `connect`, unless product policy for never-seen ids chooses fail-fast (implementation planning). +- FR-4.4: **Work-driven link:** Enqueueing/running a command on a disconnected `Peripheral` must auto-connect (and run discovery to ready as needed) without requiring a prior Manual `connect`. **Never-seen-id policy is fail-fast:** work targeting an id with no live CoreBluetooth peripheral fails with a typed not-found error. The library does not start a scan and does not retry behind that failure; recovery is retrieve-only (system cache / known-id retrieval), plus an opportunistic relink when an app-driven scan rediscovers an id that already has outstanding demand. Demand is **retained** across such a failure—it is dropped only by explicit `disconnect()` or work completion—so the failure is reported to the caller without silently discarding intent. Library-owned scan policy that would close this gap belongs to FR-8.2. - FR-4.5: **Reconnect-and-rerun:** On unexpected disconnect with commands still queued or in flight, after link recovery and return to discovery-*ready*, retry/resume command execution so transient drops do not require the app to re-drive the queue (idempotent command design preferred). - FR-4.6: **Exactly-once completion:** Each command finishes with a single terminal success or failure (no double completion). - FR-4.7: **Watchdogs:** Enforce per-step (or per-command) timeouts; streaming/multi-frame commands may reset the watchdog per frame as specified in implementation. @@ -174,10 +175,10 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - FR-8.5: Unique Identifier from Manufacturing Data: - FR-8.5.1: Provide an option for the integrating app to process manufacturing data to derive a unique identifier for each peripheral. - FR-8.5.2: Include an API method or property where the integrating app can return this identifier back to the library for more accurate peripheral identification and management. - - FR-8.5.3: Once identified, maintain this mapping of the unique identifier to the peripheral's BLE address or other identifying characteristics to ensure consistent tracking across sessions or reconnections. Handle interning and discovery matching (FR-2.4, FR-10.6.2) must adopt this identity model when available. + - FR-8.5.3: Once identified, maintain this mapping of the unique identifier to the peripheral's BLE address or other identifying characteristics to ensure consistent tracking across sessions or reconnections. Handle interning and discovery matching (FR-2.4, FR-10.6.2) must adopt this identity model when available. When a previously identified peripheral reappears with a **new** CoreBluetooth `identifier` (e.g. peripheral process death, device reboot, or random address rotation) and/or incomplete interim advertisement metadata (missing name/localName, platform default name such as "iPhone", manufacturer data arriving on a later packet): resolve it to the **same** `Peripheral.id` / discovery entry and do not leave a permanent second catalog row for the same physical device once stable identity is known; **rebind** the live CoreBluetooth reference under that id and keep **demand** (pending work or a reconnect-desiring manual-connect hold) attached so reconnection continues against the rebound peripheral without requiring a new Manual connect to a different row; and keep identity resolution and rebinding safe with an in-flight system (Tier-0) or library (Tier-1) reconnect — no orphaned mid-connect `CBPeripheral` that triggers CoreBluetooth "unused peripheral" cancellation solely due to map overwrite. Fallback when manufacturer/stable identity is **unavailable** remains best-effort (document the interim name/`cbIdentifier` limits). **Interim (not FR-8.5):** prefer advertisement local name over GAP/`CBPeripheral.name`; persist `cbUUID` with durable holds and rebind demand when the name-derived id drifts. That does not replace manufacturer-data identity. - FR-8.5.4: Settle how the raw advertisement feed (FR-8.1.3, FR-8.1.4) correlates to the identity model — specifically whether `PeripheralDiscoveryEvent` (or equivalent) exposes the app-facing peripheral id alongside the CoreBluetooth identifier. This is deferred here deliberately: resolving it before FR-8.5 would bake in the interim name-derived identity (`name → localName → uuidString`) and force a second breaking change once manufacturer-data identity lands. -- FR-8.6: Scanning respects FR-1.4 (await PoweredOn / fail terminal states)—no silent no-op when the radio is not ready. +- ✅ FR-8.6: Scanning respects FR-1.4 (await PoweredOn / fail terminal states)—no silent no-op when the radio is not ready. A scan parked on a transient state completes successfully without scanning if `stopScanning()` is called or a later scan request supersedes it; only task cancellation surfaces as a cancellation error. 9. Logging Support: @@ -194,7 +195,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - Command successes or failures - ✅ Scanning start/stop - Service/characteristic discovery events (discovery start/completion/failure, readiness transitions, GATT table changes, subscription state changes) - - Idle connect/disconnect and Manual connect/disconnect + - ✅ Idle connect/disconnect and Manual connect/disconnect - Security events (e.g., encryption initiation or failure) - Data chunking operations @@ -258,11 +259,16 @@ only where the gate must be honored. 11. Connection Lifecycle on `Peripheral`: -- FR-11.1: **Work-driven connect:** When work requires a link (non-empty command queue, or other library-defined work that needs a connection), the library connects the `Peripheral` without a prior Manual `connect` call. -- FR-11.2: **Manual connect:** `Peripheral.connect(autoReconnect: Bool)` sets a manual-connect hold (suppresses idle teardown). `Peripheral.disconnect()` clears the hold and intentionally cancels the connection. The `autoReconnect` flag controls whether Tier-0/Tier-1 apply for that hold, consistent with Approach B. Primary docs emphasize work-driven usage; the manual-connect APIs are documented as Advanced. -- FR-11.3: **Idle teardown:** Per FR-1.5—only when queue empty and no manual-connect hold; cancel connection (drops Tier-0). -- FR-11.4: **Single state machine:** Work-driven connect and Manual connect share one ensure-linked implementation (PoweredOn await, connect, discover to ready). No parallel connection stacks. -- FR-11.5: Connection-state observation remains available (FR-1.3.1) and must distinguish intentional disconnect, unexpected drop, and reconnecting where applicable. +- ✅ FR-11.1: **Work-driven connect:** When work requires a link (non-empty command queue, or other library-defined work that needs a connection), the library connects the `Peripheral` without a prior Manual `connect` call. +- ✅ FR-11.2: **Manual connect:** `Peripheral.connect(autoReconnect: Bool)` sets a manual-connect hold (suppresses idle teardown). `Peripheral.disconnect()` clears the hold and intentionally cancels the connection. The `autoReconnect` flag controls whether Tier-0/Tier-1 apply for that hold, consistent with Approach B. Primary docs emphasize work-driven usage; the manual-connect APIs are documented as Advanced. The hold is registered **before** the radio await, so `connect()` while Bluetooth is off records the intent and still reports the typed error; the link establishes when the radio returns if `autoReconnect` is true. `disconnect()` succeeds even when there is no live link to cancel—dropping intent must always be possible. +- ✅ FR-11.3: **Idle teardown:** Per FR-1.5—only when queue empty and no manual-connect hold; cancel connection (drops Tier-0). +- ✅ FR-11.4: **Single state machine:** Work-driven connect and Manual connect share one ensure-linked implementation (PoweredOn await, connect, discover to ready). No parallel connection stacks. The link half is delivered; the discover-to-ready stage joins the same path with FR-10. +- ✅ FR-11.5: Connection-state observation remains available (FR-1.3.1) and must distinguish intentional disconnect, unexpected drop, and reconnecting where applicable. Idle teardown is indistinguishable from an app-initiated disconnect and is reported as intentional. On `reconnecting`, an absent attempt/retry time means "waiting for a usable radio, not yet on the backoff ladder," distinct from an armed ladder step carrying real values. + +- ✅ FR-11.6: **Demand durability:** Demand (pending work, or a manual-connect hold) is the single signal that keeps a link alive, and it outlives radio outages: + - FR-11.6.1: A Bluetooth power cycle invalidates live peripheral references and reports each tracked peripheral as disconnected due to Bluetooth unavailability. Demand is preserved across the outage, and links for which demand wants reconnect are re-established when the radio returns. + - FR-11.6.2: While a demanded link is waiting for the radio to return, connection state reports `reconnecting` (library source, no attempt/retry values) so the app can distinguish "the library will bring this back" from "this is over." A hold created with `autoReconnect: false` settles at disconnected instead, because nothing will re-issue for it. + - FR-11.6.3: **Manual-connect holds are durable across process relaunch** when a restore identifier is configured: the hold—including its `autoReconnect` value and the CoreBluetooth peripheral UUID when known—is persisted and rehydrated on relaunch **whether or not** CoreBluetooth restores a `CBPeripheral` (force-quit often yields an empty or missing restore dictionary). The library then retrieves or waits for discovery to re-issue if reconnect is desired. A restored OS link the app explicitly asked for is retained rather than idled out. Work is process-scoped and is never resurrected: a restored link with no persisted hold starts the idle timer (FR-1.5). Manager shutdown and transient radio invalidation must not erase persisted holds. ### Non-Functional Requirements @@ -283,7 +289,7 @@ only where the gate must be honored. 3. Performance: - NFR-3.1: Ensure low latency in command execution and response handling to meet real-time application needs. -- NFR-3.2: Optimize for battery life on iOS devices by minimizing unnecessary BLE activity (including idle disconnect and filtered discovery). +- NFR-3.2: Optimize for battery life on iOS devices by minimizing unnecessary BLE activity (including idle disconnect and filtered discovery). Durable manual-connect holds (FR-11.6.3) are an explicit, documented exception: they trade battery for session continuity across relaunch, and apply only to links the app explicitly requested. Links the app never asked for—residual, OS-reconnected, or restored without a hold—still idle out. 4. Scalability and Maintainability: diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index bdd07e3..2ac0fe6 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -130,6 +130,70 @@ fileprivate final class TaskRegistry: @unchecked Sendable { } } +// MARK: - Demand Bookkeeping Types + +/// A manual-connect hold for one peripheral. `reconnectDesired` mirrors the `autoReconnect` argument +/// passed to ``Peripheral/connect(autoReconnect:)``. +/// +/// `cbIdentifier` is the CoreBluetooth peripheral UUID when known. It is the stable key for matching +/// a hold across relaunch when the app-facing name-derived `id` drifts (e.g. `"ReliaBLE Demo"` → +/// `"iPhone"` for the same radio). +struct ManualConnectHold: Sendable { + var reconnectDesired: Bool + /// Stable CoreBluetooth identity for rediscovery / restore rebind. `nil` only when a hold is + /// registered with no live peripheral yet (e.g. connect while the radio is down). + var cbIdentifier: UUID? +} + +/// One row of the durable manual-connect hold map on disk. +private struct PersistedHoldRecord: Sendable, Equatable { + var id: String + var reconnectDesired: Bool + /// UUID string form of `CBPeripheral.identifier`, when known at persist time. + var cbUUID: String? +} + +/// An opaque, actor-issued handle for one outstanding work lease. +/// +/// Storing a `Set` per peripheral (rather than a bare refcount) makes an already-released token +/// *detectable*: a refcount cannot tell a double-release of token A from a legitimate release of token B. +struct WorkLeaseToken: Sendable, Hashable { + let id: String + let leaseID: UUID +} + +/// Why ``BluetoothActor/reevaluateLink(id:reason:)`` is being asked to (re)establish a link. +/// +/// Disambiguates the issue gate: bare `demand` governs idle suppression only, while the connect-issuing +/// arm requires `wantsReconnect(id)` **or** an explicit caller intent (D-1). +enum LinkReason: Sendable { + case explicitConnect + case workAcquired + case radioReturned + case relinkAfterIntentional + case discoveredWhileDemanded +} + +/// How a parked ``startScanning(services:)`` waiter was resolved, used by its resumed code to +/// decide whether to issue a scan, re-park, or return (D-2 resume-reason table). +private enum ScanWaiterResumeReason: Sendable { + /// Radio reached `.poweredOn`; the scan was issued (or is issuable by the current owner). + case poweredOn + /// ``stopScanning()`` resolved the waiter — successful void, no scan. + case stopped + /// Superseded by a later ``startScanning(services:)`` — successful void, no scan. + case superseded +} + +/// A single parked ``startScanning(services:)`` invocation. There is at most one at a time, but each +/// waiter carries its **own** identity and filter so a superseded waiter must consult itself, never a +/// shared slot, when it resumes — fixing the earlier conflation of "current waiter" and "pending +/// request" (D-2). +private struct ScanWaiter { + let id: UUID + let services: [CBUUID]? + let continuation: CheckedContinuation +} // MARK: - BluetoothActor /// Actor that serializes all CoreBluetooth interactions for a single ``ReliaBLEManager`` stack. @@ -147,6 +211,10 @@ actor BluetoothActor { private nonisolated let eventPipeline = EventPipeline() /// Nonisolated box so `deinit` may cancel reconnect tasks without touching actor-isolated state. private nonisolated let taskRegistry = TaskRegistry() + /// A *separate* registry for idle-grace timers, distinct from ``taskRegistry`` so a peripheral can + /// legitimately have both a reconnect-ladder task and an idle task pending without key collision + /// silently cancelling the wrong one (D-1). + private nonisolated let idleTaskRegistry = TaskRegistry() // MARK: - Actor-Isolated State @@ -171,6 +239,26 @@ actor BluetoothActor { /// resumed together once `CBCentralManager.authorization` resolves away from `.notDetermined`. private var authorizationContinuations: [UUID: CheckedContinuation] = [:] + /// Continuations for in-flight ``waitUntilPoweredOn()`` calls parked on a transient radio state + /// (`.resetting` / `.unknown`), keyed by a per-call UUID so a cancelled call can resume just its own + /// continuation. All pending continuations are resumed together from ``resolvePoweredOnWaiters()`` on + /// the next central state update. + private var poweredOnContinuations: [UUID: CheckedContinuation] = [:] + + /// A single parked ``startScanning()`` call awaiting a usable radio. There is at most one pending + /// scan at a time, so a single slot suffices. The waiter carries its **own** identity and filter + /// (see ``ScanWaiter``) so a superseded waiter can never mistake a newer request's services for + /// its own when it resumes. + private var scanWaiter: ScanWaiter? + + /// The restored-scan deferral (``pendingRestoredScanServices``) and this scan waiter are two + /// distinct mechanisms kept deliberately separate rather than collapsed into one (plan D-2 open + /// item): the restored-scan stash is driven by ``handleCentralManagerStateUpdate()``'s `.poweredOn` + /// handling, whereas an app ``startScanning(services:)`` waiter is driven by + /// ``resolvePoweredOnWaiters()``. They never both fire because every app scan clears + /// ``pendingRestoredScanServices`` — the app-requested filter wins D-2 precedence. Collapsing + /// them would couple the restore deferral to the app-visible await state for no benefit. + /// The current Bluetooth state. var currentBluetoothState: BluetoothState = .unknown @@ -205,6 +293,9 @@ actor BluetoothActor { private var reconnectPolicy: ReconnectPolicy /// Stable CoreBluetooth restore identifier; `nil` disables state restoration. private var restoreIdentifier: String? + /// Idle interval (seconds) before a per-peripheral link with no demand is torn down. `0` tears + /// down as soon as demand reaches zero. Mutated by the test-only ``setIdleDisconnectInterval(_:)`` hook. + private var idleDisconnectInterval: TimeInterval /// Scan filter restored via `willRestoreState` when the central was not yet powered on. private var pendingRestoredScanServices: [CBUUID]? private var pendingRestoredScanOptions: RestoredScanOptions? @@ -212,6 +303,42 @@ actor BluetoothActor { private var intentionalDisconnects: Set = [] private var reconnectAttempts: [String: Int] = [:] + /// Live work-lease UUIDs per peripheral. `workCount(id)` is `activeLeases[id]?.count ?? 0`. + /// A `Set` rather than a bare `Int` so an already-released token is *detectable*. + private var activeLeases: [String: Set] = [:] + private var manualConnectHold: [String: ManualConnectHold] = [:] + /// Ensures disk holds are loaded into ``manualConnectHold`` at most once per process (willRestore + /// and/or first `.poweredOn`). Idempotent re-entry is a no-op. + private var didRehydrateDiskHolds = false + + /// Generation counter per peripheral for the idle-grace timer, guarding against the same + /// cancel-during-sleep race the reconnect ladder defends against: bump on arm, capture, re-check + /// on wake. A stale generation means the timer was superseded and must not fire (D-1 event 6/7). + private var idleGeneration: [String: UInt64] = [:] + + /// Obsolete app-facing id → the id that replaced it, written by + /// ``migrateIdentity(from:to:cbIdentifier:)``. + /// + /// A ``Peripheral`` handle is interned under the id in force when it was vended, and its `id` is + /// immutable — so an app that stored a handle before an identity upgrade keeps addressing the + /// actor by a key nothing is filed under any more. Without this map its `connect` / `disconnect` + /// silently no-op against the migrated hold, leaving a live link the app believes it dropped. + /// Entries are re-pointed rather than chained, so a lookup is always one hop. + /// + /// The map serves the handle surface, not the stream. A replaced id keeps working as a handle — + /// ``canonicalId(_:)`` routes its calls and ``setConnectionState(_:for:)`` mirrors state back onto + /// it — while on ``connectionStateChanges`` it receives one terminal at migration and then goes + /// quiet, because a subscriber keyed by an id has no way to be told that id moved. Consumers that + /// need to follow a rename read ``discoveredPeripherals``, where one radio is always one row. + private var migratedIds: [String: String] = [:] + + /// Generation counter per peripheral for the Tier-1 reconnect ladder. The same cancel-during-sleep + /// defense as ``idleGeneration``: a cancel landing while the ladder task is sleeping must not let a + /// superseded task drive ``performReconnect``. Bump on arm, capture in ``scheduleReconnect``, and + /// re-check on wake (closing the prior critique's taskRegistry cancel-during-sleep race). + private var reconnectGeneration: [String: UInt64] = [:] + + // MARK: - Initialization /// Bridge to the owning manager's handle registry. @@ -228,59 +355,24 @@ actor BluetoothActor { log: LoggingService, reconnectPolicy: ReconnectPolicy, restoreIdentifier: String?, + idleDisconnectInterval: TimeInterval, registry: PeripheralRegistryBridge ) { self.log = log self.reconnectPolicy = reconnectPolicy self.restoreIdentifier = restoreIdentifier + if !idleDisconnectInterval.isFinite || idleDisconnectInterval < 0 { + self.idleDisconnectInterval = 5.0 + } else { + self.idleDisconnectInterval = idleDisconnectInterval + } self.registry = registry } deinit { eventPipeline.finish() taskRegistry.cancelAll() - } - - /// Terminal teardown for tests/harness. Clears volatile state only — does **not** touch - /// persisted reconnect-intent `UserDefaults`. - func shutdown() { - guard !isShutdown else { return } - isShutdown = true - - eventPipeline.finish() - taskRegistry.cancelAll() - delegateEventTask?.cancel() - delegateEventTask = nil - - for continuation in stateContinuations.values { continuation.finish() } - for continuation in discoveryContinuations.values { continuation.finish() } - for continuation in peripheralsContinuations.values { continuation.finish() } - for continuation in connectionStateChangesContinuations.values { continuation.finish() } - stateContinuations.removeAll() - discoveryContinuations.removeAll() - peripheralsContinuations.removeAll() - connectionStateChangesContinuations.removeAll() - - let pendingAuth = authorizationContinuations - authorizationContinuations.removeAll() - for continuation in pendingAuth.values { - continuation.resume(throwing: CancellationError()) - } - - centralManager = nil - delegateShim = nil - cbPeripherals.removeAll() - discoveredPeripherals.removeAll() - clearConnectionStates() - // Drop interned handles along with the stack they belong to. Handles the app still holds keep working, - // orphaned, throwing `.bluetoothUnavailable`. A radio reset (`invalidatePeripherals`) deliberately does not - // do this — a handle must survive one with its metadata intact. - registry.removeAllHandles() - reconnectEnabled.removeAll() - intentionalDisconnects.removeAll() - reconnectAttempts.removeAll() - pendingRestoredScanServices = nil - pendingRestoredScanOptions = nil + idleTaskRegistry.cancelAll() } // MARK: - Event Streams @@ -449,7 +541,10 @@ actor BluetoothActor { guard !isShutdown else { return } guard centralManager == nil else { return } - log?.info("Initializing CBCentralManager") + let initDiskHolds = formatPersistedHoldRecords(loadPersistedHoldRecords()) + log?.info( + "Initializing CBCentralManager (restoreIdentifier=\(self.restoreIdentifier ?? "nil"), diskHolds=\(initDiskHolds))" + ) // Consumer-before-factory: start draining the (already-created) pipeline first so a // synchronous `willRestoreState` inside the factory call is not lost. @@ -610,23 +705,215 @@ actor BluetoothActor { } } + // MARK: - PoweredOn + + /// Suspends until the central manager is in a usable state, or throws a typed error for + /// terminal states. Mirrors the authorization-continuation pattern. + /// + /// - Shut down or no central → ``PeripheralError/bluetoothUnavailable`` + /// - `.poweredOff` → ``PeripheralError/bluetoothPoweredOff`` + /// - `.unsupported` → ``PeripheralError/bluetoothUnsupported`` + /// - `.unauthorized` → ``PeripheralError/bluetoothUnavailable`` + /// - `.resetting` / `.unknown` → parks a continuation until the next state update + /// - Task cancellation → ``CancellationError`` + /// + /// - Parameter waiterID: A per-call UUID so the caller (via ``cancelPoweredOnContinuation(_:)``) may + /// cancel just this specific wait. The caller is responsible for minting the UUID — typically the + /// nonisolated façade's `withTaskCancellationHandler` onCancel. + func waitUntilPoweredOn(waiterID: UUID) async throws { + guard !isShutdown else { throw PeripheralError.bluetoothUnavailable } + guard let centralManager else { throw PeripheralError.bluetoothUnavailable } + switch centralManager.state { + case .poweredOn: + return + case .poweredOff: + throw PeripheralError.bluetoothPoweredOff + case .unsupported: + throw PeripheralError.bluetoothUnsupported + case .unauthorized: + throw PeripheralError.bluetoothUnavailable + case .resetting, .unknown: + try await suspendForPoweredOn(waiterID: waiterID) + @unknown default: + throw PeripheralError.bluetoothUnavailable + } + } + + /// Parks a continuation for ``waitUntilPoweredOn(waiterID:)`` until the radio becomes usable. + private func suspendForPoweredOn(waiterID: UUID) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + poweredOnContinuations[waiterID] = continuation + } + } + + /// Resolves any pending ``waitUntilPoweredOn()`` calls based on the current radio state. + /// + /// Called from ``handleCentralManagerStateUpdate()`` on every state update. Terminal states + /// fail all waiters; `.poweredOn` resumes them successfully; transient states leave them parked. + private func resolvePoweredOnWaiters() { + let hasScanWaiter = scanWaiter != nil + guard !poweredOnContinuations.isEmpty || hasScanWaiter else { return } + guard let centralManager else { return } + + switch centralManager.state { + case .poweredOn: + // A parked scan waiter gets its scan issued and completes successfully on power-on. The + // waiter's own filter is used, so a superseded waiter never supplies a stale/reused filter. + if let waiter = scanWaiter { + scanWaiter = nil + beginScan(services: waiter.services) + waiter.continuation.resume(returning: .poweredOn) + } + if !poweredOnContinuations.isEmpty { + let pending = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pending.values { + continuation.resume(returning: ()) + } + } + case .poweredOff: + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothPoweredOff) + case .unsupported: + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothUnsupported) + case .unauthorized: + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothUnavailable) + case .resetting, .unknown: + return + @unknown default: + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothUnavailable) + } + } + + /// Fails every parked ``waitUntilPoweredOn()`` continuation and any parked scan waiter with a + /// terminal radio-state error. + private func failPoweredOnAndScanWaiters(with error: PeripheralError) { + if let waiter = scanWaiter { + scanWaiter = nil + waiter.continuation.resume(throwing: error) + } + if !poweredOnContinuations.isEmpty { + let pending = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pending.values { + continuation.resume(throwing: error) + } + } + } + + /// Resumes a single pending ``waitUntilPoweredOn()`` continuation with a `CancellationError`, + /// if still pending. Invoked from a `withTaskCancellationHandler` onCancel. + func cancelPoweredOnContinuation(_ id: UUID) { + poweredOnContinuations.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + // MARK: - Scanning - func startScanning(services: sending [CBUUID]? = nil) { + /// Starts scanning for peripherals, optionally filtering by specific services. + /// + /// Rather than silently no-op'ing when the radio is not yet usable, this waits for a + /// transient (`.resetting` / `.unknown`) state to resolve and fails fast with a typed + /// ``PeripheralError`` for terminal states (`.poweredOff`, `.unsupported`, `.unauthorized`). + /// + /// A parked scan waiter (on a transient state) has four distinct outcomes, only one of which + /// throws `CancellationError` (D-2): radio reaches `.poweredOn` (scan starts), `stopScanning()` + /// is called (waiter completes successfully, no scan), the waiter is superseded by a later + /// ``startScanning(services:)`` (completes successfully without scanning), or the radio + /// resolves to a terminal state (throws the matching typed error). + func startScanning(services: sending [CBUUID]? = nil, waiterID: UUID) async throws { guard !isShutdown else { log?.warn(tags: [.category(.scanning)], "Attempted to start scan after shutdown") - return + throw PeripheralError.bluetoothUnavailable } guard let centralManager else { log?.warn(tags: [.category(.scanning)], "Attempted to start scan without a central manager") - return + throw PeripheralError.bluetoothUnavailable } - guard centralManager.state == .poweredOn else { - log?.warn(tags: [.category(.scanning)], "Attempted to start scan while central manager is not ready (poweredOn)") + switch centralManager.state { + case .poweredOn: + // An app-requested scan supersedes any deferred restored scan (D-2 filter precedence): + // clear the stashed filter so a later power-on does not also resume it — CoreBluetooth + // has a single scan, last writer wins. + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + beginScan(services: services) + case .poweredOff: + throw PeripheralError.bluetoothPoweredOff + case .unsupported: + throw PeripheralError.bluetoothUnsupported + case .unauthorized: + throw PeripheralError.bluetoothUnavailable + case .resetting, .unknown: + try await parkScanWaiter(services: services, waiterID: waiterID) + @unknown default: + throw PeripheralError.bluetoothUnavailable + } + } + + /// Parks a ``startScanning(services:)`` invocation until the radio resolves, it is superseded + /// by a newer scan request, ``stopScanning()`` is called, or the calling task is cancelled. + /// + /// Supersede semantics: a later ``startScanning(services:)`` completes an earlier parked waiter + /// successfully without scanning (the newer request owns the single-slot scan; coalescing is not + /// attempted because the service filters may differ). Clears the deferred restored scan so an + /// app scan wins filter precedence. + /// + /// The waiter is re-driven after resume, re-reading ``centralManager`` and its state, because a + /// resumed continuation runs on a later actor turn and the radio may have flipped again. + private func parkScanWaiter(services: sending [CBUUID]?, waiterID: UUID) async throws { + // A later startScanning supersedes a parked one: resolve the previous waiter successfully. + // The previous waiter carries its OWN identity/service-filter (D-2), so when it resumes it + // can tell it was superseded and must not re-park or scan — the newer request owns the scan. + if let previous = scanWaiter { + scanWaiter = nil + previous.continuation.resume(returning: .superseded) + } + + // An app-requested scan supersedes any deferred restored scan (D-2 filter precedence). + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + + let reason: ScanWaiterResumeReason = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + scanWaiter = ScanWaiter(id: waiterID, services: services, continuation: continuation) + } + + // Resumed. Decide from OUR OWN resume reason and identity — never a global slot field. + switch reason { + case .stopped, .superseded: + // ``stopScanning()`` resolved this waiter (successful void, no scan), or a newer + // ``startScanning`` superseded it (earlier waiter completes successfully without + // scanning; the newer request owns the pending scan). Either way, return. + return + case .poweredOn: + // The radio reached `.poweredOn` and ``resolvePoweredOnWaiters()`` issued the scan using + // OUR OWN filter. If ``stopScanning()`` raced in meanwhile, ``scanWaiter`` is nil and we + // must not start a scan; otherwise ``resolvePoweredOnWaiters()`` already issued it. Either + // way there is nothing left to do. return } + } + + /// Cancels a parked ``startScanning(services:)`` waiter, invoked from a + /// `withTaskCancellationHandler` onCancel. Only cancels the waiter if its `id` matches the + /// caller-supplied `waiterID`, preventing a cancelled task from stealing a newer request's + /// parked waiter. + func cancelScanWaiter(_ waiterID: UUID) { + guard scanWaiter?.id == waiterID else { return } + let waiter = scanWaiter + scanWaiter = nil + waiter?.continuation.resume(throwing: CancellationError()) + } + /// Issues `scanForPeripherals` for the given filter and broadcasts the resulting state. + private func beginScan(services: sending [CBUUID]?) { if services == nil || services?.isEmpty == true { log?.warn( tags: [.category(.scanning)], @@ -634,6 +921,8 @@ actor BluetoothActor { ) } + guard let centralManager else { return } + lastScanServices = services centralManager.scanForPeripherals(withServices: services, options: nil) if centralManager.isScanning { @@ -644,7 +933,19 @@ actor BluetoothActor { } } + /// Stops scanning and completes any parked scan waiter. Cancelling is always allowed, so this + /// performs no radio wait. + /// + /// A ``startScanning(services:)`` suspended on a transient state is resolved **successfully** + /// (void, no scan starts) — the start-then-stop sequence completed as the app requested and is + /// not an error (D-2). func stopScanning() { + // Complete any parked scan waiter successfully before tearing down the scan. + if let waiter = scanWaiter { + scanWaiter = nil + waiter.continuation.resume(returning: .stopped) + } + guard !isShutdown else { log?.warn(tags: [.category(.scanning)], "Attempted to stop scan after shutdown") return @@ -715,12 +1016,11 @@ actor BluetoothActor { /// Restored `CBPeripheral`s arrive with no peripheral delegate and must be re-associated into /// ``cbPeripherals`` immediately. Connection state is seeded from each peripheral's /// `CBPeripheral.state`; no synchronous reconnect is issued (standing connects are OS-held). - /// Tier-1 reconnect intent is re-armed only for peripherals whose per-connect - /// `autoReconnect: true` intent was persisted before termination — a connection made with - /// `autoReconnect: false` is restored (state seeded, reference registered) without re-arming - /// the library ladder. + /// Reconnect intent is re-armed only for peripherals with a **persisted manual-connect hold**, + /// which rehydrates and suppresses idle; a restored link the app never explicitly asked for + /// starts an idle timer and eventually tears down. Work leases are never rehydrated. /// If Bluetooth is later reported off/unauthorized, ``invalidatePeripherals()`` clears this - /// state intentionally. + /// state intentionally while preserving demand. /// /// Restored peripherals are deliberately **not** emitted on the `peripheralDiscoveries` /// advertisement feed — restoration carries no advertisement payload or RSSI, so consumers @@ -730,16 +1030,27 @@ actor BluetoothActor { let restoredScanServices = payload.state[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] let restoredScanOptions = payload.state[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any] + // Load every durable hold into memory first. Matching restored peripherals then rebinds by + // name or `cbIdentifier`; holds with no restored peripheral stay demanded so a later scan + // or retrieve can reconnect. + rehydrateDiskHoldsIfNeeded() + + let restoreKeys = Array(payload.state.keys).sorted() + let diskHoldsSummary = formatMemoryHolds() log?.info( tags: [.category(.scanning)], - "Restoring BLE state: \(restoredPeripherals.count) peripheral(s), scanServices=\(restoredScanServices ?? [])" + "willRestoreState: \(restoredPeripherals.count) peripheral(s), scanServices=\(restoredScanServices ?? []), scanOptionsPresent=\(restoredScanOptions != nil), dictKeys=\(restoreKeys), memoryHolds=\(diskHoldsSummary)" ) + if restoredPeripherals.isEmpty { + log?.info( + tags: [.category(.connection)], + "willRestoreState delivered no peripherals — disk holds remain in memory for scan/retrieve rebind" + ) + } let now = Date() var didMutatePeripherals = false - - // Reconnect intent persisted across launches; only ids in this set are re-armed below. - let persistedIntent = persistedReconnectIntent() + var heldRestoredIds: [String] = [] for cbPeripheral in restoredPeripherals { // Restored peripherals arrive with no delegate; re-associate into actor-owned maps using the same @@ -757,23 +1068,39 @@ actor BluetoothActor { advertisement: nil ) + // Name-derived id may differ from the id used when the hold was persisted; rebind by UUID. + rebindHoldIfNeeded(to: resolvedId, cbIdentifier: cbPeripheral.identifier) + cbPeripherals[resolvedId] = cbPeripheral didMutatePeripherals = true - // Seed connection state after the live reference is registered. Do not reconnect here. - // Tier-1 intent is re-armed only when it was persisted at connect time (autoReconnect: true). + let restoredName = cbPeripheral.name ?? "nil" + let restoredCbUUID = cbPeripheral.identifier.uuidString + let restoredState = cbPeripheral.state.rawValue + let hasHold = manualConnectHold[resolvedId] != nil + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Restored peripheral cbUUID=\(restoredCbUUID) name=\(restoredName) state=\(restoredState) hasHold=\(hasHold)" + ) + + if hasHold { + syncReconnectIntent(id: resolvedId) + heldRestoredIds.append(resolvedId) + let reconnectDesired = manualConnectHold[resolvedId]?.reconnectDesired == true + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Restored peripheral matched manual-connect hold (reconnectDesired=\(reconnectDesired))" + ) + } + + // Seed connection state after the live reference is registered. Do not reconnect here; + // standing connects are OS-held. `reevaluateLink` below re-issues only when not linked. let connectionState: ConnectionState? switch cbPeripheral.state { case .connected: connectionState = .connected - if persistedIntent.contains(resolvedId) { - reconnectEnabled.insert(resolvedId) - } case .connecting: connectionState = .connecting - if persistedIntent.contains(resolvedId) { - reconnectEnabled.insert(resolvedId) - } case .disconnecting: connectionState = .disconnecting case .disconnected: @@ -785,8 +1112,32 @@ actor BluetoothActor { if let connectionState { setConnectionState(connectionState, for: resolvedId) } + + // Link-retention: a hold suppresses idle and re-issues if not already linked + // (`reconnectDesired: false` suppresses idle but does not re-issue). Without a hold, + // a restored live link starts the idle timer. + if hasHold { + do { + try reevaluateLink(id: resolvedId, reason: .radioReturned) + } catch { + let reason = (error as? PeripheralError) ?? .unknown + if reason == .notFound { + setConnectionState(.failed(reason: .notFound), for: resolvedId) + } + } + } else if connectionState != nil { + // Only linked restored peripherals (connected/connecting) idle out. A disconnected + // restored peripheral has nothing to tear down and must stay untracked. + beginIdleGrace(id: resolvedId) + } } + let memoryHoldsAfterRestore = formatMemoryHolds() + log?.info( + tags: [.category(.connection)], + "willRestoreState complete: heldRestored=\(heldRestoredIds), memoryHolds=\(memoryHoldsAfterRestore)" + ) + if didMutatePeripherals { broadcast(discoveredPeripherals, to: peripheralsContinuations) } @@ -841,15 +1192,36 @@ actor BluetoothActor { switch centralManager.state { case .poweredOn: + // Cold-start path when willRestore never fired (typical after force quit with nothing + // for the OS to restore). Idempotent if restore already rehydrated. + rehydrateDiskHoldsIfNeeded() + let memoryHoldsSummary = formatMemoryHolds() + let diskHoldsSummary = formatPersistedHoldRecords(loadPersistedHoldRecords()) + let trackedIds = discoveredPeripherals.map(\.id) + let hasPendingRestoredScan = pendingRestoredScanServices != nil + log?.info( + tags: [.category(.connection)], + "Radio poweredOn: memoryHolds=\(memoryHoldsSummary), diskHolds=\(diskHoldsSummary), tracked=\(trackedIds), pendingRestoredScan=\(hasPendingRestoredScan)" + ) refreshPeripherals() + // Bind any held UUIDs the OS still knows about even when discovery/restore lists were empty. + retrieveAndBindHeldPeripherals() if let services = pendingRestoredScanServices { resumeRestoredScan(services: services, options: pendingRestoredScanOptions) } - case .poweredOff, .unknown: - // These states do not invalidate peripherals. - break - case .resetting, .unsupported, .unauthorized: + + // Radio-return sweep (D-1 event 12): after the radio returns, re-link every demanded id + // and start the idle timer for restored links with no demand. ``refreshPeripherals()`` + // re-populates ``cbPeripherals`` from the CoreBluetooth cache first, so ids not + // retrievable surface as terminal `.failed(reason: .notFound)` (stranded lease). + sweepRadioReturnedDemand() + case .resetting, .unsupported, .unauthorized, .poweredOff: + // `.poweredOff` joins the invalidate triggers (D-1 event 13): CoreBluetooth invalidates + // `CBPeripheral` objects across a power cycle, so the cached `.connected` must clear and + // demand re-issue on return. `.unknown` is genuinely transient and does not invalidate. invalidatePeripherals() + case .unknown: + break @unknown default: log?.error("Unknown CBCentralManager state encountered: \(centralManager.state.rawValue)") assertionFailure("Unknown CBCentralManager state encountered: \(centralManager.state.rawValue)") @@ -857,6 +1229,48 @@ actor BluetoothActor { updateState() resolvePendingAuthorization() + resolvePoweredOnWaiters() + } + + /// Radio-return sweep (D-1 event 12), run from ``handleCentralManagerStateUpdate``'s `.poweredOn` + /// arm. For every id with demand that is not yet linked, re-evaluate the link (this is what + /// re-establishes a demanded link after a radio cycle); and for every id with no demand, start + /// an idle grace if linked (this is D-restore case 2, a restored link without a hold). + /// + /// ``reevaluateLink`` runs in delegate context here, so it cannot propagate a throw. ``.notFound`` + /// for a **work lease** without a live peripheral is terminal (stranded lease) — publish it via + /// ``setConnectionState``. Manual holds without a live ref are non-terminal (await retrieve / + /// discovery) and no longer throw from ``reevaluateLink``. + private func sweepRadioReturnedDemand() { + // Re-link every id that currently has demand (from work leases or manual-connect holds), not + // just ids with a tracked connection state — a hold created while the radio was down (via a + // `connect()` that threw) leaves the id untracked yet still demanding, and it must be linked + // when the radio returns (D-hold). + let demandedIds = Set(activeLeases.keys).union(manualConnectHold.keys) + let demandedList = demandedIds.sorted() + let liveRefList = Array(cbPeripherals.keys).sorted() + log?.info( + tags: [.category(.connection)], + "Radio-return sweep: demandedIds=\(demandedList), liveRefs=\(liveRefList)" + ) + for id in demandedIds { + do { + try reevaluateLink(id: id, reason: .radioReturned) + } catch { + let reason = (error as? PeripheralError) ?? .unknown + if reason == .notFound { + log?.warn(tags: [.peripheral(id), .category(.connection)], + "Radio-return relink failed (.notFound) — demand retained, no scan/retry") + setConnectionState(.failed(reason: .notFound), for: id) + } + // Other radio errors (e.g. radio flipped off again) leave demand as-is; a later + // sweep or evaluation re-drives it. + } + } + // Idle out linked ids with no demand (D-restore case 2, a restored link without a hold). + for id in Array(connectionStates.keys) where !demand(id: id) { + beginIdleGrace(id: id) + } } func handlePeripheralDiscovered( @@ -878,17 +1292,80 @@ actor BluetoothActor { // Identity resolution and the snapshot upsert live in the shared helper, so discovery and state // restoration cannot drift apart. + // Prefer advertisement local name (what the peripheral app intends, e.g. "ReliaBLE Demo") over + // GAP/`CBPeripheral.name` (often the OS device name, e.g. "iPhone"). + let preferredName = advertisement.localName ?? cbPeripheral.name let resolvedId = resolveAndUpsertDiscovered( cbPeripheral: cbPeripheral, - name: cbPeripheral.name ?? advertisement.localName, + name: preferredName, rssi: rssi, lastSeen: Date(), advertisement: advertisement ) + // Name-derived id may differ from the hold key written last session; rebind by UUID first so + // demand() sees the hold under the resolved id. Prefer-localName upgrades may move a hold + // from a prior GAP-only id ("iPhone") onto the ad name ("ReliaBLE Demo"). + rebindHoldIfNeeded(to: resolvedId, cbIdentifier: cbPeripheral.identifier) + // Stash the live reference under the resolved id. Never escapes the actor. + let firstLiveRefThisLaunch = cbPeripherals[resolvedId] == nil cbPeripherals[resolvedId] = cbPeripheral broadcast(discoveredPeripherals, to: peripheralsContinuations) + + // D-1 event 15 (optional polish): discovery upserting an id that already has demand links it + // opportunistically — the cheap recovery for the `notFound` terminal case. It starts **no** + // scan of its own; it only links when something else (typically an app-driven scan) finds a + // device demand is already waiting on. Restricted to ids nothing is currently driving (see the + // skip set below) so repeated advertisements do not spam connect attempts against an + // already-linked or in-transition peripheral; richer demand-driven scan policy is FR-8.2. + let hasDemand = demand(id: resolvedId) + // First live ref only — avoids advertising spam while still logging demand/hold mismatches. + if firstLiveRefThisLaunch { + let wants = wantsReconnect(id: resolvedId) + let name = preferredName ?? "nil" + let cbUUID = cbPeripheral.identifier.uuidString + let stateDesc = String(describing: connectionStates[resolvedId]) + let holdDesc = manualConnectHold[resolvedId].map { + "reconnectDesired=\($0.reconnectDesired)" + } ?? "none" + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "First discovery this launch: cbUUID=\(cbUUID) name=\(name) memoryDemand=\(hasDemand) wantsReconnect=\(wants) hold=\(holdDesc) connectionState=\(stateDesc)" + ) + } + if hasDemand { + // Skip ids already being linked or torn down; genuinely not-linked demanded ids + // (`.failed`, `.disconnected`, or currently untracked/`nil`) are re-issued here so the + // rediscovery of a stranded demand is the cheap recovery path (D-never). A ladder step is + // already being driven and is skipped too. + // + // The AwaitingRadio projection (`source: .library` with a `nil` attempt) is the exception: + // nothing is driving it — it is demand parked until a radio and a live reference exist, + // and this is the moment both do. Re-issuing here is also what makes the ordering safe, + // since the live reference is not bound until after identity resolution has returned. + // Once this issues, the state becomes `.connecting` and later advertisements skip again. + switch connectionStates[resolvedId] { + case .connected, .connecting, .disconnecting, + .reconnecting(source: .system, attempt: _, nextRetryAt: _): + break + case .reconnecting(source: .library, attempt: .some, nextRetryAt: _): + break + default: + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Discovered while demanded — reissuing connect" + ) + do { + try reevaluateLink(id: resolvedId, reason: .discoveredWhileDemanded) + } catch { + let reason = (error as? PeripheralError) ?? .unknown + if reason == .notFound { + setConnectionState(.failed(reason: .notFound), for: resolvedId) + } + } + } + } } /// Resolves the app-facing id for a peripheral, upserts its snapshot into ``discoveredPeripherals``, and @@ -898,13 +1375,25 @@ actor BluetoothActor { /// two paths previously carried near-duplicate copies of these rules, which had already drifted apart; keeping /// them together is what guarantees both return the same handle for the same device. /// - /// Resolution is: derive `cbPeripheral.name ?? advertisement.localName ?? cbPeripheral.identifier.uuidString`, - /// then match an existing entry by `id`, else by `cbIdentifier` (preserving that entry's original `id`, so a - /// device that renames itself keeps its identity), else append. + /// Resolution is: + /// 1. Prefer an existing snapshot for this `cbIdentifier` (in-process rename stability). + /// 2. Else prefer a **durable hold** key for this `cbIdentifier` when there is no advertisement local + /// name (cold retrieve after force quit — keep `"ReliaBLE Demo"` instead of GAP `"iPhone"`). + /// 3. Else derive + /// `name ?? advertisement.localName ?? cbPeripheral.name ?? uuidString` + /// (callers should pass local-name-first; ad local name beats GAP/OS device name). + /// 4. When an advertisement **does** carry a local name that differs from a held id, use the local-name + /// id so a later scan can upgrade a GAP-only hold key (rebind moves the hold). + /// + /// When resolution upgrades the id of a device already in ``discoveredPeripherals``, the row is reused and + /// ``migrateIdentity(from:to:cbIdentifier:)`` moves every other per-id record onto the new id, so no state — + /// least of all the live `CBPeripheral` — is left stranded under the obsolete key. /// - /// **Merge rule: `nil` means keep.** A `nil` `name`, `rssi`, or `advertisement` preserves the existing value - /// rather than clearing it, falling back to `nil` (or an empty advertisement) when there is no existing entry. - /// Discovery always passes real values, so the rule is a no-op there. Restoration depends on it: a restored + /// **Merge rule for name:** prefer `advertisement.localName`, then existing snapshot name, then caller + /// `name`, then a durable hold id for this UUID (label), then GAP name. Never let a retrieve-only GAP + /// name wipe a better known label. + /// + /// **Merge rule for `rssi` / `advertisement`:** `nil` means keep. Restoration depends on it: a restored /// peripheral carries no advertisement payload and no RSSI, and wiping those would silently downgrade a device /// the app had already discovered — visibly, now that the handle republishes them. /// @@ -934,26 +1423,50 @@ actor BluetoothActor { // stable identity derived from manufacturing data; until then the dedup key is best-effort. The // `cbIdentifier` fallback below only rescues a *single* device whose advertised name changes, not the // same-name collision between *different* devices. - let identifier = cbPeripheral.name - ?? advertisement?.localName - ?? cbPeripheral.identifier.uuidString let cbIdentifier = cbPeripheral.identifier - + let adLocalName = advertisement?.localName + // Local name (ad) beats GAP/OS device name for the name-derived id. + let nameDerived = name + ?? adLocalName + ?? cbPeripheral.name + ?? cbIdentifier.uuidString + let holdIdForUUID = manualConnectHold.first(where: { $0.value.cbIdentifier == cbIdentifier })?.key + + // Prefer stable radio match first so a rename does not create a second snapshot in-process. + // An advertisement local name may still *upgrade* a prior GAP/hold-only id (e.g. "iPhone" → + // "ReliaBLE Demo"); ``rebindHoldIfNeeded`` moves demand onto the new id after return. let existingIndex: Int? let resolvedId: String - if let idx = discoveredPeripherals.firstIndex(where: { $0.id == identifier }) { + if let idx = discoveredPeripherals.firstIndex(where: { $0.cbIdentifier == cbIdentifier }) { existingIndex = idx - resolvedId = identifier - } else if let idx = discoveredPeripherals.firstIndex(where: { $0.cbIdentifier == cbIdentifier }) { + let existingId = discoveredPeripherals[idx].id + if let adLocalName, !adLocalName.isEmpty, adLocalName != existingId { + resolvedId = adLocalName + } else { + resolvedId = existingId + } + } else if let holdId = holdIdForUUID, adLocalName == nil { + // Cold retrieve / restore without ads: keep the durable hold's app id (e.g. "ReliaBLE Demo") + // instead of adopting GAP "iPhone". + existingIndex = discoveredPeripherals.firstIndex(where: { $0.id == holdId }) + resolvedId = holdId + } else if let idx = discoveredPeripherals.firstIndex(where: { $0.id == nameDerived }) { existingIndex = idx - resolvedId = discoveredPeripherals[idx].id + resolvedId = nameDerived } else { existingIndex = nil - resolvedId = identifier + // With an ad local name, prefer it even when a hold is keyed under a prior GAP-only id; + // ``rebindHoldIfNeeded`` upgrades the hold after return. + resolvedId = nameDerived } let existing = existingIndex.map { discoveredPeripherals[$0] } - let mergedName = name ?? existing?.name + // Ad local name > existing label > hold key (durable product name) > caller/GAP. + let mergedName = adLocalName + ?? existing?.name + ?? holdIdForUUID + ?? name + ?? cbPeripheral.name let mergedRSSI = rssi ?? existing?.rssi // `??` is lazily evaluated, so the empty placeholder is only built on the restore-a-never-seen-device path. let mergedAdvertisement = advertisement @@ -970,6 +1483,24 @@ actor BluetoothActor { registry: registry ) + // An alias only holds while nothing else answers to the retired id. The moment a device + // resolves to it again, that id is a live catalog entry: keeping the alias would route a + // caller asking for this device to the one that vacated the name. Same-name devices still + // collapse into one entry (FR-8.5), but a *retired* name must not re-collapse after the + // upgrade that separated them. + if let supersededAlias = migratedIds.removeValue(forKey: resolvedId) { + // Dropping the alias stops *future* mirroring, but the handle interned under this id is + // still carrying the departed device's state from every mirror before now. Re-point it at + // whatever the library tracks for the device claiming the id — `nil` for one it has never + // linked — so a discovery UI cannot render two connected devices for one live link. + applyToHandles(id: resolvedId, state: connectionStates[resolvedId]) + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Alias id=\(resolvedId) → id=\(supersededAlias) dropped — a device now resolves to that id" + ) + } + + let previousId = existing?.id if let existingIndex { discoveredPeripherals[existingIndex] = snapshot } else { @@ -977,6 +1508,17 @@ actor BluetoothActor { discoveredPeripherals.append(snapshot) } + // The row was upgraded in place above; every other record keyed by the obsolete id has to + // follow it. Runs after the upsert so migration cannot invalidate `existingIndex`. + if let previousId, previousId != resolvedId { + let carried = demand(id: previousId) ? "demand carried over" : "no demand" + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Identity upgraded: id=\(previousId) → id=\(resolvedId) for cbUUID=\(cbIdentifier.uuidString) (\(carried))" + ) + migrateIdentity(from: previousId, to: resolvedId, cbIdentifier: cbIdentifier) + } + // Apply before the caller broadcasts — see the Important note above. registry.applyDiscovery( id: resolvedId, @@ -991,23 +1533,102 @@ actor BluetoothActor { } private func invalidatePeripherals() { - // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. + // The normative per-id invalidation sequence (D-1 event 13). Replaces the bulk + // ``clearConnectionStates()`` nil-out with a per-id policy because demand survives a radio + // outage and the public stream must distinguish "the library will bring this back" from + // "this is over": + let trackedIds = Array(connectionStates.keys) + + // (1) Non-terminal tracked states emit `.disconnected(.bluetoothUnavailable)` so a + // stream-only UI never sticks on `.connected` / in-progress after radio death. + // Already-terminal `.disconnected` / `.failed` skip that rewrite — re-tagging a settled + // clean disconnect (or prior failure) as a radio fault confuses observers when Bluetooth + // toggles with no live link. Demand projection below still runs for every tracked id. + for id in trackedIds { + switch connectionStates[id] { + case .disconnected, .failed: + continue + case .connected: + log?.warn( + tags: [.peripheral(id), .category(.connection)], + "Live connection dropped (bluetoothUnavailable) — radio invalidated" + ) + setConnectionState(.disconnected(reason: .bluetoothUnavailable), for: id) + case .connecting, .disconnecting, .reconnecting, .none: + log?.warn( + tags: [.peripheral(id), .category(.connection)], + "In-progress connection dropped (bluetoothUnavailable) — radio invalidated" + ) + setConnectionState(.disconnected(reason: .bluetoothUnavailable), for: id) + } + } + + // (2) Drop live references and cancel every in-flight timer/ladder. `activeLeases` and + // `manualConnectHold` are **preserved** — demand survives a radio outage (D-1 event 13). cbPeripherals.removeAll() - clearConnectionStates() taskRegistry.cancelAll() + idleTaskRegistry.cancelAll() reconnectAttempts.removeAll() - reconnectEnabled.removeAll() - persistReconnectIntent() + reconnectGeneration.removeAll() intentionalDisconnects.removeAll() - pendingRestoredScanServices = nil - pendingRestoredScanOptions = nil + reconnectEnabled.removeAll() // re-synced per-id from demand below. + + // Recompute the projection per id: no-demand ids are untracked, wants-reconnect ids move + // to AwaitingRadio, and demand-but-no-wants-reconnect ids settle at disconnected. + let reconnectingIds = trackedIds.filter { wantsReconnect(id: $0) } + let suppressedIds = trackedIds.filter { demand(id: $0) && !wantsReconnect(id: $0) } + let untrackedIds = trackedIds.filter { !demand(id: $0) } + + // (5) No-demand ids are simply untracked: the handle reverts to nil after the clear. + // When step 1 skipped (already terminal), this clear is silent on the stream — correct, + // because observers already hold a terminal caption. + for id in untrackedIds { + applyToHandles(id: id, state: nil) + } + connectionStates.removeAll() + + // (3) For each id that wants a link back, publish the AwaitingRadio projection + // `.reconnecting(source: .library, attempt: nil, nextRetryAt: nil)`. This holds until + // ``issueConnect`` moves it to `.connecting`, the ladder supplies real attempt values, the + // link succeeds, or demand is cleared. When step 1 skipped, the stream moves + // terminal → reconnecting with no intermediate `bluetoothUnavailable`. + for id in reconnectingIds { + syncReconnectIntent(id: id) + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Awaiting radio return for reconnect" + ) + setConnectionState(.reconnecting(source: .library, attempt: nil, nextRetryAt: nil), for: id) + } + + // (4) Demand with no `wantsReconnect` (a `connect(autoReconnect: false)` hold) gets **no** + // `.reconnecting` signal — nothing will re-issue for it (event 12's issue gate refuses), so + // settling at `.disconnected` is the honest state. If step 1 ran, the handle still carries + // that terminal caption via the registry; if step 1 skipped, the prior terminal remains. + for id in suppressedIds { + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Radio invalidated — reconnect suppressed (hold does not want reconnect)" + ) + syncReconnectIntent(id: id) + } + + // This method must **never** write to disk: ``syncReconnectIntent`` is called without + // `persistHold`, and the old `persistReconnectIntent()` here is gone. A transient blip must + // not erase the durable hold map (D-1 event 13). The deferred restored scan is also + // **preserved** rather than cleared, so a warm power-off does not drop it (FR-8.2). + broadcast(discoveredPeripherals, to: peripheralsContinuations) - log?.debug("Invalidated all peripheral references") + log?.info( + tags: [.category(.connection)], + "Invalidated all peripheral references (\(trackedIds.count) tracked)" + ) } // MARK: - Persisted Reconnect Intent - /// `UserDefaults` key for the persisted reconnect-intent set, namespaced by restore identifier. + /// `UserDefaults` key for the persisted manual-connect hold map, namespaced by restore + /// identifier. /// /// `nil` when no ``restoreIdentifier`` is configured — without state restoration there is no /// relaunch path that could consume persisted intent, so nothing is written. @@ -1015,30 +1636,16 @@ actor BluetoothActor { restoreIdentifier.map { "com.five3apps.relia-ble.reconnect-intent.\($0)" } } - /// Mirrors ``reconnectEnabled`` to `UserDefaults` so per-connect `autoReconnect` intent - /// survives process death. ``handleWillRestoreState(_:)`` re-arms Tier-1 reconnect only for - /// restored peripherals present in this persisted set. - /// - /// Called after every explicit mutation of ``reconnectEnabled`` (connect, disconnect, - /// invalidation). Restoration itself only reads the set. - private func persistReconnectIntent() { - guard let key = reconnectIntentDefaultsKey else { return } - UserDefaults.standard.set(Array(reconnectEnabled).sorted(), forKey: key) - } - - /// Reads the reconnect-intent set persisted by a previous launch (or this one). - private func persistedReconnectIntent() -> Set { - guard let key = reconnectIntentDefaultsKey, - let stored = UserDefaults.standard.stringArray(forKey: key) else { return [] } - return Set(stored) - } - private func refreshPeripherals() { guard let centralManager else { return } let identifiers = discoveredPeripherals.compactMap { $0.cbIdentifier } guard !identifiers.isEmpty else { - log?.debug("No peripheral identifiers to refresh") + let discoveredCount = discoveredPeripherals.count + let memoryHoldsSummary = formatMemoryHolds() + log?.debug( + "No peripheral identifiers to refresh (discovered=\(discoveredCount), memoryHolds=\(memoryHoldsSummary))" + ) return } @@ -1049,83 +1656,792 @@ actor BluetoothActor { } } broadcast(discoveredPeripherals, to: peripheralsContinuations) - log?.debug("Refreshed \(retrieved.count) peripherals from CBCentralManager") + log?.debug("Refreshed \(retrieved.count)/\(identifiers.count) peripherals from CBCentralManager") } - // 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 !isShutdown else { - log?.warn(tags: [.peripheral(id)], "Attempted to connect after shutdown") - throw PeripheralError.bluetoothUnavailable + /// `retrievePeripherals` for every held `cbIdentifier`, upserting snapshots under the durable hold + /// id when possible (no advertisement — GAP name alone must not rename the hold). Enables + /// reconnect without waiting for a scan when the OS still knows the UUID. + private func retrieveAndBindHeldPeripherals() { + guard let centralManager else { return } + let heldUUIDs = Array( + Set(manualConnectHold.values.compactMap(\.cbIdentifier)) + ) + guard !heldUUIDs.isEmpty else { return } + + let retrieved = centralManager.retrievePeripherals(withIdentifiers: heldUUIDs) + guard !retrieved.isEmpty else { + log?.info( + tags: [.category(.connection)], + "retrieveAndBindHeldPeripherals: 0/\(heldUUIDs.count) held UUID(s) known to CoreBluetooth" + ) + return } - guard let centralManager else { - log?.warn(tags: [.peripheral(id)], "Attempted to connect without a central manager") - throw PeripheralError.bluetoothUnavailable + + let now = Date() + for cbPeripheral in retrieved { + // Pass nil name so resolve prefers the durable hold id over GAP `"iPhone"`. + let resolvedId = resolveAndUpsertDiscovered( + cbPeripheral: cbPeripheral, + name: nil, + rssi: nil, + lastSeen: now, + advertisement: nil + ) + rebindHoldIfNeeded(to: resolvedId, cbIdentifier: cbPeripheral.identifier) + cbPeripherals[resolvedId] = cbPeripheral + + // Seed linked state when the OS already has an ACL; disconnected retrieves stay untracked + // until issueConnect / discovery. + switch cbPeripheral.state { + case .connected: + setConnectionState(.connected, for: resolvedId) + case .connecting: + setConnectionState(.connecting, for: resolvedId) + case .disconnecting: + setConnectionState(.disconnecting, for: resolvedId) + case .disconnected: + break + @unknown default: + break + } } - + broadcast(discoveredPeripherals, to: peripheralsContinuations) + let retrievedCount = retrieved.count + let heldCount = heldUUIDs.count + log?.info( + tags: [.category(.connection)], + "retrieveAndBindHeldPeripherals: bound \(retrievedCount)/\(heldCount) held UUID(s)" + ) + } + + // MARK: - Demand Substrate + + /// Number of live work leases for `id`. + private func workCount(for id: String) -> Int { + activeLeases[id]?.count ?? 0 + } + + /// Derived, never stored: demand exists when any work lease is held or a manual-connect hold is set. + private func demand(id: String) -> Bool { + workCount(for: id) > 0 || manualConnectHold[id] != nil + } + + /// Derived, never stored: whether demand wants a link back (work leases, or a hold with + /// `reconnectDesired`). Governs the connect-issuing arm and Tier-1 gating. + private func wantsReconnect(id: String) -> Bool { + workCount(for: id) > 0 || manualConnectHold[id]?.reconnectDesired == true + } + + /// Whether the cached state for `id` is a Tier-0 OS reconnect in limbo + /// (`.reconnecting(source: .system, ...)`), i.e. CoreBluetooth still has pending connect work. + private func cachedSystemReconnect(id: String) -> Bool { + if case .reconnecting(source: .system, _, _) = connectionStates[id] { return true } + return false + } + + /// Resolves an id supplied by a caller to the id the library currently files that device under. + /// + /// Apply this to every entry point that accepts an id from outside the actor — a ``Peripheral`` + /// handle's `id` is fixed at the moment it was vended, and an identity upgrade since then would + /// otherwise turn the caller's request into a silent no-op. Internal call sites already work in + /// current ids, where this is the identity function. + private func canonicalId(_ id: String) -> String { + guard let current = migratedIds[id] else { return id } + log?.debug( + tags: [.peripheral(current), .category(.connection)], + "Resolved caller id=\(id) to current id=\(current)" + ) + return current + } + + /// The only place in the codebase that may call `centralManager.connect(_:options:)`. + /// + /// Side-effect free apart from its two intended effects: an optimistic `.connecting` via + /// ``setConnectionState(_:for:)`` and the CoreBluetooth connect call. It does **not** mutate + /// ``reconnectEnabled``, does not persist, and does not clear ``intentionalDisconnects`` — those + /// are the caller's job (``syncReconnectIntent`` for the signal, per-event logic for the rest). + private func issueConnect(id: String, enableAutoReconnect: Bool) throws { guard let cbPeripheral = cbPeripherals[id] else { + log?.warn( + tags: [.peripheral(id), .category(.connection)], + "issueConnect failed: no live CBPeripheral (notFound)" + ) throw PeripheralError.notFound } - - if autoReconnect { - reconnectEnabled.insert(id) - } else { - reconnectEnabled.remove(id) + guard let centralManager else { + throw PeripheralError.bluetoothUnavailable } - persistReconnectIntent() - intentionalDisconnects.remove(id) + setConnectionState(.connecting, for: id) - + var options: [String: Any]? if #available(macOS 14.0, iOS 17.0, *) { - options = autoReconnect ? [CBConnectPeripheralOptionEnableAutoReconnect: true] : nil + options = enableAutoReconnect ? [CBConnectPeripheralOptionEnableAutoReconnect: true] : nil } + let cbUUID = cbPeripheral.identifier.uuidString + let peripheralState = cbPeripheral.state.rawValue + log?.info( + tags: [.peripheral(id), .category(.connection)], + "issueConnect: cbUUID=\(cbUUID) enableAutoReconnect=\(enableAutoReconnect) peripheralState=\(peripheralState)" + ) centralManager.connect(cbPeripheral, options: options) } - - /// Initiates a disconnection from the live peripheral backing the given snapshot `id`. + + /// Single choke point for every `cancelPeripheralConnection` call, so the test-only cancel counter + /// (``cancelCallCounts``) cannot drift from reality. The CoreBluetooth cancel is issued unchanged — + /// routing through here adds only bookkeeping, never a behavioral change to which cancels are sent + /// or their ordering. + private func issueCancel(_ cbPeripheral: CBPeripheral) { + if let id = id(for: cbPeripheral) { + cancelCallCounts[id, default: 0] += 1 + } + centralManager?.cancelPeripheralConnection(cbPeripheral) + } + + /// Keeps ``reconnectEnabled`` in step with the derived demand signal. This is the **sole** + /// persistence writer: it is the only place a manual-connect hold is written to `UserDefaults`. /// - /// Optimistically broadcasts `.disconnecting` before cancelling the connection. The actual - /// `.disconnected` callback arrives later via the delegate pipeline. + /// Inserts ``id`` into ``reconnectEnabled`` when ``wantsReconnect(id)`` is true, removes it + /// otherwise (and cancels that id's Tier-1 ladder when it no longer wants a link back). /// - /// - 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 !isShutdown else { - log?.warn(tags: [.peripheral(id)], "Attempted to disconnect after shutdown") + /// Persistence is **hold-driven only**: `persistHold` is `true` only from the two manual-connect + /// entry points (``applyManualConnectHold(id:reconnectDesired:)`` and + /// ``applyManualDisconnect(id:)``), plus hold rebind when the app-facing id changes. Never from + /// work-lease or event-driven demand changes. The persisted artifact is an array of hold records + /// (`id`, `reconnectDesired`, optional `cbUUID`), namespaced by ``restoreIdentifier``. Nothing is + /// written when there is no restore identifier (no relaunch path could consume it). + private func syncReconnectIntent(id: String, persistHold: Bool = false) { + let wants = wantsReconnect(id: id) + if wants { + reconnectEnabled.insert(id) + } else { + reconnectEnabled.remove(id) + // Demand no longer wants a link back — cancel the Tier-1 ladder so a quiet peripheral + // cannot keep scheduling retries. Idle teardown and manual disconnect also route here. + // Clear attempts and bump the generation too, so a racing ladder task whose cancel + // landed mid-sleep cannot re-drive `performReconnect` against a now-quiet link. + taskRegistry.cancel(id) + reconnectAttempts[id] = nil + reconnectGeneration[id] = (reconnectGeneration[id] ?? 0) + 1 + } + if persistHold, restoreIdentifier != nil { + persistHoldMap() + } + } + + /// Loads every durable hold from disk into ``manualConnectHold`` once per process. + /// + /// Called from ``handleWillRestoreState`` (often before `.poweredOn`) and from the first + /// `.poweredOn` when restore never fired (force-quit relaunch). Does not issue connects — that + /// waits for live refs via restore, ``retrieveAndBindHeldPeripherals``, or discovery. + private func rehydrateDiskHoldsIfNeeded() { + guard restoreIdentifier != nil else { return } + guard !didRehydrateDiskHolds else { return } + didRehydrateDiskHolds = true + + let records = loadPersistedHoldRecords() + guard !records.isEmpty else { + log?.debug(tags: [.category(.connection)], "No disk holds to rehydrate") + return + } + + var loadedIds: [String] = [] + var droppedInvalidIds: [String] = [] + for record in records { + // Holds without a CoreBluetooth UUID cannot rebind across name drift or be retrieved + // after process death — skip incomplete rows and rewrite disk without them. + guard let uuidString = record.cbUUID, let uuid = UUID(uuidString: uuidString) else { + droppedInvalidIds.append(record.id) + continue + } + if var existing = manualConnectHold[record.id] { + // Restore path may have inserted a hold before disk load finished merging UUID. + if existing.cbIdentifier == nil { + existing.cbIdentifier = uuid + manualConnectHold[record.id] = existing + } + continue + } + // Prefer an existing memory hold already bound to this UUID under another id (rare race). + if manualConnectHold.contains(where: { $0.value.cbIdentifier == uuid }) { + continue + } + manualConnectHold[record.id] = ManualConnectHold( + reconnectDesired: record.reconnectDesired, + cbIdentifier: uuid + ) + syncReconnectIntent(id: record.id) + loadedIds.append(record.id) + } + if !droppedInvalidIds.isEmpty { + let dropped = droppedInvalidIds.sorted() + log?.info( + tags: [.category(.connection)], + "Dropped disk hold(s) without valid cbUUID: \(dropped)" + ) + persistHoldMap() + } + let summary = formatMemoryHolds() + log?.info( + tags: [.category(.connection)], + "Rehydrated disk holds into memory: loaded=\(loadedIds) memoryHolds=\(summary)" + ) + } + + /// When a live peripheral resolves to `resolvedId` but a hold is keyed under a different id with + /// the same `cbIdentifier`, move the hold (and related bookkeeping) to `resolvedId`. + /// + /// Same radio UUID with a different advertised/GAP name between sessions keeps demand intact. + @discardableResult + private func rebindHoldIfNeeded(to resolvedId: String, cbIdentifier: UUID) -> Bool { + // Exact id already has a hold — keep UUID current. + if var hold = manualConnectHold[resolvedId] { + if hold.cbIdentifier != cbIdentifier { + hold.cbIdentifier = cbIdentifier + manualConnectHold[resolvedId] = hold + if restoreIdentifier != nil { + persistHoldMap() + } + } + return false + } + + guard let oldEntry = manualConnectHold.first(where: { $0.value.cbIdentifier == cbIdentifier }) else { + return false + } + let oldId = oldEntry.key + guard oldId != resolvedId else { return false } + + let oldUUID = cbIdentifier.uuidString + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Hold identity drift: moving hold from id=\(oldId) → id=\(resolvedId) for cbUUID=\(oldUUID) (name-derived id changed; demand preserved)" + ) + + migrateIdentity(from: oldId, to: resolvedId, cbIdentifier: cbIdentifier) + return true + } + + /// Moves every per-id record from `oldId` onto `newId` after identity resolution assigned a + /// different app-facing id to the same radio. + /// + /// Upholds the invariant ``id(for:)`` depends on — **one ``cbPeripherals`` key per live + /// `CBPeripheral`** — and keeps demand, connection state, and scheduled work addressed by the id + /// the app can actually see. Identity drift is not exclusive to durable holds: an advertisement + /// that first arrives without a local name resolves to the `cbIdentifier` string and is upgraded + /// by the next packet, before any hold exists. + /// + /// Callers own the log line describing *why* the id moved; this only moves state. + private func migrateIdentity(from oldId: String, to newId: String, cbIdentifier: UUID) { + guard oldId != newId else { return } + + // Re-point existing aliases before adding this one, so every obsolete id resolves in one hop. + for (stale, current) in migratedIds where current == oldId { + migratedIds[stale] = newId + } + migratedIds[oldId] = newId + migratedIds[newId] = nil + + if var hold = manualConnectHold.removeValue(forKey: oldId) { + hold.cbIdentifier = cbIdentifier + manualConnectHold[newId] = hold + } + + // Transfer demand-adjacent bookkeeping so ladders / leases / terminal states do not stick + // under the obsolete id. + if let leases = activeLeases.removeValue(forKey: oldId) { + activeLeases[newId, default: []].formUnion(leases) + } + if intentionalDisconnects.remove(oldId) != nil { + intentionalDisconnects.insert(newId) + } + if let cb = cbPeripherals.removeValue(forKey: oldId) { + cbPeripherals[newId] = cb + } + let movedState = connectionStates.removeValue(forKey: oldId) + if let movedState { + // A stream-only observer still keyed by `oldId` would render the moved state forever — no + // further event can ever reach it — so give that id one terminal. The *handle* interned + // under `oldId` is not cleared: the alias registered above keeps its calls working, and + // ``setConnectionState(_:for:)`` mirrors the live state back onto it. + broadcast( + ConnectionStateChange(peripheralId: oldId, state: .disconnected(reason: nil)), + to: connectionStateChangesContinuations + ) + setConnectionState(movedState, for: newId) + } + if let attempts = reconnectAttempts.removeValue(forKey: oldId) { + reconnectAttempts[newId] = attempts + } + if let gen = reconnectGeneration.removeValue(forKey: oldId) { + reconnectGeneration[newId] = gen + } + if let gen = idleGeneration.removeValue(forKey: oldId) { + idleGeneration[newId] = gen + } + if let cancels = cancelCallCounts.removeValue(forKey: oldId) { + cancelCallCounts[newId, default: 0] += cancels + } + // Drop any obsolete discovery snapshot so consumers do not see two rows for one radio. An id + // upgraded in place by ``resolveAndUpsertDiscovered`` already reuses its row, so this is a + // no-op on that path. + if let oldIdx = discoveredPeripherals.firstIndex(where: { $0.id == oldId }) { + discoveredPeripherals.remove(at: oldIdx) + } + + // Both task registries key their closures by the id captured at arm time, so the old id's + // pending work cannot be reused and has to be cancelled. Re-drive it under the new id below — + // the maps are silent about scheduled work, and nothing else will pick a ladder step or an + // idle timer back up. (An AwaitingRadio projection is the one case discovery does re-issue, + // once the live reference is bound, which is later than this runs.) + taskRegistry.cancel(oldId) + idleTaskRegistry.cancel(oldId) + reconnectEnabled.remove(oldId) + syncReconnectIntent(id: newId, persistHold: restoreIdentifier != nil && manualConnectHold[newId] != nil) + + switch movedState { + case .connected, .connecting, .reconnecting(source: .system, attempt: _, nextRetryAt: _): + // Re-arms only when demand is still zero; `beginIdleGrace` self-guards. + beginIdleGrace(id: newId) + case .reconnecting(source: .library, let attempt, _): + guard wantsReconnect(id: newId) else { break } + if let attempt { + // Same attempt number, fresh delay: the move restarts the wait but must not inflate + // the ladder's progress toward `maxAttempts`. `scheduleReconnect` does not write + // ``reconnectAttempts``, and ``performReconnect`` refuses to fire unless the map and + // the scheduled step agree, so pin it here. + reconnectAttempts[newId] = attempt + scheduleReconnect(id: newId, attempt: attempt) + } else { + // A `nil` attempt is the AwaitingRadio projection — demand waiting on a usable radio, + // not a ladder step. Re-drive the link directly; if the radio is still unusable this + // throws and leaves the projection standing for the radio-return sweep. + try? reevaluateLink(id: newId, reason: .discoveredWhileDemanded) + } + case .disconnecting, .disconnected, .failed, .none: + break + } + } + + /// Writes the persisted hold records for the current ``restoreIdentifier``. + /// + /// Only ``syncReconnectIntent`` (with `persistHold: true`) and hold rebind call this — it is the + /// single on-disk write. ``invalidatePeripherals()`` and ``shutdown()`` must never reach here, or + /// a transient blip would erase the durable hold map. + private func persistHoldMap() { + guard let key = reconnectIntentDefaultsKey else { + log?.debug(tags: [.category(.connection)], "persistHoldMap skipped — no restoreIdentifier") + return + } + // Enrich each hold with the best-known cbUUID (hold field, then live maps). + let records: [[String: Any]] = manualConnectHold.map { id, hold in + var row: [String: Any] = [ + "id": id, + "reconnectDesired": hold.reconnectDesired, + ] + let uuid = hold.cbIdentifier + ?? cbPeripherals[id]?.identifier + ?? discoveredPeripherals.first(where: { $0.id == id })?.cbIdentifier + if let uuid { + row["cbUUID"] = uuid.uuidString + } + return row + } + // Stable order for readable logs / diffs. + let sorted = records.sorted { + ($0["id"] as? String ?? "") < ($1["id"] as? String ?? "") + } + UserDefaults.standard.set(sorted, forKey: key) + let holdsSummary = formatPersistedHoldRecords(loadPersistedHoldRecords()) + log?.info( + tags: [.category(.connection)], + "Persisted hold map key=\(key) holds=\(holdsSummary)" + ) + } + + /// Reads durable hold records from disk. + /// + /// Expected shape: `[[String: Any]]` rows with `id`, `reconnectDesired`, and optional `cbUUID`. + /// Unrecognized values are treated as no holds. + private func loadPersistedHoldRecords() -> [PersistedHoldRecord] { + guard let key = reconnectIntentDefaultsKey else { return [] } + guard let raw = UserDefaults.standard.object(forKey: key) else { return [] } + + guard let rows = raw as? [[String: Any]] else { + log?.warn( + tags: [.category(.connection)], + "loadPersistedHoldRecords decode failed for key=\(key) rawType=\(type(of: raw)) — treating as no holds" + ) + return [] + } + + return rows.compactMap { row in + guard let id = row["id"] as? String else { return nil } + let reconnect: Bool + if let b = row["reconnectDesired"] as? Bool { + reconnect = b + } else if let n = row["reconnectDesired"] as? NSNumber { + reconnect = n.boolValue + } else { + return nil + } + let cbUUID = row["cbUUID"] as? String + return PersistedHoldRecord(id: id, reconnectDesired: reconnect, cbUUID: cbUUID) + } + } + + /// Convenience `id → reconnectDesired` view of disk holds (tests + compact logs). + private func persistedHoldMap() -> [String: Bool] { + Dictionary(uniqueKeysWithValues: loadPersistedHoldRecords().map { ($0.id, $0.reconnectDesired) }) + } + + /// Compact rendering of in-memory holds including optional UUID prefixes. + private func formatMemoryHolds() -> String { + if manualConnectHold.isEmpty { return "[:]" } + return "[" + manualConnectHold.keys.sorted().map { id in + let hold = manualConnectHold[id]! + let uuidPart = hold.cbIdentifier.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "\(id)=reconnect:\(hold.reconnectDesired)/cb:\(uuidPart)" + }.joined(separator: ", ") + "]" + } + + /// Compact rendering of disk hold records. + private func formatPersistedHoldRecords(_ records: [PersistedHoldRecord]) -> String { + if records.isEmpty { return "[:]" } + return "[" + records.map { rec in + let uuidPart = rec.cbUUID.map { String($0.prefix(8)) } ?? "nil" + return "\(rec.id)=reconnect:\(rec.reconnectDesired)/cb:\(uuidPart)" + }.joined(separator: ", ") + "]" + } + + /// The single ensure-linked entry point (D-1 event 5). + /// + /// Returns early (no-op) when there is no demand. Otherwise verifies the radio per D-radio, the + /// live peripheral, and the current link state before deciding whether to issue a connect. + /// `.notFound` is terminal for the automatic path — no scan, no retry loop — and demand is retained. + func reevaluateLink(id: String, reason: LinkReason) throws { + let id = canonicalId(id) + guard demand(id: id) else { return } + + // Re-check the radio per D-radio: a waiter resumed at .poweredOn runs on a later actor turn, by + // which time the radio may have flipped again. Fail on terminal regression; transient states are + // re-driven later (radio-return sweep / next evaluation), never issued against a dead radio. + guard !isShutdown, let centralManager else { throw PeripheralError.bluetoothUnavailable } - guard let centralManager else { - log?.warn(tags: [.peripheral(id)], "Attempted to disconnect without a central manager") + switch centralManager.state { + case .poweredOn: + break + case .poweredOff: + throw PeripheralError.bluetoothPoweredOff + case .unsupported: + throw PeripheralError.bluetoothUnsupported + case .unauthorized: + throw PeripheralError.bluetoothUnavailable + case .resetting, .unknown: + return + @unknown default: throw PeripheralError.bluetoothUnavailable } - + guard let cbPeripheral = cbPeripherals[id] else { + // Automatic paths (radio return, etc.) may evaluate a durable hold before any live ref + // exists after process death. Wait for retrieve/discovery rather than terminal + // `.notFound`. Explicit `connect` and work-lease paths still throw so callers learn the + // id is unknown / unbound. + if manualConnectHold[id] != nil, reason != .explicitConnect, reason != .workAcquired { + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Hold awaiting live peripheral (no CB ref yet) — demand retained" + ) + return + } throw PeripheralError.notFound } - - // Reset auto-reconnect since this was an explicit disconnect - intentionalDisconnects.insert(id) - reconnectEnabled.remove(id) - persistReconnectIntent() + + // Already connected — verify against the live CBPeripheral state, not the cached + // connectionStates value. Tier-0 cannot be flipped on a live link (CoreBluetooth has no API to + // change connect options mid-connection), so if demand now wants it, the option applies on the + // next connect issue. Just keep intent in step. + if cbPeripheral.state == .connected { + syncReconnectIntent(id: id) + return + } + + // Already connecting, or system reconnect (Tier-0) in progress — let it run. + switch connectionStates[id] { + case .connecting, .reconnecting(source: .system, attempt: _, nextRetryAt: _): + syncReconnectIntent(id: id) + return + default: + break + } + + if wantsReconnect(id: id) || reason == .explicitConnect { + if reason == .radioReturned { + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Radio returned — reissuing connect" + ) + } + try issueConnect(id: id, enableAutoReconnect: wantsReconnect(id: id)) + } + } + + /// Registers a manual-connect hold (D-1 event 3). Hold registration **only** — does not wait for + /// the radio and does not issue a connect; that is split out so a `connect` that throws + /// `bluetoothPoweredOff` still leaves durable demand behind. + /// + /// When the hold wants reconnect and the radio is not yet usable (powered off / transient), + /// projects AwaitingRadio ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)`` with + /// `source: .library` and `nil` attempt/retry — the same signal ``invalidatePeripherals()`` uses + /// when demand survives a radio drop — so stream-only UIs show demand immediately rather than a + /// terminal caption while the call fails fast. Does **not** project when `reconnectDesired` is + /// false (no radio-return re-issue) or when the radio is already `.poweredOn` (``reevaluateLink`` + /// / ``issueConnect`` drive `.connecting`). + /// - Returns: The id the hold was actually filed under. A caller that follows this with a second + /// actor call (``reevaluateLink(id:reason:)``) must pass this back rather than re-resolving its + /// own id: resolution can change across the `await` between them, and the two calls landing on + /// different devices is exactly the split this prevents. + @discardableResult + func applyManualConnectHold(id: String, reconnectDesired: Bool) -> String { + let id = canonicalId(id) + let cbId = cbPeripherals[id]?.identifier + ?? discoveredPeripherals.first(where: { $0.id == id })?.cbIdentifier + ?? manualConnectHold[id]?.cbIdentifier + manualConnectHold[id] = ManualConnectHold(reconnectDesired: reconnectDesired, cbIdentifier: cbId) + intentionalDisconnects.remove(id) + // A hold suppresses idle: bump the generation and cancel any pending idle timer for this id. + idleGeneration[id] = (idleGeneration[id] ?? 0) + 1 + idleTaskRegistry.cancel(id) + let hasLiveRef = cbPeripherals[id] != nil + let cbDesc = cbId?.uuidString ?? "nil" + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Manual connect hold registered (reconnectDesired=\(reconnectDesired), liveRef=\(hasLiveRef), cbUUID=\(cbDesc))" + ) + syncReconnectIntent(id: id, persistHold: true) + + guard reconnectDesired, shouldProjectAwaitingRadio else { return id } + switch connectionStates[id] { + case .connected, .connecting, .disconnecting, .reconnecting: + // Already linked or in progress — leave the live state alone. + break + case .disconnected, .failed, .none: + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Awaiting radio return for reconnect" + ) + setConnectionState(.reconnecting(source: .library, attempt: nil, nextRetryAt: nil), for: id) + } + + return id + } + + /// Whether the radio is in a state where a connect cannot be issued yet but may become usable + /// (powered off, resetting, unknown, or no central yet) — not terminal unsupported / unauthorized. + private var shouldProjectAwaitingRadio: Bool { + switch centralManager?.state { + case .poweredOff, .resetting, .unknown, .none: + true + case .poweredOn, .unsupported, .unauthorized: + false + @unknown default: + false + } + } + + /// Clears a manual-connect hold and tears down the link per the settling rule (D-1 event 4). + /// + /// Returns success (does not throw) when there is no live `CBPeripheral` or nothing to cancel — + /// dropping a hold during a radio outage must not throw `.notFound`. When there is no live + /// reference (typical after radio invalidation left an AwaitingRadio `.reconnecting` projection), + /// still settles synchronously to `.disconnected(reason: nil)` so stream-only UIs leave the + /// reconnecting caption and can start a new connect hold while the radio remains off. + func applyManualDisconnect(id: String) { + let id = canonicalId(id) + manualConnectHold.removeValue(forKey: id) + syncReconnectIntent(id: id, persistHold: true) + + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Manual disconnect (hold cleared, will persist empty/remaining holds)" + ) + + // Cancel the Tier-1 ladder and any pending idle timer. A manual disconnect does not start one. taskRegistry.cancel(id) + idleGeneration[id] = (idleGeneration[id] ?? 0) + 1 + idleTaskRegistry.cancel(id) reconnectAttempts[id] = nil - + + guard let cbPeripheral = cbPeripherals[id] else { + // No live peripheral (radio outage / never discovered in this process): demand is already + // gone above. Publish a clean intentional terminal so observers do not stick on + // `.reconnecting(source: .library, attempt: nil, …)` until the radio returns and + // `sweepRadioReturnedDemand` → `beginIdleGrace` eventually rewrites it. + setConnectionState(.disconnected(reason: nil), for: id) + return + } + + if cbPeripheral.state == .connected { + intentionalDisconnects.insert(id) + setConnectionState(.disconnecting, for: id) + issueCancel(cbPeripheral) + } else { + // Settling rule: never publish an optimistic `.disconnecting` unless the peripheral is + // currently `.connected`. Settle synchronously and do NOT mark intentional — a late + // `didDisconnect` is then classified as unexpected, and `armReconnect` is gated off by + // `wantsReconnect`, so the late callback is a no-op. The cancel below is still issued for + // non-connected states that represent pending CoreBluetooth work: a peripheral in + // `.connecting` or a cached `.reconnecting(source: .system, ...)` (Tier-0 limbo) has work + // only `cancelPeripheralConnection` can stop (FR-1.2 / D-tier — cancelling ends Tier-0). + // It is fire-and-forget and deliberately NOT inserted into `intentionalDisconnects`. + // The predicate below must be evaluated BEFORE `setConnectionState(.disconnected)` runs: + // `cachedSystemReconnect` reads the cached state, which that call would overwrite. Order is + // load-bearing here, not stylistic. + let shouldCancelPendingWork = + cbPeripheral.state == .connecting || cachedSystemReconnect(id: id) + + setConnectionState(.disconnected(reason: nil), for: id) + + if shouldCancelPendingWork { + issueCancel(cbPeripheral) + } + } + } + + /// Acquires a work lease on a discovered peripheral, creating demand that drives an auto-connect. + /// + /// Fails `.notFound` when there is no live `CBPeripheral`. The radio wait (D-radio) is done by the + /// caller-facing wrapper (``Peripheral/acquireWorkLease()``) before calling here. + func acquireWorkLease(id: String) async throws -> WorkLeaseToken { + let id = canonicalId(id) + guard cbPeripherals[id] != nil else { throw PeripheralError.notFound } + + // Work arriving suppresses idle: bump the generation and cancel any pending idle timer. + idleGeneration[id] = (idleGeneration[id] ?? 0) + 1 + idleTaskRegistry.cancel(id) + + let leaseID = UUID() + activeLeases[id, default: []].insert(leaseID) + syncReconnectIntent(id: id) + try reevaluateLink(id: id, reason: .workAcquired) + return WorkLeaseToken(id: id, leaseID: leaseID) + } + + /// Releases a work lease. Releasing an unknown or already-released token is a no-op. + /// + /// The lease UUID, not ``WorkLeaseToken/id``, is the identity that matters here: a token minted + /// before ``migrateIdentity(from:to:cbIdentifier:)`` moved the peripheral onto a new app-facing id + /// still names the obsolete one, and a lookup that trusted it would silently fail to release — + /// leaving demand asserted forever and the link unable to idle. + func releaseWorkLease(_ token: WorkLeaseToken) async { + let resolvedId = activeLeases[token.id]?.contains(token.leaseID) == true + ? token.id + : activeLeases.first { $0.value.contains(token.leaseID) }?.key + guard let resolvedId, activeLeases[resolvedId]?.remove(token.leaseID) != nil else { + log?.debug(tags: [.peripheral(token.id)], "releaseWorkLease: unknown or already-released token — no-op") + return + } + if resolvedId != token.id { + log?.debug( + tags: [.peripheral(resolvedId), .category(.connection)], + "releaseWorkLease: token named id=\(token.id); lease had moved to id=\(resolvedId)" + ) + } + syncReconnectIntent(id: resolvedId) + if !demand(id: resolvedId) { + beginIdleGrace(id: resolvedId) + } + } + + /// Arms (or, for states with nothing to tear down, immediately settles) the idle-grace timer for + /// a peripheral whose demand has dropped to zero (D-1 event 6). + /// + /// Proceeds only when ``demand(id:)`` is false. For a peripheral in `.connected`, `.connecting`, + /// or system-`.reconnecting`, increments the generation guard, cancels any prior idle task, and + /// schedules ``fireIdle(id:generation:)`` after ``idleDisconnectInterval``. For a peripheral in a + /// state where nothing is teared down (library-`.reconnecting`, `.disconnected`, `.failed`), + /// cancels the reconnect ladder and settles synchronously to `.disconnected(reason: nil)` with no + /// timer and no CoreBluetooth cancel. + private func beginIdleGrace(id: String) { + guard !demand(id: id) else { return } + + switch connectionStates[id] { + case .connected, .connecting, .reconnecting(source: .system, attempt: _, nextRetryAt: _): + // Arm the idle timer. + let generation = (idleGeneration[id] ?? 0) + 1 + idleGeneration[id] = generation + idleTaskRegistry.cancel(id) + + log?.info(tags: [.peripheral(id), .category(.connection)], "Idle timer armed for \(id)") + + let interval = idleDisconnectInterval + let task = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: UInt64(max(0, interval) * 1_000_000_000)) + } catch { + return // Cancelled — superseded by a newer arm or an acquire/hold/disconnect. + } + guard let self else { return } + await self.fireIdle(id: id, generation: generation) + } + idleTaskRegistry.insert(id, task) + + case .reconnecting(source: .library, attempt: _, nextRetryAt: _), .disconnected, .failed, .none: + // Nothing linked to tear down: cancel the ladder and settle synchronously. + taskRegistry.cancel(id) + reconnectAttempts[id] = nil + setConnectionState(.disconnected(reason: nil), for: id) + + case .disconnecting: + // A teardown is already in flight; leave it to resolve via `didDisconnect`. No timer, no settle. + break + } + } + + /// Fires the idle-grace timer for a peripheral (D-1 event 7). + /// + /// No-ops when the captured generation is stale (a newer arm, acquire, hold, or disconnect + /// superseded this timer) or demand has since returned. Otherwise logs the idle disconnect and runs + /// the intentional-cancel path, which drops Tier-0; the result is `.disconnecting` → + /// `.disconnected(reason: nil)`, deliberately indistinguishable from an app disconnect to the + /// reconnect policy (FR-11.5 "intentional"). + private func fireIdle(id: String, generation: UInt64) { + guard idleGeneration[id] == generation, !demand(id: id) else { return } + + log?.info(tags: [.peripheral(id), .category(.connection)], "Idle disconnect fired for \(id)") + + taskRegistry.cancel(id) + reconnectAttempts[id] = nil + + // Settling rule: never publish an optimistic `.disconnecting` unless the peripheral is + // currently connected. Verify against the live `CBPeripheral` state, not the cached value. + guard let cbPeripheral = cbPeripherals[id], cbPeripheral.state == .connected else { + // Settle synchronously. A non-connected state may still have pending CoreBluetooth work + // (`.connecting`, or a cached `.reconnecting(source: .system, ...)` Tier-0 limbo) that + // only `cancelPeripheralConnection` can stop (FR-1.2 / D-tier — cancelling ends Tier-0). + // Issue it fire-and-forget, still WITHOUT inserting into `intentionalDisconnects` (there + // is no `.disconnecting` to settle) and without publishing `.disconnecting`. + // The predicate below must be evaluated BEFORE `setConnectionState(.disconnected)` runs: + // `cachedSystemReconnect` reads the cached state, which that call would overwrite. Order is + // load-bearing here, not stylistic. + let live = cbPeripherals[id] + let shouldCancelPendingWork = + live?.state == .connecting || cachedSystemReconnect(id: id) + + setConnectionState(.disconnected(reason: nil), for: id) + + if shouldCancelPendingWork, let live { + issueCancel(live) + } + return + } + + intentionalDisconnects.insert(id) setConnectionState(.disconnecting, for: id) - centralManager.cancelPeripheralConnection(cbPeripheral) + issueCancel(cbPeripheral) } /// The single write path for per-peripheral connection state. @@ -1135,10 +2451,27 @@ actor BluetoothActor { /// leave the handle's cached value silently stale. private func setConnectionState(_ state: ConnectionState, for id: String) { connectionStates[id] = state - registry.applyConnectionState(id: id, state: state) + applyToHandles(id: id, state: state) broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) } + /// Pushes `state` onto the handle for `id` **and** onto every handle whose id `id` replaced. + /// + /// A handle's `id` is fixed when it is vended, so an app holding one from before an identity + /// upgrade would otherwise watch a device it can still act on (``canonicalId(_:)`` routes its + /// calls) report nothing at all. Every write to a handle goes through here, so a replaced id can + /// never disagree with the id that replaced it — including clears, where a handle left reporting + /// `.connected` is not merely stale but known to be false. + /// + /// The stream deliberately does not follow suit: an obsolete id gets one terminal at migration and + /// then goes quiet, because a subscriber keyed by it has no way to learn the id moved. + private func applyToHandles(id: String, state: ConnectionState?) { + registry.applyConnectionState(id: id, state: state) + for (stale, current) in migratedIds where current == id { + registry.applyConnectionState(id: stale, state: state) + } + } + /// Drops all tracked connection state, mirroring the clear onto every affected handle and broadcasting a /// terminal transition for each. /// @@ -1156,7 +2489,7 @@ actor BluetoothActor { /// is a no-op there — a torn-down stack ends its streams rather than emitting a final state into them. private func clearConnectionStates() { for id in connectionStates.keys { - registry.applyConnectionState(id: id, state: nil) + applyToHandles(id: id, state: nil) broadcast( ConnectionStateChange(peripheralId: id, state: .disconnected(reason: .bluetoothUnavailable)), to: connectionStateChangesContinuations @@ -1170,9 +2503,24 @@ actor BluetoothActor { /// Derives nothing — it reads back the key that ``handlePeripheralDiscovered(_:advertisementData:rssi:)`` /// already assigned, so ``handlePeripheralDiscovered(_:advertisementData:rssi:)`` remains the library's single /// source of identity truth. + /// + /// ``migrateIdentity(from:to:cbIdentifier:)`` maintains one key per live object, so the lookup is + /// normally unambiguous. A duplicate means that invariant broke: resolve it in favour of the id + /// currently published in ``discoveredPeripherals`` and log, rather than letting dictionary order + /// route a delegate callback to an arbitrary id. // TODO: FR-8.5 private func id(for cbPeripheral: CBPeripheral) -> String? { - cbPeripherals.first { $0.value === cbPeripheral }?.key + let matches = cbPeripherals.filter { $0.value === cbPeripheral }.map(\.key) + guard matches.count > 1 else { return matches.first } + + let published = discoveredPeripherals.first { $0.cbIdentifier == cbPeripheral.identifier }?.id + let resolved = published.flatMap { matches.contains($0) ? $0 : nil } ?? matches[0] + log?.warn( + tags: [.peripheral(resolved), .category(.connection)], + "Multiple ids map to one CBPeripheral (\(matches.sorted().joined(separator: ", "))) — resolved to \(resolved)" + ) + + return resolved } private func handleDidConnect(_ payload: ConnectionPayload) { @@ -1185,6 +2533,14 @@ actor BluetoothActor { log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral connected") setConnectionState(.connected, for: id) + + if demand(id: id) { + syncReconnectIntent(id: id) + } else { + // Event 8: a reconnect landing with zero demand re-arms the idle timer and gets cancelled + // again — the accepted Tier-0 grace-window blip from FR-1.2. + beginIdleGrace(id: id) + } } private func handleDidDisconnect(_ payload: ConnectionPayload) { @@ -1192,39 +2548,82 @@ actor BluetoothActor { log?.warn(tags: [.category(.connection)], "didDisconnect for unknown peripheral — dropped") return } - + if intentionalDisconnects.remove(id) != nil { + // Intentional (D-1 event 9): clean `.disconnected(reason: nil)`, clear attempts. // 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. + // explicit `cancelPeripheralConnection`, so we intentionally ignore `payload.error` here. log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected (explicit)") + clearReconnectState(for: id) setConnectionState(.disconnected(reason: nil), for: id) - + + // Deferred half of ``disconnectWithActiveLeaseRelinks``: a Manual `disconnect()` that races + // pending work must not strand it — if any lease is live, re-drive the link. + if workCount(for: id) > 0 { + do { + try reevaluateLink(id: id, reason: .relinkAfterIntentional) + } catch { + let reason = (error as? PeripheralError) ?? .unknown + if reason == .notFound { + setConnectionState(.failed(reason: .notFound), for: id) + } + } + } return } - + if payload.isReconnecting { - // Defensively cancel any pending library ladder so Tier 0 (system) and Tier 1 - // (library) cannot overlap under odd callback ordering. + // Tier-0 in progress (D-1 event 9). Defensively cancel any pending library ladder so Tier 0 + // (system) and Tier 1 (library) cannot overlap under odd callback ordering. taskRegistry.cancel(id) - log?.info(tags: [.peripheral(id), .category(.connection)], "System auto-reconnect in progress") - setConnectionState(.reconnecting(source: .system, attempt: nil, nextRetryAt: nil), for: id) - + if wantsReconnect(id: id) { + // Field observability: Tier-0 collapses the physical drop into `.reconnecting(source: .system)` + // without a `.disconnected` emission, so surface the drop at warn (including any CB error). + if let error = payload.error { + let mapped = (error as? CBError).map(PeripheralError.fromCBError) ?? .unknown + log?.warn( + tags: [.peripheral(id), .category(.connection)], + "Peripheral disconnected unexpectedly (system reconnecting): \(mapped)" + ) + } else { + log?.warn( + tags: [.peripheral(id), .category(.connection)], + "Peripheral disconnected unexpectedly (system reconnecting)" + ) + } + log?.info(tags: [.peripheral(id), .category(.connection)], "System auto-reconnect in progress") + setConnectionState(.reconnecting(source: .system, attempt: nil, nextRetryAt: nil), for: id) + } else { + // Do NOT trust Tier-0: publish `.disconnected(reason: nil)` immediately (so the app does + // not read a `nil` handle as an auto-relink), and cancel as fire-and-forget suppression. + // Per the settling rule we do NOT insert into `intentionalDisconnects`: the peripheral is + // already physically disconnected, so there is no `.disconnecting` to settle. This branch + // cannot loop: `!wantsReconnect` implies no live leases, so the relink branch above is + // unreachable from it. + log?.info(tags: [.peripheral(id), .category(.connection)], + "Tier-0 reconnect suppressed (no demand wants it)") + setConnectionState(.disconnected(reason: nil), for: id) + if let cbPeripheral = cbPeripherals[id] { + issueCancel(cbPeripheral) + } + } return } - + + // Otherwise unexpected (D-1 event 9, final branch). let mappedError: PeripheralError? = payload.error.map { ($0 as? CBError).map(PeripheralError.fromCBError) ?? .unknown } 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") } - + setConnectionState(.disconnected(reason: mappedError), for: id) - armReconnect(id: id) + if wantsReconnect(id: id) { + armReconnect(id: id) + } } private func handleDidFailToConnect(_ payload: ConnectionPayload) { @@ -1232,37 +2631,62 @@ 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 } log?.warn(tags: [.peripheral(id), .category(.connection)], "Peripheral connection failed with error: \(mappedError ?? .unknown)") - + setConnectionState(.failed(reason: mappedError), for: id) - armReconnect(id: id) + // Close the prior critique's incomplete intentionalDisconnects clearing on fail. + intentionalDisconnects.remove(id) + if wantsReconnect(id: id) { + armReconnect(id: id) + } } // MARK: - Reconnection private func armReconnect(id: String) { - guard reconnectEnabled.contains(id) else { return } + // Gated on whether demand currently wants a link back (D-4 / #59). A quiet peripheral never + // runs the ladder, even if it connected with `autoReconnect: true` earlier and demand later + // dropped. + guard wantsReconnect(id: id) else { + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect ladder not armed (no demand wants reconnect)" + ) + 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. + let maxAttempts = reconnectPolicy.maxAttempts + guard maxAttempts > 0, attempts < maxAttempts else { + // Give-up clears in-flight ladder bookkeeping only. Demand is deliberately retained so a + // later re-evaluation can start a fresh ladder. clearReconnectState(for: id) - log?.info(tags: [.peripheral(id), .category(.connection)], "Reconnect attempts exhausted") - + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect ladder exhausted (maxAttempts=\(maxAttempts))" + ) + return } - reconnectAttempts[id] = attempts + 1 - scheduleReconnect(id: id, attempt: attempts + 1) + let nextAttempt = attempts + 1 + reconnectAttempts[id] = nextAttempt + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect ladder armed (attempt \(nextAttempt)/\(maxAttempts))" + ) + scheduleReconnect(id: id, attempt: nextAttempt) } private func scheduleReconnect(id: String, attempt: Int) { + // Bump the per-id generation so a stale sleeping task (whose wake raced a cancel from + // ``taskRegistry``) cannot drive ``performReconnect`` after being superseded. The same + // cancel-during-sleep defense ``idleGeneration`` applies to the idle timer (carry-over). + let generation = (reconnectGeneration[id] ?? 0) + 1 + reconnectGeneration[id] = generation taskRegistry.cancel(id) - // `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"). @@ -1283,6 +2707,11 @@ actor BluetoothActor { let sleepNanos: UInt64 = nanosDouble >= Double(UInt64.max) ? .max : UInt64(nanosDouble) let nextRetryAt = Date().addingTimeInterval(delaySeconds) + let delayDisplay = String(format: "%.1f", delaySeconds) + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect scheduled attempt \(attempt) in \(delayDisplay)s" + ) setConnectionState(.reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt), for: id) let task = Task { [weak self] in @@ -1297,41 +2726,219 @@ actor BluetoothActor { return } - await self.performReconnect(id: id, attempt: attempt) + await self.performReconnect(id: id, attempt: attempt, generation: generation) } taskRegistry.insert(id, task) } - private func performReconnect(id: String, attempt: Int) { + private func performReconnect(id: String, attempt: Int, generation: UInt64) { guard !Task.isCancelled, + reconnectGeneration[id] == generation, reconnectAttempts[id] == attempt, case .reconnecting(_, let currentAttempt?, _) = connectionStates[id], currentAttempt == attempt else { return } - + + // Gate the ladder on the same radio / link checks ``reevaluateLink`` centralizes, instead of + // issuing blindly (defect: a ladder woke after the radio flipped off but before the delegate + // state-update invalidated peripherals, and issued a connect against a dead radio). We read the + // CACHED ``connectionStates[id]`` for the connecting/system-reconnecting check rather than the + // live `cbPeripheral.state` here: the ladder only ever runs for a genuinely `.reconnecting` + // library state, and the cached mirror is what the state machine actually publishes. (Routing + // through ``reevaluateLink`` outright would break on its live `.connected` short-circuit; see + // the reconnect tests.) + guard !isShutdown, let centralManager else { + failLadderRadio(.bluetoothUnavailable, id: id) + return + } + switch centralManager.state { + case .poweredOn: + break + case .poweredOff: + failLadderRadio(.bluetoothPoweredOff, id: id) + return + case .unsupported: + failLadderRadio(.bluetoothUnsupported, id: id) + return + case .unauthorized: + failLadderRadio(.bluetoothUnavailable, id: id) + return + case .resetting, .unknown: + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect fire deferred (radio transient) — attempt \(attempt)" + ) + return // Radio transient — the radio-return sweep will re-drive the ladder later (D-radio). + @unknown default: + failLadderRadio(.bluetoothUnavailable, id: id) + return + } + + guard cbPeripherals[id] != nil else { + failLadderRadio(.notFound, id: id) + return + } + + // Already connecting (our own optimistic state) or a Tier-0 / system reconnect is in flight — + // let it run rather than issuing a redundant connect. + switch connectionStates[id] { + case .connecting, .reconnecting(source: .system, attempt: _, nextRetryAt: _): + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect fire skipped (connect already in flight) — attempt \(attempt)" + ) + return + default: + break + } + + log?.info( + tags: [.peripheral(id), .category(.connection)], + "Library reconnect firing attempt \(attempt)" + ) do { - try connect(id: id, autoReconnect: true) + try issueConnect(id: id, enableAutoReconnect: wantsReconnect(id: id)) } catch { let reason = (error as? PeripheralError) ?? .unknown clearReconnectState(for: id) - log?.warn(tags: [.peripheral(id), .category(.connection)], "Reconnect attempt failed: \(reason)") + log?.warn(tags: [.peripheral(id), .category(.connection)], "Library reconnect attempt failed: \(reason)") setConnectionState(.failed(reason: reason), for: id) } } + + /// Clears the ladder and publishes a terminal `.failed` for a reconnect attempt that was gated out + /// by a dead radio / missing peripheral, mirroring ``reevaluateLink``'s error surfacing. + private func failLadderRadio(_ error: PeripheralError, id: String) { + clearReconnectState(for: id) + log?.warn(tags: [.peripheral(id), .category(.connection)], "Library reconnect attempt gated: \(error)") + setConnectionState(.failed(reason: error), for: id) + } private func clearReconnectState(for id: String) { taskRegistry.cancel(id) + // Invalidate any in-flight ladder task's captured generation so it cannot re-drive after + // being cleared (success / give-up / intentional). Close the prior critique's incomplete + // intentionalDisconnects clearing path on give-up and success: intentionalDisconnects is + // removed here, and handleDidFailToConnect removes it on fail. + reconnectGeneration[id] = (reconnectGeneration[id] ?? 0) + 1 reconnectAttempts[id] = nil intentionalDisconnects.remove(id) } + // MARK: - Testing Helpers + + /// Test-only: the service filter of the most recently started scan, recorded by ``beginScan``. + /// Lets a test assert which caller's filter actually reached the radio (D-2 supersede precedence). + private var lastScanServices: [CBUUID]? + + /// Test-only: number of `cancelPeripheralConnection` calls issued per peripheral id, so a test + /// can pin that a cancel was actually issued (mirrors the ``testLastScanServices`` hook style). + /// Incremented at the single choke point ``issueCancel(_:)`` so it cannot drift from reality. + private var cancelCallCounts: [String: Int] = [:] + + /// Terminal teardown for tests/harness. Clears volatile state only — does **not** touch + /// persisted reconnect-intent `UserDefaults`. + func shutdown() { + guard !isShutdown else { return } + isShutdown = true + + eventPipeline.finish() + taskRegistry.cancelAll() + idleTaskRegistry.cancelAll() + delegateEventTask?.cancel() + delegateEventTask = nil + + for continuation in stateContinuations.values { continuation.finish() } + for continuation in discoveryContinuations.values { continuation.finish() } + for continuation in peripheralsContinuations.values { continuation.finish() } + for continuation in connectionStateChangesContinuations.values { continuation.finish() } + stateContinuations.removeAll() + discoveryContinuations.removeAll() + peripheralsContinuations.removeAll() + connectionStateChangesContinuations.removeAll() + + let pendingAuth = authorizationContinuations + authorizationContinuations.removeAll() + for continuation in pendingAuth.values { + continuation.resume(throwing: CancellationError()) + } + + let pendingPoweredOn = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pendingPoweredOn.values { + continuation.resume(throwing: PeripheralError.bluetoothUnavailable) + } + + if let waiter = scanWaiter { + scanWaiter = nil + waiter.continuation.resume(throwing: PeripheralError.bluetoothUnavailable) + } + + centralManager = nil + delegateShim = nil + cbPeripherals.removeAll() + discoveredPeripherals.removeAll() + clearConnectionStates() + // Drop interned handles along with the stack they belong to. Handles the app still holds keep working, + // orphaned, throwing `.bluetoothUnavailable`. A radio reset (`invalidatePeripherals`) deliberately does not + // do this — a handle must survive one with its metadata intact. + registry.removeAllHandles() + reconnectEnabled.removeAll() + intentionalDisconnects.removeAll() + reconnectAttempts.removeAll() + activeLeases.removeAll() + manualConnectHold.removeAll() + idleGeneration.removeAll() + reconnectGeneration.removeAll() + // Aliases belong to the handles dropped just above, so they go with them. Note this is + // teardown only — `invalidatePeripherals()` deliberately does not clear them, because a handle + // survives a radio reset and the id it carries has to keep resolving afterwards. + migratedIds.removeAll() + pendingRestoredScanServices = nil + } + + /// Test-only hook: number of live work leases for `id`. + func testWorkCount(for id: String) -> Int { + workCount(for: id) + } + + /// Test-only hook: whether a manual-connect hold exists for `id`. + func testHasManualConnectHold(for id: String) -> Bool { + manualConnectHold[id] != nil + } + /// Test-only hook func setReconnectPolicy(_ policy: ReconnectPolicy) { reconnectPolicy = policy } + /// Test-only hook: overrides the idle disconnect interval (seconds). `0` tears down as soon + /// as demand reaches zero. + func setIdleDisconnectInterval(_ interval: TimeInterval) { + if !interval.isFinite || interval < 0 { + idleDisconnectInterval = 5.0 + } else { + idleDisconnectInterval = interval + } + } + + /// Test-only hook: number of continuations currently parked by ``waitUntilPoweredOn()``. + func testPendingPoweredOnWaiterCount() -> Int { + poweredOnContinuations.count + } + + /// Test-only hook: whether a ``startScanning(services:)`` waiter is currently parked. + func testPendingScanWaiterCount() -> Int { + scanWaiter == nil ? 0 : 1 + } + + /// Test-only hook: the service filter of the most recently started scan, or `nil` if none. + func testLastScanServices() -> [CBUUID]? { + lastScanServices + } + /// Test-only hook: injects a disconnect event with the specified `isReconnecting` flag, /// routing through the same `handleDidDisconnect(_:)` path as a real delegate callback. /// @@ -1346,11 +2953,63 @@ actor BluetoothActor { handleDidDisconnect(payload) } + /// Test-only hook: injects a connect event, routing through the same ``handleDidConnect(_:)`` + /// path as a real delegate callback. + /// + /// Used to simulate a Tier-0 reconnect landing with zero demand (D-1 event 8), which the mock + /// cannot drive directly once the link has been torn down. + func testInjectConnect(for id: String) { + guard let cbPeripheral = cbPeripherals[id] else { return } + let payload = ConnectionPayload(peripheral: cbPeripheral, isReconnecting: false, error: nil) + handleDidConnect(payload) + } + + /// Test-only hook: replays an advertisement for an already-bound peripheral carrying a different + /// local name, driving an identity upgrade through the real + /// ``handlePeripheralDiscovered(_:advertisementData:rssi:)`` path. The mock cannot vary a spec's + /// advertised local name between packets. + func testRediscover(id: String, advertisedLocalName: String) { + guard let cbPeripheral = cbPeripherals[id] else { return } + handlePeripheralDiscovered( + cbPeripheral, + advertisementData: [CBAdvertisementDataLocalNameKey: advertisedLocalName], + rssi: -50 + ) + } + + /// Test-only hook: the id a caller-supplied `id` currently resolves to through the alias map. + func testCanonicalId(for id: String) -> String { + canonicalId(id) + } + + /// Test-only hook: the number of ids currently bound to the same live `CBPeripheral` as `id`. + /// One is the invariant ``id(for:)`` depends on; more means identity migration leaked a key. + func testIdCount(boundToPeripheralFor id: String) -> Int { + guard let cbPeripheral = cbPeripherals[id] else { return 0 } + return cbPeripherals.values.filter { $0 === cbPeripheral }.count + } + /// Test-only hook: runs the same peripheral invalidation as a Bluetooth reset/unauthorized path. func testInvalidatePeripherals() { invalidatePeripherals() } + /// Test-only hook: clears all discovered snapshots so ``refreshPeripherals()`` cannot retrieve + /// any id (simulating a peripheral that is no longer in the system cache — the D-never stranded + /// case). + func testClearDiscoveredPeripherals() { + discoveredPeripherals = [] + } + + /// Test-only hook: simulates the radio returning (as if the central just reached `.poweredOn`), + /// running the same refresh + radio-return sweep as ``handleCentralManagerStateUpdate`` + /// (D-1 event 12). Deterministic way for a stranded-lease or rediscovery test to drive the sweep + /// without a real power cycle. + func testSimulateRadioReturn() { + refreshPeripherals() + sweepRadioReturnedDemand() + } + /// Test-only hook: whether `id` is currently marked as an intentional disconnect. func testContainsIntentionalDisconnect(_ id: String) -> Bool { intentionalDisconnects.contains(id) @@ -1399,11 +3058,62 @@ actor BluetoothActor { cbPeripherals[id] != nil } + /// Test-only hook: the live `CBPeripheral`'s CoreBluetooth state for `id`, or `nil` if none. + func testCBPeripheralState(for id: String) -> CBPeripheralState? { + cbPeripherals[id]?.state + } + + /// Test-only hook: seeds the cached connection state for `id` to a Tier-0 OS reconnect in limbo + /// (`.reconnecting(source: .system, ...)`) WITHOUT touching the live `CBPeripheral` or issuing any + /// CoreBluetooth call. + /// + /// Production only produces a `.reconnecting(.system)` together with a live peripheral the mock + /// reports as `.connecting` (see ``CBMPeripheralMock``), which means the `.connecting` arm of the + /// teardown predicate alone would explain a `cancelPeripheralConnection`. This hook lets a test pin + /// the **cached** `cachedSystemReconnect(id:)` arm against a non-connecting live peripheral. + func testSeedSystemReconnectState(for id: String) { + setConnectionState(.reconnecting(source: .system, attempt: nil, nextRetryAt: nil), for: id) + } + /// Test-only hook: whether the central is currently scanning. func testIsScanning() -> Bool { centralManager?.isScanning == true } + /// Test-only hook: number of `cancelPeripheralConnection` calls issued for `id`. + /// + /// Lets a test pin that a cancel was actually issued (rather than reasoning about a downstream + /// side effect the mock cannot produce), mirroring the ``testIsScanning`` / ``testLastScanServices`` + /// hook style. + func testCancelPeripheralConnectionCount(for id: String) -> Int { + cancelCallCounts[id] ?? 0 + } + + /// Test-only hook: drives one reconnect-ladder step for `id` on this actor turn, bypassing the + /// sleep/backoff machinery. + /// + /// Re-seeds the ladder bookkeeping (``reconnectGeneration``, ``reconnectAttempts``) and a + /// `.reconnecting(source: .library, attempt:nextRetryAt:)` cached state, then invokes + /// ``performReconnect(id:attempt:generation:)`` directly so a test can pin the dead-radio gate + /// deterministically instead of racing the delegate-driven invalidation that cancels a sleeping + /// ladder. White-box test hook only — never called from production code. + func testInvokeLadderStep(for id: String) { + _ = testInvokeLadderStepReturningState(for: id) + } + + /// Same as ``testInvokeLadderStep(for:)``, but returns the connection state at the end of the + /// actor turn so tests can assert the gate outcome without racing a later mock power-on sweep + /// that may rewrite no-demand terminals via ``beginIdleGrace``. + func testInvokeLadderStepReturningState(for id: String) -> ConnectionState? { + let attempt = 1 + let generation = (reconnectGeneration[id] ?? 0) + 1 + reconnectGeneration[id] = generation + reconnectAttempts[id] = attempt + setConnectionState(.reconnecting(source: .library, attempt: attempt, nextRetryAt: Date()), for: id) + performReconnect(id: id, attempt: attempt, generation: generation) + return connectionStates[id] + } + /// Test-only hook: number of registered `connectionStateChanges` subscribers. /// /// Stream registration is asynchronous — the factory schedules `register(...)` on a detached @@ -1449,9 +3159,23 @@ actor BluetoothActor { delegateShim is BluetoothDelegateShim } - /// Test-only hook: reconnect intent persisted for the current restore identifier. - func testPersistedReconnectIntent() -> Set { - persistedReconnectIntent() + /// Test-only hook: the persisted manual-connect hold map (`id → reconnectDesired`) for the + /// current restore identifier. + func testPersistedManualConnectHolds() -> [String: Bool] { + persistedHoldMap() + } + + /// Test-only hook: CoreBluetooth UUID stored with a persisted hold, when present. + func testPersistedHoldCbUUID(for id: String) -> UUID? { + loadPersistedHoldRecords() + .first(where: { $0.id == id })? + .cbUUID + .flatMap(UUID.init(uuidString:)) + } + + /// Test-only hook: in-memory hold's CoreBluetooth UUID, when present. + func testManualConnectHoldCbUUID(for id: String) -> UUID? { + manualConnectHold[id]?.cbIdentifier } /// Test-only hook: removes any persisted reconnect intent for the current restore identifier. @@ -1460,7 +3184,7 @@ actor BluetoothActor { UserDefaults.standard.removeObject(forKey: key) } - } +} // MARK: - BluetoothDelegateShim diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index d94320f..72ced06 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -4,7 +4,12 @@ Installing ReliaBLE to your project, configuration and some starter examples of ## Overview -[TODO] More details coming soon. +ReliaBLE uses a **work-driven connection model**: a peripheral connects because pending work needs a link — not because the app called `connect`. When the work is done and there is no manual-connect hold, the link tears down after a configurable idle interval (default 5 seconds). This keeps the link alive only as long as it is needed, minimizing unnecessary BLE activity. + +The two ways to create demand for a link are: + +- **Work** (the primary path): a non-empty command queue (FR-4/FR-5, not yet shipped) drives an auto-connect. In the interim, the library uses internal work leases for the same purpose. +- **Manual connect** (advanced, expected to be rare): ``Peripheral/connect(autoReconnect:)`` sets a manual-connect hold that suppresses idle teardown. ``Peripheral/disconnect()`` clears the hold. A manual connect is **durable across relaunch** when ``ReliaBLEConfig/restoreIdentifier`` is configured — the hold is persisted and rehydrated on restore, so a standing session survives app termination. ## Installing ReliaBLE @@ -81,16 +86,15 @@ Once Bluetooth is authorized, you can start scanning for nearby Bluetooth Low En The ReliaBLEManager provides methods to control scanning: -1. Ensure Bluetooth is ready before scanning. Scanning won't work if Bluetooth is unauthorized or powered off. -2. Use ``ReliaBLEManager/startScanning(services:)`` to begin discovering peripherals. You can pass an optional array of `CBUUID` objects to filter for peripherals advertising specific services, or omit the parameter to scan for all peripherals. -3. Use ``ReliaBLEManager/stopScanning()`` to stop the scan when done. +1. Use ``ReliaBLEManager/startScanning(services:)`` to begin discovering peripherals. You can pass an optional array of `CBUUID` objects to filter for peripherals advertising specific services, or omit the parameter to scan for all peripherals. Rather than silently no-op'ing when the radio is not yet usable, this awaits a transient (`.resetting` / `.unknown`) state and fails fast with a typed ``PeripheralError`` for terminal states (``PeripheralError/bluetoothPoweredOff``, ``PeripheralError/bluetoothUnsupported``, ``PeripheralError/bluetoothUnavailable``). +2. Use ``ReliaBLEManager/stopScanning()`` to stop the scan when done. Example of starting and stopping a scan for all peripherals: ```swift // Check if Bluetooth is ready if await bleManager.currentState == .ready { - await bleManager.startScanning() + try await bleManager.startScanning() // Stop scanning after 10 seconds try? await Task.sleep(for: .seconds(10)) @@ -109,7 +113,7 @@ import CoreBluetooth // Check if Bluetooth is ready if await bleManager.currentState == .ready { let serviceUUIDs = [CBUUID(string: "180D"), CBUUID(string: "180F")] // Heart Rate and Battery services - await bleManager.startScanning(services: serviceUUIDs) + try await bleManager.startScanning(services: serviceUUIDs) // Stop scanning after 10 seconds try? await Task.sleep(for: .seconds(10)) @@ -165,6 +169,18 @@ try await band.disconnect() ``Peripheral/connect(autoReconnect:)`` is an `async throws` call that throws ``PeripheralError/notFound`` when the device has never been discovered and ``PeripheralError/bluetoothUnavailable`` when the manager that vended the handle has been deallocated or shut down. +### Manual connect + +The ``Peripheral/connect(autoReconnect:)`` / ``Peripheral/disconnect()`` pair is the **Manual connect** API. It sets a manual-connect hold that suppresses idle teardown while the hold is active. This is documented as **Advanced** and expected to be rare: in the final model, the primary path to a link is work (a non-empty command queue), not an explicit connect call. + +A manual connect is **durable across relaunch** when ``ReliaBLEConfig/restoreIdentifier`` is configured. The hold is persisted and rehydrated on state restoration, so a standing session the app explicitly established survives app termination. If no restore identifier is configured, the hold is process-scoped only. + +### Idle disconnect + +When a peripheral has no pending work and no manual-connect hold, the link tears down after ``ReliaBLEConfig/idleDisconnectInterval`` (default 5 seconds). This is **global**: it applies to every peripheral managed by this config, not per-peripheral. A value of `0` tears the link down as soon as demand reaches zero. + +The idle teardown is indistinguishable from a clean disconnect on the ``ReliaBLEManager/connectionStateChanges`` stream — both produce ``ConnectionState/disconnected(reason:)`` with a `nil` reason. This is by design: the reconnect policy treats it as intentional (FR-11.5). + ### Reading handle metadata A ``Peripheral`` handle carries synchronous, cached, **last-known** metadata: @@ -204,6 +220,8 @@ Reconnection is **on by default** via the `autoReconnect` parameter (default `tr 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. +Both tiers gate on the same derived demand signal: Tier 0 is passed on every connect issued while the link is wanted, and Tier 1 arms on unexpected disconnect only while demand is present. A quiet peripheral (no work, no hold) arms neither. + To disable auto-reconnect for a one-shot connection, pass `autoReconnect: false`: ```swift diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md index 5e95785..7222186 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md @@ -63,6 +63,11 @@ await bleManager.startScanning(services: [CBUUID(string: "180D")]) > to foreground scans. ``AdvertisementData`` fields that originate from scan > response data (such as local name) are especially affected. +> Note: True continuous background scanning — the library keeping a scan going +> on its own, driven by demand — is out of scope here. Neither a manual-connect +> hold nor pending work implies "keep scanning"; a hold or lease keeps a *link* +> alive, not a scan. Demand-driven scan policy is a separate, future concern. + ## Restored connections If your app had connected or connecting peripherals when it was terminated, @@ -81,6 +86,18 @@ Restored peripherals are **not** emitted on advertisement payload or RSSI, so that feed remains reserved for real advertisements. +### What survives restoration + +Restoration is governed by a **restore matrix** that decides, per restored +link, whether it is kept alive or torn down: + +| Case | Restored link | Persisted manual-connect hold | Result | +|------|---------------|-------------------------------|--------| +| 1 | Yes | Yes | Hold rehydrates and keeps the link — no idle timer. Reconnect intent is re-armed with the `autoReconnect` value the app originally asked for. | +| 2 | Yes | No | No hold — the link idles out after ``ReliaBLEConfig/idleDisconnectInterval`` (default 5s). | +| 3 | — | — | **Work never survives process death.** Work leases are process-scoped and are never persisted or rehydrated. | +| 4 | Yes | Yes, `autoReconnect: false` | Hold survives and suppresses idle, but arms neither reconnect tier and is not re-issued on radio return. | + The Tier-0 system-managed reconnection (``ReconnectSource/system``) survives app termination because it runs in the iOS daemon. Tier-1 library-managed reconnection (``ReconnectSource/library``) does not, so ReliaBLE persists your diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index 06bcd32..e8dd0a1 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -61,13 +61,28 @@ All mutating actions are `async` and hop onto the Bluetooth actor for you: - ``Peripheral/connect(autoReconnect:)`` - ``Peripheral/disconnect()`` +> Note: ``ReliaBLEManager/startScanning(services:)`` **throws**. A usable radio is awaited +> before scanning begins, and cancelling the calling task while the scan is parked on a transient +> state unblocks the pending radio wait with a `CancellationError`. +> +> The radio wait follows a **wait-vs-fail policy** shared by scanning, ``Peripheral/connect(autoReconnect:)``, +> and work submission: +> +> - **Transient states** (`.resetting`, `.unknown`) are **awaited** until the radio resolves. +> - **Terminal states** fail fast with a **typed error**: `.poweredOff` throws +> ``PeripheralError/bluetoothPoweredOff``, `.unsupported` throws +> ``PeripheralError/bluetoothUnsupported``, and `.unauthorized` (or a missing/shut-down central) +> throws ``PeripheralError/bluetoothUnavailable``. +> +> So a scan or connect never silently no-op's when the radio is not usable: it either waits for a +> transient state to clear, or surfaces a typed ``PeripheralError`` you can react to. + The current Bluetooth state is exposed as an `async` getter, ``ReliaBLEManager/currentState``: ```swift let state = await manager.currentState ``` - ### Observing events ReliaBLE exposes three event surfaces, each of which returns a **fresh, diff --git a/Sources/ReliaBLE/Models/ConnectionState.swift b/Sources/ReliaBLE/Models/ConnectionState.swift index 4ee18b8..27063ea 100644 --- a/Sources/ReliaBLE/Models/ConnectionState.swift +++ b/Sources/ReliaBLE/Models/ConnectionState.swift @@ -34,10 +34,14 @@ public enum ConnectionState: Sendable, Equatable, Hashable { 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. + /// For ``ReconnectSource/library``, a `nil` `attempt` / `nextRetryAt` means the link is + /// **waiting for the radio** — projected when a demanded link's radio drops, or when a + /// reconnect-wanting manual-connect hold is registered while the radio is not yet usable, and + /// held until a connect is issued, the ladder supplies real values, the link succeeds, or demand + /// clears — not yet on the backoff ladder. A ladder step that has actually armed carries + /// **real** values: `attempt >= 1` and a concrete `nextRetryAt`. ``ReconnectSource/system`` + /// is iOS reconnecting at the daemon level; the library is not involved and always exposes + /// `nil` for both. case reconnecting(source: ReconnectSource, attempt: Int?, nextRetryAt: Date?) /// The peripheral is currently connected. case connected @@ -46,7 +50,9 @@ public enum ConnectionState: Sendable, Equatable, Hashable { /// The peripheral has disconnected. /// /// The `reason` is `nil` for a clean, explicit disconnect and non-`nil` for an unexpected - /// drop from CoreBluetooth. + /// drop from CoreBluetooth. Idle-grace teardown also surfaces as an intentional + /// `reason: nil` — deliberately indistinguishable from an app-initiated disconnect to the + /// reconnect policy (FR-11.5 "intentional"). case disconnected(reason: PeripheralError?) /// A connection attempt has failed. /// diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 8bafcb2..9db70bd 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -154,6 +154,12 @@ public final class Peripheral: Sendable, Identifiable, Hashable { /// Initiates a connection to this peripheral. /// + /// Rather than silently no-op'ing or throwing when the radio is not yet usable, this waits for + /// a transient (`.resetting` / `.unknown`) radio state to resolve before issuing the connect, + /// and fails fast with a typed error for terminal states (``PeripheralError/bluetoothPoweredOff``, + /// ``PeripheralError/bluetoothUnsupported``, ``PeripheralError/bluetoothUnavailable``). Cancelling + /// the calling task while parked on a transient state unblocks the wait with a `CancellationError`. + /// /// - 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 @@ -166,18 +172,77 @@ public final class Peripheral: Sendable, Identifiable, Hashable { guard let manager = state.withLock({ $0.manager }) else { throw PeripheralError.bluetoothUnavailable } await manager.bluetooth.ensureCentralManager() - try await manager.bluetooth.connect(id: id, autoReconnect: autoReconnect) + + // Register the manual-connect hold FIRST so a connect that throws (e.g. bluetoothPoweredOff) + // still leaves durable demand behind — the throw is informational, not destructive. + // When `autoReconnect` is true and the radio is not usable, the hold path also projects + // AwaitingRadio `.reconnecting(.library, nil, nil)` so stream observers see demand immediately. + // The actor resolves this handle's id to whatever it currently files the device under, and + // returns it. Reuse that below rather than passing `id` again: the two calls are separated by + // an `await`, and re-resolving could land the connect on a different device than the hold. + let resolvedID = await manager.bluetooth.applyManualConnectHold(id: id, reconnectDesired: autoReconnect) + + // Await a usable radio (cancellable), then ensure the link. Failures after the hold is + // recorded (terminal radio, missing peripheral, etc.) are warned so Console shows why + // the call threw; `CancellationError` is not a failure and is rethrown quietly. + do { + let waiterID = UUID() + try await withTaskCancellationHandler { + try await manager.bluetooth.waitUntilPoweredOn(waiterID: waiterID) + } onCancel: { + Task { await manager.bluetooth.cancelPoweredOnContinuation(waiterID) } + } + + try await manager.bluetooth.reevaluateLink(id: resolvedID, reason: .explicitConnect) + } catch is CancellationError { + throw CancellationError() + } catch { + manager.loggingService.warn( + tags: [.peripheral(id), .category(.connection)], + "Manual connect failed: \(error)" + ) + throw error + } } /// Initiates a disconnection from this peripheral. /// - /// - Throws: ``PeripheralError/notFound`` if the library holds no live reference for this ``id``, or - /// ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up or the vending manager is gone. + /// Clears this handle's manual-connect hold and intentionally cancels the link per the settling + /// rule. Returns success even when the library holds no live reference (e.g. during a radio + /// outage), because dropping a hold must never throw `.notFound`. In that case the connection + /// state still settles immediately to ``ConnectionState/disconnected(reason:)`` with a `nil` + /// reason so observers drop any radio-await reconnecting caption without waiting for power-on. public func disconnect() async throws { guard let manager = state.withLock({ $0.manager }) else { throw PeripheralError.bluetoothUnavailable } + await manager.bluetooth.applyManualDisconnect(id: id) + } + + /// Acquires a work lease on this peripheral (internal demand substrate). + /// + /// Ensures a central, awaits a usable radio (cancellable), then forwards to the actor lease + /// acquisition, which creates demand and drives an auto-connect without a prior manual + /// ``connect(autoReconnect:)``. `@testable`-visible only; no public surface this phase (D-work). + func acquireWorkLease() async throws -> WorkLeaseToken { + guard let manager = state.withLock({ $0.manager }) else { throw PeripheralError.bluetoothUnavailable } + await manager.bluetooth.ensureCentralManager() - try await manager.bluetooth.disconnect(id: id) + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await manager.bluetooth.waitUntilPoweredOn(waiterID: waiterID) + } onCancel: { + Task { await manager.bluetooth.cancelPoweredOnContinuation(waiterID) } + } + + return try await manager.bluetooth.acquireWorkLease(id: id) + } + + /// Releases a work lease previously acquired on this peripheral. Releasing an unknown or + /// already-released token is a no-op. + func releaseWorkLease(_ token: WorkLeaseToken) async { + guard let manager = state.withLock({ $0.manager }) else { return } + await manager.bluetooth.releaseWorkLease(token) } // MARK: - Internal mutation diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift index 546acf1..76f444c 100644 --- a/Sources/ReliaBLE/Models/PeripheralError.swift +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -47,6 +47,26 @@ public enum PeripheralError: Error, Sendable, Equatable { /// retrying; if the manager is gone, create a new one and obtain a fresh handle. case bluetoothUnavailable + /// Bluetooth is powered off, so the operation could not be performed. + /// + /// Thrown when an operation requires a usable radio but `CBCentralManager.state` is + /// `.poweredOff`. This is a **terminal fail-fast** condition: user-disabled Bluetooth is + /// a decision the app must surface, not a condition to hang the wait on, so the wait + /// does not continue when this state is reached. + case bluetoothPoweredOff + + /// Bluetooth is unsupported on this device, so the operation could not be performed. + /// + /// Thrown when an operation requires a usable radio but `CBCentralManager.state` is + /// `.unsupported`. Like ``bluetoothPoweredOff``, this is a **terminal fail-fast** + /// condition — the radio will never become usable, so the operation fails rather than + /// awaiting. + /// + /// Unlike the transient states (`.resetting` and `.unknown`), which are awaited until + /// they resolve, ``bluetoothPoweredOff`` and ``bluetoothUnsupported`` fail promptly. + /// ``bluetoothUnavailable`` covers the `.unauthorized`, no-central, and shut-down cases. + case bluetoothUnsupported + /// The connection to the peripheral failed. case connectionFailed diff --git a/Sources/ReliaBLE/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 818a1c3..db8a8ce 100644 --- a/Sources/ReliaBLE/ReliaBLEConfig.swift +++ b/Sources/ReliaBLE/ReliaBLEConfig.swift @@ -65,10 +65,18 @@ public struct ReliaBLEConfig: Sendable { /// The default `nil` disables state restoration and preserves the existing lazy-init /// contract unchanged. public var restoreIdentifier: String? = nil - + + /// The idle time, in seconds, after which a connected peripheral is torn down when it has + /// no pending work and no manual-connect hold (FR-1.5). This is **global**: it applies to + /// every peripheral managed by this config, not per-peripheral. + /// + /// A value of `0` tears the link down as soon as demand reaches zero. The default value + /// is `5.0`. + public var idleDisconnectInterval: TimeInterval = 5.0 + /// Initializes a new `ReliaBLEConfig` instance with the default values. public init() { - + } } diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 14081c9..cb63e69 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -71,6 +71,7 @@ public final class ReliaBLEManager: Sendable { log: loggingService, reconnectPolicy: config.reconnectPolicy, restoreIdentifier: config.restoreIdentifier, + idleDisconnectInterval: config.idleDisconnectInterval, registry: handleRegistry ) @@ -175,14 +176,24 @@ public final class ReliaBLEManager: Sendable { /// Starts scanning for peripheral devices, optionally filtering by specific services. /// + /// Rather than silently no-op'ing when the radio is not yet usable, this waits for a transient + /// (`.resetting` / `.unknown`) state to resolve and fails fast with a typed + /// ``PeripheralError`` for terminal states (``PeripheralError/bluetoothPoweredOff``, + /// ``PeripheralError/bluetoothUnsupported``, ``PeripheralError/bluetoothUnavailable``). + /// Cancelling the calling task while the call is parked on a transient radio state unblocks the + /// wait with a `CancellationError`. + /// /// - Parameter services: An optional array of `CBUUID` objects representing the services to scan for. If provided, /// only peripherals advertising these services will be discovered. If `nil`, scans for all peripheral devices. - /// - /// - Note: If Bluetooth is not authorized or powered on, this method will not start scanning. It is the caller's - /// responsibility to ensure that Bluetooth is authorized and powered on before calling this method. - public func startScanning(services: sending [CBUUID]? = nil) async { + public func startScanning(services: sending [CBUUID]? = nil) async throws { await bluetooth.ensureCentralManager() - await bluetooth.startScanning(services: services) + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await bluetooth.startScanning(services: services, waiterID: waiterID) + } onCancel: { + Task { await bluetooth.cancelScanWaiter(waiterID) } + } } /// Stops scanning for peripheral devices. diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 4ff1060..2aca5d7 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -68,8 +68,8 @@ struct ReliaBLEManagerTests { // `peripheral(id:)` is synchronous and nonisolated — callable from any isolation domain. _ = manager.peripheral(id: "unused") - await manager.startScanning() - await manager.startScanning(services: []) + try? await manager.startScanning() + try? await manager.startScanning(services: []) await manager.stopScanning() // `authorizeBluetooth()` suspends until the authorization decision resolves; under the mock's @@ -182,7 +182,7 @@ struct ReliaBLEManagerTests { @Test func authorizeThrowsWhenDenied() async throws { let manager = await Mock.makeManager() - CBMCentralManagerMock.simulateAuthorization(.denied) + await Mock.simulateAuthorization(.denied) do { try await manager.authorizeBluetooth() @@ -196,7 +196,7 @@ struct ReliaBLEManagerTests { @Test func authorizeThrowsWhenRestricted() async throws { let manager = await Mock.makeManager() - CBMCentralManagerMock.simulateAuthorization(.restricted) + await Mock.simulateAuthorization(.restricted) do { try await manager.authorizeBluetooth() @@ -220,7 +220,7 @@ struct ReliaBLEManagerTests { // Force the undetermined path so `authorizeBluetooth()` suspends awaiting the user's decision. // Cancelling the task must unblock the suspension instead of hanging forever. - CBMCentralManagerMock.simulateAuthorization(.notDetermined) + await Mock.simulateAuthorization(.notDetermined) let task = Task { try await manager.authorizeBluetooth() } try? await Task.sleep(nanoseconds: 100_000_000) @@ -235,20 +235,20 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateAuthorization(.denied) + await Mock.simulateAuthorization(.denied) await manager.bluetooth.updateState() #expect(await manager.currentState.description == "Denied") - CBMCentralManagerMock.simulateAuthorization(.restricted) + await Mock.simulateAuthorization(.restricted) await manager.bluetooth.updateState() #expect(await manager.currentState.description == "Restricted") - CBMCentralManagerMock.simulateAuthorization(.notDetermined) + await Mock.simulateAuthorization(.notDetermined) await manager.bluetooth.updateState() #expect(await manager.currentState.description == "Not Authorized") // Restore the baseline so later tests start from a known-good authorization. - CBMCentralManagerMock.simulateAuthorization(.allowedAlways) + await Mock.simulateAuthorization(.allowedAlways) await manager.bluetooth.updateState() } @@ -256,7 +256,7 @@ struct ReliaBLEManagerTests { // Pin auth before construction: ensureConfigured only does this once, and the prior // test may have left .allowedAlways. Init's fire-and-forget ensureCentralManager must // publish .unauthorized(.notDetermined) even though no central is created. - CBMCentralManagerMock.simulateAuthorization(.notDetermined) + await Mock.simulateAuthorization(.notDetermined) let manager = await Mock.makeManager() #expect(await Mock.waitForState("Not Authorized", on: manager)) @@ -294,26 +294,232 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning(services: nil) + try await manager.startScanning(services: nil) #expect(await Mock.waitForState("Scanning", on: manager)) await manager.stopScanning() #expect(await Mock.waitForState("Ready", on: manager)) } - @Test func startScanningIsNoOpWhenNotPoweredOn() async throws { + @Test func startScanningFailsWhenPoweredOff() async throws { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() #expect(await Mock.waitForState("Powered Off", on: manager)) - await manager.startScanning() - // The guard on `centralManager.state == .poweredOn` means the scan never starts. - #expect(await manager.currentState.description == "Powered Off") + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await manager.startScanning() + } + + // No scan started despite the call. + #expect(await manager.bluetooth.testIsScanning() == false) // Restore power so later tests start from a known-good state. - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @Test func startScanningFailsWhenUnsupported() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unsupported) + #expect(await Mock.waitForState("Unsupported", on: manager)) + + await #expect(throws: PeripheralError.bluetoothUnsupported) { + try await manager.startScanning() + } + + #expect(await manager.bluetooth.testIsScanning() == false) + + // Restore power so later tests start from a known-good state. + await Mock.simulateInitialState(.poweredOn) + #expect(await Mock.waitForState("Ready", on: manager)) + } + + @Test func startScanningAwaitsTransientState() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanTask = Task { try await manager.startScanning() } + + // Wait until the scan waiter is parked on the transient state. + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + await Mock.simulatePowerOn() + _ = try await scanTask.value + + // The radio reaching `.poweredOn` resolved the waiter and started the scan. + #expect(await Mock.waitForState("Scanning", on: manager)) + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + } + + @Test func transientStateResolvingToPoweredOffFailsWaiter() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanTask = Task { try await manager.startScanning() } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + await Mock.simulatePowerOff() + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await scanTask.value + } + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + #expect(await manager.bluetooth.testIsScanning() == false) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @Test func stopScanningCompletesParkedScanWaiterSuccessfully() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanTask = Task { try await manager.startScanning() } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + // stopScanning() while parked resolves the waiter successfully; no scan starts. + await manager.stopScanning() + _ = try await scanTask.value + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + #expect(await manager.bluetooth.testIsScanning() == false) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @Test func supersededScanWaiterCompletesWithoutScanning() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let first = Task { try await manager.startScanning(services: [CBUUID(string: "180D")]) } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + // A second startScanning supersedes the first. + let second = Task { try await manager.startScanning(services: nil) } + + // The earlier waiter completes successfully without scanning. + _ = try await first.value + + // The newer request now owns the single-slot scan waiter. + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + await Mock.simulatePowerOn() + _ = try await second.value + + #expect(await Mock.waitForState("Scanning", on: manager)) + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + } + + @Test func supersededScanWaiterDoesNotStealNewerRequest() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanUUID = CBUUID(string: "180D") + + // First waiter parks with a NIL filter while the radio is transient. + let first = Task { try await manager.startScanning(services: nil) } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + // A second waiter with a NON-nil filter supersedes the first. The first must complete + // successfully WITHOUT scanning, and the second must keep ownership of the pending scan. + let second = Task { try await manager.startScanning(services: [scanUUID]) } + + _ = try await first.value // superseded — success, no scan, no error + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 1) + + await Mock.simulatePowerOn() + _ = try await second.value + + #expect(await Mock.waitForState("Scanning", on: manager)) + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + // The scan that actually started must use the SECOND caller's filter, not the first's nil. + #expect(await manager.bluetooth.testLastScanServices() == [scanUUID]) + + await manager.stopScanning() + } + + @Test func cancelScanWaiterIgnoresNonMatchingWaiterID() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanUUID = CBUUID(string: "180D") + + // A scan task parks with a NON-nil filter while the radio is transient. + let scanTask = Task { try await manager.startScanning(services: [scanUUID]) } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + // Call cancelScanWaiter with a foreign, non-matching id — this must NOT + // cancel the parked waiter. Against the pre-fix global cancel, this would + // have resumed the waiter with a CancellationError. + await manager.bluetooth.cancelScanWaiter(UUID()) + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 1) + + // Power on: the waiter resolves and starts scanning with its filter. + await Mock.simulatePowerOn() + _ = try await scanTask.value // Must NOT throw + + #expect(await Mock.waitForState("Scanning", on: manager)) + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + #expect(await manager.bluetooth.testLastScanServices() == [scanUUID]) + + await manager.stopScanning() + } + + @Test func startScanningCancellationUnblocksWaiter() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanTask = Task { try await manager.startScanning() } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + // Cancelling the awaiting task unblocks the parked waiter with a `CancellationError` + // and leaves no continuation behind. + scanTask.cancel() + // Bound the wait so a regression (waiter failing to unblock) fails explicitly instead of + // wedging the whole test run; a matching cancel surfaces `CancellationError` here. + do { + try await withTimeout(nanoseconds: 4_000_000_000) { + _ = try await scanTask.value + } + Issue.record("startScanning must throw CancellationError when cancelled, but returned normally") + } catch is CancellationError { + // Expected: the cancelled waiter surfaces a `CancellationError` through the task value. + } catch { + throw error + } + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + #expect(await manager.bluetooth.testIsScanning() == false) + + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -326,7 +532,7 @@ struct ReliaBLEManagerTests { // `peripheralDiscoveries` does not replay, so subscribe before scanning starts. let discoveries = manager.peripheralDiscoveries - await manager.startScanning() + try await manager.startScanning() let discovered = await Mock.waitForDiscovered( id: Mock.testPeripheralID, @@ -378,7 +584,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -409,7 +615,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -419,10 +625,10 @@ struct ReliaBLEManagerTests { // Powering off then on drives the `centralManagerDidUpdateState` path, which re-resolves // the live references for already-discovered peripherals on power-on. - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() #expect(await Mock.waitForState("Powered Off", on: manager)) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() #expect(await Mock.waitForState("Ready", on: manager)) } @@ -430,7 +636,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -456,7 +662,7 @@ struct ReliaBLEManagerTests { #expect(handle.lastSeen == nil) #expect(handle.advertisement == nil) - await manager.startScanning() + try await manager.startScanning() _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -475,7 +681,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -496,7 +702,7 @@ struct ReliaBLEManagerTests { let handle = manager.peripheral(id: Mock.testPeripheralID) let stream = manager.discoveredPeripherals - await manager.startScanning() + try await manager.startScanning() // On the first element containing the test id, immediately assert the handle // carries metadata — no polling, just a direct assertion after the element arrives. @@ -519,7 +725,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() let discovered = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -534,6 +740,94 @@ struct ReliaBLEManagerTests { try await handle.connect() } + @Test func connectFailsWhenPoweredOff() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let discovered = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + let handle = try #require(discovered).peripheral + + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await handle.connect() + } + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @Test func connectAwaitsTransientState() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let discovered = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + let handle = try #require(discovered).peripheral + + // Transition to a transient state. `.unknown` deliberately avoids `.resetting`, which would + // trigger peripheral invalidation (a later step); here the live reference must survive so + // connect can proceed once the radio returns. + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let connectTask = Task { try await handle.connect() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 + } + + await Mock.simulatePowerOn() + try await connectTask.value + + // The parked radio wait was resolved; no continuation is left behind. + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + } + + @Test func connectTransientResolvingToPoweredOffThrows() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let discovered = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + let handle = try #require(discovered).peripheral + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let connectTask = Task { try await handle.connect() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 + } + + // The transient resolves to a terminal `.poweredOff`, failing the waiter with a typed error. + await Mock.simulatePowerOff() + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await connectTask.value + } + + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + @Test func connectToUnknownPeripheralThrows() async throws { let manager = await Mock.makeManager() // ensureReady brings the central online so `.notFound` is deterministic. @@ -558,7 +852,7 @@ struct ReliaBLEManagerTests { var changes = manager.connectionStateChanges.makeAsyncIterator() #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -598,7 +892,7 @@ struct ReliaBLEManagerTests { // Drive a scan cycle so the enabled logger evaluates its message autoclosures. await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() _ = await Mock.waitForState("Scanning", on: manager) await manager.stopScanning() } @@ -702,7 +996,7 @@ struct ReliaBLEManagerTests { #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) // Discover the connectable test peripheral. - await manager.startScanning() + try await manager.startScanning() let discovered = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -722,618 +1016,620 @@ struct ReliaBLEManagerTests { #expect(connected?.state == .connected) } - @Test func connectionStateChangesEmitsDisconnectSequence() async throws { + /// An advertisement that upgrades a peripheral's app-facing id must carry every per-id record onto + /// the new id — above all the live `CBPeripheral`. + /// + /// The live `CBPeripheral` must end up bound to exactly one id, so delegate callbacks resolve back + /// to the id that carries the demand. + @Test func identityUpgradeMigratesLiveReferenceSoConnectResolvesToUpgradedId() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) let manager = await Mock.makeManager() await Mock.ensureReady(manager) - let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() - var changes = manager.connectionStateChanges.makeAsyncIterator() - #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - - await manager.startScanning() - let discovered = await Mock.waitForDiscovered( + try await manager.startScanning() + _ = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(discovered).peripheral await manager.stopScanning() - try await handle.connect() + let upgradedID = "ReliaBLE-Renamed-Peripheral" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) - // Drain .connecting and .connected. - _ = await changes.next() - _ = await changes.next() + // The live reference moved rather than being duplicated. + #expect(await manager.bluetooth.testContainsCBPeripheral(upgradedID)) + #expect(await manager.bluetooth.testContainsCBPeripheral(Mock.connectionTestPeripheralID) == false) + #expect(await manager.bluetooth.testIdCount(boundToPeripheralFor: upgradedID) == 1) - try await handle.disconnect() + // And exactly one row is published for the one radio. + let rows = await manager.bluetooth.discoveredPeripherals.filter { + $0.id == upgradedID || $0.id == Mock.connectionTestPeripheralID + } + #expect(rows.count == 1) + #expect(rows.first?.id == upgradedID) - let disconnecting = await changes.next() - #expect(disconnecting?.peripheralId == handle.id) - #expect(disconnecting?.state == .disconnecting) + // The payoff: `didConnect` resolves back to the id that carries the demand. + let handle = manager.peripheral(id: upgradedID) + try await handle.connect() - let disconnected = await changes.next() - #expect(disconnected?.peripheralId == handle.id) - #expect(disconnected?.state == .disconnected(reason: nil)) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) + #expect(await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == nil) + + await Mock.tearDown(manager) } - @Test func connectionStateChangesEmitsConnectFailureSequence() async throws { - // Pre-condition: no stale connection state from a preceding lifecycle test. - Mock.connectionTestSpec.simulateDisconnection() - try? await Task.sleep(nanoseconds: 100_000_000) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + /// The Demo hang's actual shape: demand is already live under the interim id when the upgrading + /// advertisement lands, so the hold, the connection state, and the live reference all have to move + /// together — and the link must survive the move rather than idling out under an id nothing holds. + @Test func identityUpgradeDuringLiveConnectionMovesStateAndDemand() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) let manager = await Mock.makeManager() await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) - let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() - var changes = manager.connectionStateChanges.makeAsyncIterator() - #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let interimHandle = try #require(snap).peripheral await manager.stopScanning() - // Force a clean disconnection on the spec to reset any lingering - // `virtualConnections` / `isConnected` state left by a preceding test. - Mock.connectionTestSpec.simulateDisconnection() - try? await Task.sleep(nanoseconds: 100_000_000) + try await interimHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - // Configure failure only after discovery — a failed connectionResult can - // interfere with mock advertising while the previous stack tears down. - Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + let upgradedID = "ReliaBLE-Renamed-Live" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) - try await handle.connect() + // Demand and state followed the id. + #expect(await manager.bluetooth.testHasManualConnectHold(for: upgradedID)) + #expect(await manager.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID) == false) + #expect(await manager.bluetooth.testIsReconnectEnabled(upgradedID)) + #expect(await manager.currentConnectionStates[upgradedID] == .connected) + #expect(await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == nil) + #expect(await manager.bluetooth.testIdCount(boundToPeripheralFor: upgradedID) == 1) - let connecting = await changes.next() - let failed = await changes.next() + // The hold still suppresses idle after the move — well past the 0.1s interval. + try await Task.sleep(nanoseconds: 400_000_000) + #expect(await manager.currentConnectionStates[upgradedID] == .connected) - #expect(connecting?.peripheralId == handle.id) - #expect(connecting?.state == .connecting) - #expect(failed?.peripheralId == handle.id) - #expect(failed?.state == .failed(reason: .connectionTimeout)) + await Mock.tearDown(manager) } - @Test func connectionStateChangesSupportsConcurrentSubscribers() async throws { + /// Migration cancels the old id's idle timer because its closure captured that id. The timer has to + /// be re-armed under the new one: nothing else re-drives it, so a no-demand link would otherwise + /// stay up forever. + @Test func identityUpgradeReArmsIdleTeardownUnderUpgradedId() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) let manager = await Mock.makeManager() await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(1.0) - var subscriberA = manager.connectionStateChanges.makeAsyncIterator() - var subscriberB = manager.connectionStateChanges.makeAsyncIterator() - - // Force an actor hop to guarantee the registration Tasks have completed - // before we issue the connect (connectionStateChanges has no replay). - _ = await manager.currentConnectionStates - - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let interimHandle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() + let token = try await interimHandle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - // Both subscribers see the .connecting event. - let a1 = await subscriberA.next() - let b1 = await subscriberB.next() - #expect(a1?.state == .connecting) - #expect(b1?.state == .connecting) + // Drop demand to arm the idle timer, then upgrade the id inside the grace window. + await interimHandle.releaseWorkLease(token) + #expect(await manager.bluetooth.testWorkCount(for: Mock.connectionTestPeripheralID) == 0) - // Both subscribers see the .connected event. - let a2 = await subscriberA.next() - let b2 = await subscriberB.next() - #expect(a2?.state == .connected) - #expect(b2?.state == .connected) - } + let upgradedID = "ReliaBLE-Renamed-Idle" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) - // MARK: - Reconnection + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[upgradedID] == .disconnected(reason: nil) + }) - /// 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 - ) + await Mock.tearDown(manager) + } - @Test func systemReconnectOnUnexpectedDrop() async throws { + /// A lease minted before an identity upgrade names the obsolete id. Releasing it must still drop + /// demand, or the link can never idle. + @Test func workLeaseTakenBeforeIdentityUpgradeIsStillReleasable() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - - let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() - var changes = manager.connectionStateChanges.makeAsyncIterator() - #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + await manager.bluetooth.setIdleDisconnectInterval(0.1) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let interimHandle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() - - // Drain .connecting and .connected. - let c1 = await changes.next() - #expect(c1?.state == .connecting) - let c2 = await changes.next() - #expect(c2?.state == .connected) - - // Simulate an unexpected disconnect with the OS auto-reconnect option active. - Mock.connectionTestSpec.simulateDisconnection() + let token = try await interimHandle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - // Tier 0: OS sends isReconnecting=true → library emits .system with nil metadata. - let c3 = await changes.next() - 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) + let upgradedID = "ReliaBLE-Renamed-Lease" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + #expect(await manager.bluetooth.testWorkCount(for: upgradedID) == 1) - // No library ladder should have been armed — give the mock a beat to surface any - // further events; a library reconnect would land in connectionStates. - try? await Task.sleep(nanoseconds: 500_000_000) - let states = await manager.currentConnectionStates - let libraryActive = states.values.contains { state in - if case .reconnecting(.library, _, _) = state { return true } - return false - } - #expect(!libraryActive, "Expected no .library reconnect state, got \(states)") + // The token still names the interim id; the release must find the lease anyway. + await interimHandle.releaseWorkLease(token) + #expect(await manager.bluetooth.testWorkCount(for: upgradedID) == 0) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .disconnected(reason: nil) + }) - // Cleanup: explicit disconnect to cancel any pending reconnect state. - try? await handle.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) + await Mock.tearDown(manager) } - @Test func reconnectGivesUpAfterMaxAttempts() async throws { - let giveUpPolicy = ReconnectPolicy( - maxAttempts: 2, - initialDelay: 0.001, - maxDelay: 0.005, - jitter: 0 - ) - - Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + /// An AwaitingRadio projection (`attempt: nil`) is demand parked on a missing radio, not work in + /// flight. Nothing re-drives it on its own: it has no ladder task, and identity migration runs + /// before the advertisement that triggered it has bound a live reference — so if discovery also + /// treats `.reconnecting` as already-being-driven, a demanded link stays parked forever. + /// + /// This is the cold path after a radio outage: hold survives, references do not, and the first + /// advertisement back both rebinds the reference and drifts the id. + @Test func identityUpgradeReDrivesAwaitingRadioProjectionInsteadOfArmingLadder() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let manager = await Mock.makeManager(reconnectPolicy: giveUpPolicy) + // A long first rung keeps the library ladder from racing the discovery-driven re-drive. + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 5.0 + policy.jitter = 0.0 + let manager = await Mock.makeManager(reconnectPolicy: policy) await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(giveUpPolicy) - let changes = manager.connectionStateChanges - - await manager.startScanning() - let snap = await Mock.waitForDiscovered( + try await manager.startScanning() + _ = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).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 handle.connect() + // Rename first, so the advertisement that arrives after the outage drifts the id *back* and + // drives identity migration at a moment when no live reference exists. + let interimID = "ReliaBLE-Renamed-AwaitingRadio" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: interimID + ) + let heldHandle = manager.peripheral(id: interimID) + try await heldHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[interimID] == .connected + }) - let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) - let states = events.map { $0.state } + // Refuse connections for the duration of the outage, so the OS-level reconnect cannot quietly + // re-establish the link and answer the question this test is asking. + Mock.connectionTestDelegate.connectionResult = .failure(PeripheralError.notFound) + await Mock.simulateDisconnection() - #expect(states.count >= 6, "Expected at least 6 events, got \(states.count)") + // The radio drop clears every live reference and leaves demanded ids on AwaitingRadio. + await manager.bluetooth.testInvalidatePeripherals() + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, nil, nil) = await manager.currentConnectionStates[interimID] { + return true + } + return false + }) + #expect(await manager.bluetooth.testContainsCBPeripheral(interimID) == false) - // 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) + Mock.connectionTestDelegate.connectionResult = .success(()) - // 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 - } - } + // A real advertisement now rebinds the reference *and* drifts the id back. Migration runs + // before the binding, so only the discovery path can re-drive this — and it must, because + // AwaitingRadio is demand parked on a missing radio, not work already in flight. + try await manager.startScanning() - // 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)") + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(await manager.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID)) - // 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 handle.disconnect() - var cleanup = ReconnectPolicy() - cleanup.maxAttempts = 0 - await manager.bluetooth.setReconnectPolicy(cleanup) - try? await Task.sleep(nanoseconds: 200_000_000) + await manager.stopScanning() + await Mock.tearDown(manager) } - @Test func explicitDisconnectDoesNotReconnect() async throws { + /// An alias is only good while nothing else answers to the retired id. Once a device resolves to + /// that id again it is a live catalog entry, and a caller asking for it must reach that device — + /// not the one that vacated the name. + /// + /// A radio drop untracks a no-demand id, and the handle for it reverts to "not tracked". A handle + /// vended under a replaced id has to revert with it — two handles for one device disagreeing about + /// whether it is connected is the state the mirror exists to prevent. + @Test func radioDropClearsHandlesForReplacedIdsToo() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - - let changes = manager.connectionStateChanges + await manager.bluetooth.setIdleDisconnectInterval(0.1) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let staleHandle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() - - // Drain .connecting and .connected. - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + // Link it via a work lease, then release so the id carries no demand when the radio drops. + let token = try await staleHandle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - // Explicit disconnect. - try await handle.disconnect() + let upgradedID = "ReliaBLE-Renamed-RadioDrop" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + #expect(staleHandle.connectionState != nil) - let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) - let states = events.map { $0.state } + await staleHandle.releaseWorkLease(token) + #expect(await manager.bluetooth.testWorkCount(for: upgradedID) == 0) - // 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))") + await manager.bluetooth.testInvalidatePeripherals() - let hasReconnecting = states.contains { - if case .reconnecting = $0 { return true } - return false - } - #expect(!hasReconnecting, "Expected no .reconnecting events after explicit disconnect") + // No demand, so the id is untracked — and both handles have to say so. + #expect(await manager.currentConnectionStates[upgradedID] == nil) + #expect(manager.peripheral(id: upgradedID).connectionState == nil) + #expect(staleHandle.connectionState == nil) - // Cleanup: prevent further reconnect attempts from interfering with subsequent tests. - var cleanup = ReconnectPolicy() - cleanup.maxAttempts = 0 - await manager.bluetooth.setReconnectPolicy(cleanup) - try? await Task.sleep(nanoseconds: 200_000_000) + await Mock.tearDown(manager) } - @Test func transientConnectFailureArmsReconnect() async throws { - Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + /// The reclaim that matters is by a **second radio** the library holds no snapshot for: a device + /// that already has a row drifts through identity migration, which retires the alias on its own. + /// Snapshots are cleared here to produce exactly that condition. + /// + /// Routing is only half of it. The handle interned under the retired id has been mirroring the + /// upgraded device's state all along, so the reclaim has to re-point it too — otherwise a + /// discovery UI renders two connected devices for one live link. + @Test func aliasIsDroppedWhenASecondRadioClaimsTheRetiredId() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - let changes = manager.connectionStateChanges - - await manager.startScanning() - let snap = await Mock.waitForDiscovered( + // Two distinct simulated radios: the connectable one is upgraded and linked, the other later + // claims the id it vacated. + try await manager.startScanning() + _ = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + _ = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) await manager.stopScanning() - // Force a clean disconnection to reset any lingering mock state. - Mock.connectionTestSpec.simulateDisconnection() - try? await Task.sleep(nanoseconds: 100_000_000) + let retiredID = Mock.connectionTestPeripheralID + let upgradedID = "ReliaBLE-Renamed-Reclaim" + await manager.bluetooth.testRediscover(id: retiredID, advertisedLocalName: upgradedID) - try await handle.connect() + try await manager.peripheral(id: upgradedID).connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) - let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) - let states = events.map { $0.state } + // While the retired id is unclaimed it routes to — and mirrors — the upgraded device. + #expect(await manager.bluetooth.testCanonicalId(for: retiredID) == upgradedID) + #expect(manager.peripheral(id: retiredID).connectionState == .connected) - #expect(states.count >= 3, "Expected at least 3 events, got \(states.count)") + // Drop the snapshots so the next advertisement resolves as a device the library has no row + // for — the one route to the retired id that does not pass through identity migration. + await manager.bluetooth.testClearDiscoveredPeripherals() + await manager.bluetooth.testRediscover( + id: Mock.testPeripheralID, + advertisedLocalName: retiredID + ) - // 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) + // Routing follows the claimant, and the handle stops reporting the device that left. + #expect(await manager.bluetooth.testCanonicalId(for: retiredID) == retiredID) + #expect(manager.peripheral(id: retiredID).connectionState != .connected) - // Cleanup: cancel the pending reconnect task via explicit disconnect, then - // prevent further arming so no stray reconnect fires during subsequent tests. - try? await handle.disconnect() - var cleanup = ReconnectPolicy() - cleanup.maxAttempts = 0 - await manager.bluetooth.setReconnectPolicy(cleanup) - try? await Task.sleep(nanoseconds: 200_000_000) - } + // The upgraded device is untouched: still linked, still bound to exactly one id. + #expect(await manager.currentConnectionStates[upgradedID] == .connected) + #expect(await manager.bluetooth.testIdCount(boundToPeripheralFor: upgradedID) == 1) - /// 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()`. + await Mock.tearDown(manager) + } - @Test func autoReconnectFalseDoesNotArmLadder() async throws { + /// A genuine ladder step keeps its attempt number across the move, and `reconnectAttempts` has to + /// agree with the rescheduled step or `performReconnect` will not fire. + @Test func identityUpgradeReschedulesLiveLadderStepUnderUpgradedId() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 0.1 + policy.jitter = 0.0 + let manager = await Mock.makeManager(reconnectPolicy: policy) await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - - let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let interimHandle = try #require(snap).peripheral await manager.stopScanning() - // Connect with autoReconnect: false — the OS option is NOT passed, and the - // library ladder is NOT armed. - try await handle.connect(autoReconnect: false) + try await interimHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - // Drain .connecting and .connected. - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + // An unexpected drop arms the Tier-1 ladder. + await Mock.simulateDisconnection() + await manager.bluetooth.testInjectDisconnect(for: Mock.connectionTestPeripheralID, isReconnecting: false) + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, _, _) = await manager.currentConnectionStates[Mock.connectionTestPeripheralID] { + return true + } + return false + }) - // 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 manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + let upgradedID = "ReliaBLE-Renamed-Ladder" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) - let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) - let states = events.map { $0.state } + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) - guard case .disconnected = states.first else { - Issue.record("Expected .disconnected, got \(states)") - return - } + await Mock.tearDown(manager) + } - let hasReconnecting = states.contains { - if case .reconnecting = $0 { return true } - return false - } - #expect(!hasReconnecting, "Expected no .reconnecting events when autoReconnect is false") + /// A handle vended before an identity upgrade keeps reporting the device it was vended for: its + /// calls are routed to the current id and its ``Peripheral/connectionState`` mirrors that id's + /// state. The stream makes the opposite trade — the obsolete id gets one terminal and then goes + /// quiet, since a subscriber keyed by an id cannot be told the id moved. + @Test func preUpgradeHandleKeepsMirroringStateWhileStreamTerminatesOldId() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) - // Cleanup: explicit disconnect and prevent further reconnect attempts. - try? await handle.disconnect() - var cleanup = ReconnectPolicy() - cleanup.maxAttempts = 0 - await manager.bluetooth.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) + // A long first rung keeps the post-drop ladder state stationary while it is compared against + // the handle's mirrored copy. + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 5.0 + policy.jitter = 0.0 + let manager = await Mock.makeManager(reconnectPolicy: policy) await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - let changes = manager.connectionStateChanges + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + var changes = manager.connectionStateChanges.makeAsyncIterator() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let staleHandle = try #require(snap).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 handle.connect() + try await staleHandle.connect(autoReconnect: true) - // Drain .connecting and .connected. - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + // Drain up to `.connected` on the pre-upgrade id. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + while await changes.next()?.state != .connected {} - // Inject OS give-up: isReconnecting: false unexpected disconnect. - await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + let upgradedID = "ReliaBLE-Renamed-Mirror" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) - // 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 - } + // Stream: one terminal on the obsolete id, then the live state on the new one. + let terminal = await changes.next() + #expect(terminal?.peripheralId == Mock.connectionTestPeripheralID) + #expect(terminal?.state == .disconnected(reason: nil)) + + let moved = await changes.next() + #expect(moved?.peripheralId == upgradedID) + #expect(moved?.state == .connected) + + // Handle: not orphaned. On its own this is weak — the handle held `.connected` before the + // upgrade too, so a handle that had simply been abandoned would read the same. + #expect(staleHandle.id == Mock.connectionTestPeripheralID) + #expect(staleHandle.connectionState == .connected) + + // The load-bearing check: drive a transition to a state the handle has never held, with no + // app involvement, and require the handle to follow. Injected rather than simulated so the + // library ladder is observable instead of Tier-0 re-establishing the link. + await manager.bluetooth.testInjectDisconnect(for: upgradedID, isReconnecting: false) + var laddered: ConnectionState? + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, _, _) = await manager.currentConnectionStates[upgradedID] { + return true + } + return false + }) + laddered = await manager.currentConnectionStates[upgradedID] + #expect(laddered != nil) + #expect(staleHandle.connectionState == laddered) - // 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) + // Acting through the obsolete id still resolves, and the handle follows that transition too. + try await staleHandle.disconnect() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .disconnected(reason: nil) + }) + #expect(staleHandle.connectionState == .disconnected(reason: nil)) - // Cleanup: cancel the pending reconnect task via explicit disconnect. - try? await handle.disconnect() - var cleanup = ReconnectPolicy() - cleanup.maxAttempts = 0 - await manager.bluetooth.setReconnectPolicy(cleanup) - try? await Task.sleep(nanoseconds: 200_000_000) + await Mock.tearDown(manager) } - @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 - ) + /// A `Peripheral` handle's `id` is fixed when it is vended, so an app holding one from before an + /// identity upgrade still addresses the actor by the obsolete key. `disconnect()` on that handle + /// must still tear the link down rather than no-op against a hold filed under the new id. + @Test func disconnectViaPreUpgradeHandleStillTearsDownTheLink() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: slowPolicy) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(slowPolicy) - - let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let staleHandle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() + try await staleHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let upgradedID = "ReliaBLE-Renamed-StaleHandle" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + #expect(staleHandle.id == Mock.connectionTestPeripheralID) + #expect(await manager.bluetooth.testHasManualConnectHold(for: upgradedID)) - // Arm the library ladder, then cancel it mid-sleep with an explicit disconnect. - await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + // Disconnect through the obsolete id. + try await staleHandle.disconnect() - 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 - } + #expect(await manager.bluetooth.testHasManualConnectHold(for: upgradedID) == false) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .disconnected(reason: nil) + }) - 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) + await Mock.tearDown(manager) + } - try await handle.disconnect() + /// The upgrade can land while a connect is still in flight. Whichever way the two interleave, the + /// stack has to converge on the upgraded id with a single binding. + @Test func identityUpgradeRacingInFlightConnectConvergesOnUpgradedId() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) - let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 1_000_000_000) - let states = events.map(\.state) + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) - #expect(states.contains(.disconnecting)) - #expect(states.contains(.disconnected(reason: nil))) + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let interimHandle = try #require(snap).peripheral + await manager.stopScanning() - 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)") + // `connect()` returns once the connect is issued — before `didConnect` lands. + try await interimHandle.connect(autoReconnect: true) - await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) - try? await Task.sleep(nanoseconds: 200_000_000) + let upgradedID = "ReliaBLE-Renamed-InFlight" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) + #expect(await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == nil) + #expect(await manager.bluetooth.testIdCount(boundToPeripheralFor: upgradedID) == 1) + + await Mock.tearDown(manager) } - @Test func explicitDisconnectReportsCleanReasonDespiteUnderlyingError() async throws { + @Test func connectionStateChangesEmitsDisconnectSequence() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: ReconnectPolicy(maxAttempts: 0)) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) - let changes = manager.connectionStateChanges + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + var changes = manager.connectionStateChanges.makeAsyncIterator() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - await manager.startScanning() - let snap = await Mock.waitForDiscovered( + try await manager.startScanning() + let discovered = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let handle = try #require(snap).peripheral + let handle = try #require(discovered).peripheral await manager.stopScanning() try await handle.connect() - _ = 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 manager.bluetooth.testSeedIntentionalDisconnect(handle.id) - await manager.bluetooth.testInjectDisconnect( - for: handle.id, - isReconnecting: false, - error: NSError(domain: "test.explicit", code: 1) - ) + // Drain .connecting and .connected. + _ = await changes.next() + _ = await changes.next() - 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))" - ) + try await handle.disconnect() - await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) - try? await Task.sleep(nanoseconds: 200_000_000) - } + let disconnecting = await changes.next() + #expect(disconnecting?.peripheralId == handle.id) + #expect(disconnecting?.state == .disconnecting) - @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 - ) + let disconnected = await changes.next() + #expect(disconnected?.peripheralId == handle.id) + #expect(disconnected?.state == .disconnected(reason: nil)) + } - Mock.connectionTestDelegate.connectionResult = .success(()) + @Test func connectionStateChangesEmitsConnectFailureSequence() async throws { + // Pre-condition: no stale connection state from a preceding lifecycle test. + await Mock.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let manager = await Mock.makeManager(reconnectPolicy: hostilePolicy) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(hostilePolicy) - let changes = manager.connectionStateChanges + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + var changes = manager.connectionStateChanges.makeAsyncIterator() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1342,36 +1638,40 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected + // Force a clean disconnection on the spec to reset any lingering + // `virtualConnections` / `isConnected` state left by a preceding test. + await Mock.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) - // Unexpected drop arms the library ladder; scheduling must survive the non-finite delay. - await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + // Configure failure only after discovery — a failed connectionResult can + // interfere with mock advertising while the previous stack tears down. + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) - _ = 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 handle.connect() - try await handle.disconnect() - await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) - try? await Task.sleep(nanoseconds: 200_000_000) + let connecting = await changes.next() + let failed = await changes.next() + + #expect(connecting?.peripheralId == handle.id) + #expect(connecting?.state == .connecting) + #expect(failed?.peripheralId == handle.id) + #expect(failed?.state == .failed(reason: .connectionTimeout)) } - @Test func successfulLibraryReconnectResetsAttemptCounter() async throws { + @Test func connectionStateChangesSupportsConcurrentSubscribers() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - let changes = manager.connectionStateChanges + var subscriberA = manager.connectionStateChanges.makeAsyncIterator() + var subscriberB = manager.connectionStateChanges.makeAsyncIterator() + + // Force an actor hop to guarantee the registration Tasks have completed + // before we issue the connect (connectionStateChanges has no replay). + _ = await manager.currentConnectionStates - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1382,57 +1682,88 @@ struct ReliaBLEManagerTests { try await handle.connect() - _ = 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 manager.bluetooth.testInjectDisconnect(for: handle.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) + // Both subscribers see the .connecting event. + let a1 = await subscriberA.next() + let b1 = await subscriberB.next() + #expect(a1?.state == .connecting) + #expect(b1?.state == .connecting) - let reconnectConnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - #expect(reconnectConnecting?.state == .connecting) + // Both subscribers see the .connected event. + let a2 = await subscriberA.next() + let b2 = await subscriberB.next() + #expect(a2?.state == .connected) + #expect(b2?.state == .connected) + } - let reconnectConnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - #expect(reconnectConnected?.state == .connected) + // MARK: - Reconnection - // Second unexpected drop must start a fresh ladder at attempt 1, not continue at 2. - await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + /// 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 + ) - _ = 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))") + @Test func systemReconnectOnUnexpectedDrop() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) + + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + var changes = manager.connectionStateChanges.makeAsyncIterator() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + + // Drain .connecting and .connected. + let c1 = await changes.next() + #expect(c1?.state == .connecting) + let c2 = await changes.next() + #expect(c2?.state == .connected) + + // Simulate an unexpected disconnect with the OS auto-reconnect option active. + await Mock.simulateDisconnection() + + // Tier 0: OS sends isReconnecting=true → library emits .system with nil metadata. + let c3 = await changes.next() + guard case .reconnecting(let source, let attempt, let nextRetryAt) = c3?.state else { + Issue.record("Expected .reconnecting, got \(String(describing: c3?.state))") return } - #expect(secondAttempt == 1) + #expect(source == .system) + #expect(attempt == nil) + #expect(nextRetryAt == nil) + + // No library ladder should have been armed — give the mock a beat to surface any + // further events; a library reconnect would land in connectionStates. + try? await Task.sleep(nanoseconds: 500_000_000) + let states = await manager.currentConnectionStates + let libraryActive = states.values.contains { state in + if case .reconnecting(.library, _, _) = state { return true } + return false + } + #expect(!libraryActive, "Expected no .library reconnect state, got \(states)") + // Cleanup: explicit disconnect to cancel any pending reconnect state. try? await handle.disconnect() - await manager.bluetooth.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 manager.bluetooth.testSeedIntentionalDisconnect(id) - #expect(await manager.bluetooth.testContainsIntentionalDisconnect(id)) - - await manager.bluetooth.testInvalidatePeripherals() - #expect(!(await manager.bluetooth.testContainsIntentionalDisconnect(id))) - } - - @Test func giveUpThenUnexpectedDropRearmsFreshLadder() async throws { + @Test func reconnectGivesUpAfterMaxAttempts() async throws { let giveUpPolicy = ReconnectPolicy( - maxAttempts: 1, + maxAttempts: 2, initialDelay: 0.001, maxDelay: 0.005, jitter: 0 @@ -1445,11 +1776,9 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) await manager.bluetooth.setReconnectPolicy(giveUpPolicy) - var changes = manager.connectionStateChanges.makeAsyncIterator() - // Force an actor hop so registration completes before we connect. - _ = await manager.currentConnectionStates + let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1458,382 +1787,2804 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - Mock.connectionTestSpec.simulateDisconnection() + // Force a clean disconnection to reset any lingering mock state. + await Mock.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) try await handle.connect() - // Exhaust the single-attempt ladder: - // connecting → failed → reconnecting(1) → connecting → failed (give-up). - let s0 = await changes.next() - #expect(s0?.state == .connecting) + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) + let states = events.map { $0.state } - let s1 = await changes.next() - guard case .failed = s1?.state else { - Issue.record("Expected .failed at index 1, got \(String(describing: s1?.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 } - - 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))") + 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) - - 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))") + #expect(states[3] == .connecting) + guard case .failed = states[4] else { + Issue.record("Expected .failed at index 4, got \(String(describing: states[4]))") return } - - // Intent survives give-up: a later unexpected drop must arm a fresh ladder at attempt 1. - await manager.bluetooth.testInjectDisconnect(for: handle.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))") + 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) - 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 + // 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 + } } - #expect(a2 == 1) + // 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 handle.disconnect() - await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await manager.bluetooth.setReconnectPolicy(cleanup) try? await Task.sleep(nanoseconds: 200_000_000) } - // MARK: - State Restoration - - @Test func reliaBLEConfigRestoreIdentifierDefaultsToNil() { - let config = ReliaBLEConfig() - #expect(config.restoreIdentifier == nil) + @Test func explicitDisconnectDoesNotReconnect() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) - var custom = ReliaBLEConfig() - custom.restoreIdentifier = "com.example.ble-central" - #expect(custom.restoreIdentifier == "com.example.ble-central") - } + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - @Test func ensureInitializedWithoutRestoreIdentifierDoesNotCreateCentralWhenUnauthorized() async throws { - // Default makeManager pins .notDetermined before construction. A restoreIdentifier of nil - // must preserve the lazy contract: no central until authorize / allowedAlways. - CBMCentralManagerMock.simulateAuthorization(.notDetermined) + let changes = manager.connectionStateChanges - let manager = await Mock.makeManager(restoreIdentifier: nil) - try? await Task.sleep(nanoseconds: 200_000_000) + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() - #expect(!(await manager.bluetooth.hasCentralManager)) - // Config default remains nil on the value type regardless of actor lifetime. - #expect(ReliaBLEConfig().restoreIdentifier == nil) - } + try await handle.connect() - @Test func ensureInitializedWithRestoreIdentifierCreatesCentralWhenAuthorized() async throws { - let restoreId = "com.five3apps.relia-ble.tests.restore" + // Drain .connecting and .connected. + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - // When authorized, a restoreIdentifier does not relax the auth gate — it only adds the - // restore-id option to the existing creation path. - CBMCentralManagerMock.simulateAuthorization(.allowedAlways) - CBMCentralManagerMock.simulatePowerOn() + // Explicit disconnect. + try await handle.disconnect() - let manager = await Mock.makeManager(restoreIdentifier: restoreId) - #expect(await manager.bluetooth.testRestoreIdentifier() == restoreId) + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) + let states = events.map { $0.state } - let optionKeys = await manager.bluetooth.testCentralCreationOptionKeys() - #expect(optionKeys.contains(CBMCentralManagerOptionRestoreIdentifierKey)) + // 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))") - await Mock.ensureReady(manager) - #expect(await manager.bluetooth.hasCentralManager) - // Restore-id option and restoring peer shim are installed together (never disagree). - #expect(await manager.bluetooth.testDelegateIsRestoringShim()) - #expect(!(await manager.bluetooth.testDelegateIsNonRestoringShim())) + let hasReconnecting = states.contains { + if case .reconnecting = $0 { return true } + return false + } + #expect(!hasReconnecting, "Expected no .reconnecting events after explicit disconnect") - let noRestore = await Mock.makeManager(restoreIdentifier: nil) - #expect(await noRestore.bluetooth.testCentralCreationOptionKeys().isEmpty) - await Mock.ensureReady(noRestore) - #expect(await noRestore.bluetooth.hasCentralManager) - #expect(await noRestore.bluetooth.testDelegateIsNonRestoringShim()) - #expect(!(await noRestore.bluetooth.testDelegateIsRestoringShim())) + // Cleanup: prevent further reconnect attempts from interfering with subsequent tests. + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await manager.bluetooth.setReconnectPolicy(cleanup) + try? await Task.sleep(nanoseconds: 200_000_000) } - @Test func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { - Mock.connectionTestDelegate.connectionResult = .success(()) - defer { - Mock.connectionTestDelegate.connectionResult = .success(()) - Mock.clearStateRestoration() - } + @Test func transientConnectFailureArmsReconnect() async throws { + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let restoreId = "com.five3apps.relia-ble.tests.restore-broadcasts" - let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) - await Mock.ensureReady(manager1) - await manager1.bluetooth.testClearPersistedReconnectIntent() + let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) + await Mock.ensureReady(manager) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges - await manager1.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, - on: manager1, + on: manager, withinNanoseconds: 3_000_000_000 ) let handle = try #require(snap).peripheral - await manager1.stopScanning() + await manager.stopScanning() + + // Force a clean disconnection to reset any lingering mock state. + await Mock.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) try await handle.connect() - _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[handle.id] == .connected + + 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 handle.disconnect() + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // Connect with autoReconnect: false — the OS option is NOT passed, and the + // library ladder is NOT armed. + try await handle.connect(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 manager.bluetooth.testInjectDisconnect(for: handle.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 handle.disconnect() + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).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 handle.connect() + + // 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 manager.bluetooth.testInjectDisconnect(for: handle.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 handle.disconnect() + var cleanup = ReconnectPolicy() + cleanup.maxAttempts = 0 + await manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(slowPolicy) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + + _ = 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 manager.bluetooth.testInjectDisconnect(for: handle.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 handle.disconnect() + + 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 manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = 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 manager.bluetooth.testSeedIntentionalDisconnect(handle.id) + await manager.bluetooth.testInjectDisconnect( + for: handle.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 manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(hostilePolicy) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = 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 manager.bluetooth.testInjectDisconnect(for: handle.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 handle.disconnect() + await manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + + _ = 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 manager.bluetooth.testInjectDisconnect(for: handle.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 manager.bluetooth.testInjectDisconnect(for: handle.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 handle.disconnect() + await manager.bluetooth.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 manager.bluetooth.testSeedIntentionalDisconnect(id) + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(id)) + + await manager.bluetooth.testInvalidatePeripherals() + #expect(!(await manager.bluetooth.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 manager.bluetooth.setReconnectPolicy(giveUpPolicy) + + var changes = manager.connectionStateChanges.makeAsyncIterator() + // Force an actor hop so registration completes before we connect. + _ = await manager.currentConnectionStates + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + await Mock.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) + + try await handle.connect() + + // 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 manager.bluetooth.testInjectDisconnect(for: handle.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 handle.disconnect() + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + try? await Task.sleep(nanoseconds: 200_000_000) + } + + // MARK: - State Restoration + + @Test func reliaBLEConfigRestoreIdentifierDefaultsToNil() { + let config = ReliaBLEConfig() + #expect(config.restoreIdentifier == nil) + + var custom = ReliaBLEConfig() + custom.restoreIdentifier = "com.example.ble-central" + #expect(custom.restoreIdentifier == "com.example.ble-central") + } + + @Test func ensureInitializedWithoutRestoreIdentifierDoesNotCreateCentralWhenUnauthorized() async throws { + // Default makeManager pins .notDetermined before construction. A restoreIdentifier of nil + // must preserve the lazy contract: no central until authorize / allowedAlways. + await Mock.simulateAuthorization(.notDetermined) + + let manager = await Mock.makeManager(restoreIdentifier: nil) + try? await Task.sleep(nanoseconds: 200_000_000) + + #expect(!(await manager.bluetooth.hasCentralManager)) + // Config default remains nil on the value type regardless of actor lifetime. + #expect(ReliaBLEConfig().restoreIdentifier == nil) + } + + @Test func ensureInitializedWithRestoreIdentifierCreatesCentralWhenAuthorized() async throws { + let restoreId = "com.five3apps.relia-ble.tests.restore" + + // When authorized, a restoreIdentifier does not relax the auth gate — it only adds the + // restore-id option to the existing creation path. + await Mock.simulateAuthorization(.allowedAlways) + await Mock.simulatePowerOn() + + let manager = await Mock.makeManager(restoreIdentifier: restoreId) + #expect(await manager.bluetooth.testRestoreIdentifier() == restoreId) + + let optionKeys = await manager.bluetooth.testCentralCreationOptionKeys() + #expect(optionKeys.contains(CBMCentralManagerOptionRestoreIdentifierKey)) + + await Mock.ensureReady(manager) + #expect(await manager.bluetooth.hasCentralManager) + // Restore-id option and restoring peer shim are installed together (never disagree). + #expect(await manager.bluetooth.testDelegateIsRestoringShim()) + #expect(!(await manager.bluetooth.testDelegateIsNonRestoringShim())) + + let noRestore = await Mock.makeManager(restoreIdentifier: nil) + #expect(await noRestore.bluetooth.testCentralCreationOptionKeys().isEmpty) + await Mock.ensureReady(noRestore) + #expect(await noRestore.bluetooth.hasCentralManager) + #expect(await noRestore.bluetooth.testDelegateIsNonRestoringShim()) + #expect(!(await noRestore.bluetooth.testDelegateIsRestoringShim())) + } + + @Test @MainActor func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } + + let restoreId = "com.five3apps.relia-ble.tests.restore-broadcasts" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() + + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager1, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { + await manager1.currentConnectionStates[handle.id] == .connected + } + #expect(await manager1.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) + + // Cold relaunch: shut down stack 1. Central deinit may zero virtualConnections, so + // re-mark the spec connected before install — persisted intent survives in UserDefaults. + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + + let scanUUID = CBMUUID(string: "180D") + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], + scanServices: [scanUUID] + ) + + CBMCentralManagerMock.simulateAuthorization(.notDetermined) + let manager2 = await Mock.makeManager(restoreIdentifier: restoreId) + let baseConn = await manager2.bluetooth.testConnectionStateSubscriberCount() + let basePeriph = await manager2.bluetooth.testPeripheralsSubscriberCount() + var peripherals = manager2.discoveredPeripherals.makeAsyncIterator() + var connectionChanges = manager2.connectionStateChanges.makeAsyncIterator() + let subscriptionsReady = await pollUntil(timeout: 3.0) { + let connReady = await manager2.bluetooth.testConnectionStateSubscriberCount() > baseConn + let periphReady = await manager2.bluetooth.testPeripheralsSubscriberCount() > basePeriph + return connReady && periphReady + } + #expect(subscriptionsReady) + + CBMCentralManagerMock.simulateAuthorization(.allowedAlways) + CBMCentralManagerMock.simulatePowerOn() + try await manager2.authorizeBluetooth() + + let connectionSeeded = await pollUntil(timeout: 3.0) { + let state = await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] + return state == .connected || state == .connecting + } + #expect(connectionSeeded) + // Mock may restore as .connecting when virtualConnections was cleared by central deinit; + // simulateConnection before install prefers .connected. Either way maps rehydrate. + #expect(await manager2.bluetooth.testContainsCBPeripheral(Mock.connectionTestPeripheralID)) + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + + let restoredList = await manager2.bluetooth.discoveredPeripherals + #expect(restoredList.contains(where: { $0.id == Mock.connectionTestPeripheralID })) + + // Restored peripherals are kept off the advertisement feed; discoveredPeripherals replays. + let peripheralsEvent = await peripherals.next() + #expect(peripheralsEvent?.contains(where: { $0.id == Mock.connectionTestPeripheralID }) == true) + + let connectionEvent = await connectionChanges.next() + #expect(connectionEvent?.peripheralId == Mock.connectionTestPeripheralID) + #expect( + connectionEvent?.state == .connected + || connectionEvent?.state == .connecting + ) + + let scanSettled = await pollUntil(timeout: 3.0) { + let scanning = await manager2.bluetooth.testIsScanning() + let pending = await manager2.bluetooth.testPendingRestoredScanServices() + return scanning && pending == nil + } + #expect(scanSettled) + + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test @MainActor func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { + // D-restore case 1: a restored connected/connecting peripheral whose manual-connect hold + // (autoReconnect: true, reconnectDesired = true) was persisted rehydrates that hold — which + // supplies demand, re-arms reconnect intent (so the link comes back armed), and suppresses + // idle. Disconnected-peripheral seeding is a direct-handler unit test (item 4) — iOS never + // restores disconnected peripherals, and the mock always restores specs as + // connected/connecting based on virtualConnections. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } + + let restoreId = "com.five3apps.relia-ble.tests.restore-seeding" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() + + try await manager1.startScanning() + let connectionPeripheral = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager1, + withinNanoseconds: 3_000_000_000 + ) + let connected = try #require(connectionPeripheral).peripheral + await manager1.stopScanning() + + try await connected.connect() + _ = await pollUntil(timeout: 3.0) { + await manager1.currentConnectionStates[connected.id] == .connected + } + // connect() with the default autoReconnect: true persisted a hold { id: true }. + #expect(await manager1.bluetooth.testPersistedManualConnectHolds()[connected.id] == true) + + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], + scanServices: nil + ) + + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + // The rehydrated hold re-armed reconnect intent — the link is genuinely able to be + // re-linked, not merely a residual OS connection. And because the hold suppresses idle, + // the restored link stays up (case 1). + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + #expect(await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID)) + + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test @MainActor func willRestoreDoesNotRearmReconnectWithoutPersistedIntent() async throws { + // D-restore case 2 (no persisted hold): a restored link the app never explicitly requested + // must NOT come back armed. Reconnect intent is demand-derived (from a rehydrated hold), + // so with no persisted hold there is no demand and no re-arming; the link is seeded and an + // idle timer starts. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } + + let restoreId = "com.five3apps.relia-ble.tests.restore-no-intent" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() + + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager1, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { + await manager1.currentConnectionStates[handle.id] == .connected + } + // Wipe the durable hold before relaunch: the OS will still restore the residual connection, + // but there is no persisted intent — exactly the "restored without an explicit ask" case. + await manager1.bluetooth.testClearPersistedReconnectIntent() + #expect(await manager1.bluetooth.testPersistedManualConnectHolds().isEmpty) + + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], + scanServices: nil + ) + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + // No hold was persisted, so nothing re-arms the link. + #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) + #expect(!(await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID))) + + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test func willRestoreIgnoresEmptyScanServiceFilter() async throws { + // Direct-handler unit test: CoreBluetoothMock treats a non-nil (even empty) scan-services + // array as `isScanning = true` at restore-init, so the faithful fixture cannot express + // "empty filter ignored" without fighting the mock. Production still ignores empty filters. + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.bluetooth.testHandleWillRestoreState(scanServices: []) + + #expect(!(await manager.bluetooth.testIsScanning())) + #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) + } + + @Test @MainActor func invalidatePreservesHoldsProjectsReconnectingAndKeepsDeferredScan() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } + + let restoreId = "com.five3apps.relia-ble.tests.restore-invalidate" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() + + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager1, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { + await manager1.currentConnectionStates[handle.id] == .connected + } + + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], + scanServices: nil + ) + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + + #expect(await pollUntil(timeout: 3.0) { + await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID) + }) + + // Direct-handler: stash a pending restored scan, then invalidate (faithful second restore + // while powered off is awkward because mock forces isScanning at restore-init). + let scanUUID = CBUUID(string: "180D") + CBMCentralManagerMock.simulatePowerOff() + #expect(await Mock.waitForState("Powered Off", on: manager2)) + await manager2.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) + #expect(await manager2.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + + await manager2.bluetooth.testInvalidatePeripherals() + // D-1 event 13: the deferred restored scan is **preserved** across the invalidate. + #expect(await manager2.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + // Demand survives a radio outage: the rehydrated hold still wants reconnect, so the id stays + // armed and is projected into `AwaitingRadio` rather than cleared to nil. + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + if case .reconnecting(.library, nil, nil) = await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] { + // Expected AwaitingRadio projection. + } else { + Issue.record("Expected reconnecting(.library, nil, nil), got \(String(describing: await manager2.currentConnectionStates[Mock.connectionTestPeripheralID]))") + } + // invalidate must **never** write to disk; the persisted hold is untouched. + #expect(await manager2.bluetooth.testPersistedManualConnectHolds()[Mock.connectionTestPeripheralID] == true) + + CBMCentralManagerMock.simulatePowerOn() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test func invalidateHandleIsNotStaleConnectedAndKeepsMetadata() async throws { + // The two halves of a radio reset pull in opposite directions, and the handle must honor both: + // last-known metadata survives (it is still the best thing known about the device), while a stale + // `.connected` must NOT survive — a handle stuck reporting `.connected` after the library tore the + // connection down is not stale, it is false. A wanted reconnect id is honestly re-projected to + // `.reconnecting(.library, nil, nil)` (AwaitingRadio) rather than left claiming connected. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + + let priorName = handle.name + let priorRSSI = try #require(handle.rssi) + + await manager.bluetooth.testInvalidatePeripherals() + + #expect(handle.connectionState != .connected) + #expect(handle.connectionState == .reconnecting(source: .library, attempt: nil, nextRetryAt: nil)) + #expect(await manager.currentConnectionStates[handle.id] != .connected) + #expect(handle.name == priorName) + #expect(handle.rssi == priorRSSI) + } + + @Test func invalidatePeripheralsEmitsTerminalConnectionStateChange() async throws { + // Non-terminal path: clearing a live/in-progress link is the transition a stream-only UI + // cannot infer. Without `.disconnected(.bluetoothUnavailable)`, it would stick on + // `.connected` after a radio reset. Already-terminal ids are covered by + // `invalidateSkipsBluetoothUnavailableWhenAlreadyTerminal`. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + + // Subscribe before invalidating — `connectionStateChanges` has no replay, so a stream created afterwards + // would miss the very event under test, and creating one only *enqueues* registration. + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + let changes = manager.connectionStateChanges + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + + let id = handle.id + let collector = Task { () -> ConnectionStateChange? in + for await change in changes + where change.peripheralId == id && change.state == .disconnected(reason: .bluetoothUnavailable) { + return change + } + return nil + } + + await manager.bluetooth.testInvalidatePeripherals() + + // Bound the wait: a regression that drops the emit must fail this test, not hang the suite. + let watchdog = Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + collector.cancel() + } + let terminal = await collector.value + watchdog.cancel() + + #expect(terminal?.state == .disconnected(reason: .bluetoothUnavailable)) + // The event describes the transition; the handle re-projects to AwaitingRadio because this + // id still wants reconnect (a hold is held), so it is not `.connected` and not `nil`. + #expect(handle.connectionState != .connected) + #expect(handle.connectionState == .reconnecting(source: .library, attempt: nil, nextRetryAt: nil)) + } + + @Test func invalidateSkipsBluetoothUnavailableWhenAlreadyTerminal() async throws { + // Invalidate must not rewrite a settled `.disconnected` / `.failed` into + // `.bluetoothUnavailable`. Intentional disconnect then radio death is the HW case that + // was re-tagging a clean "Disconnected" caption as an orange radio fault with no live link. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // autoReconnect false: no Tier-0 option / ladder bookkeeping on a quiet disconnect path. + try await handle.connect(autoReconnect: false) + #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + + try await handle.disconnect() + #expect(await pollUntil(timeout: 3.0) { + if case .disconnected(reason: nil) = handle.connectionState { return true } + return false + }) + + // Subscribe only after the clean disconnect so the collector sees invalidate-era events alone. + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + let changes = manager.connectionStateChanges + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + + let id = handle.id + let collector = Task { () -> [ConnectionState] in + var states: [ConnectionState] = [] + for await change in changes where change.peripheralId == id { + states.append(change.state) + } + return states + } + + await manager.bluetooth.testInvalidatePeripherals() + + // A rewrite to `.bluetoothUnavailable` is synchronous on the actor; a short window is enough + // to observe a regression without holding an open stream for a multi-second drain (which + // stressed CoreBluetoothMock advertisement timers and correlated with CI SIGSEGV). + try? await Task.sleep(nanoseconds: 250_000_000) + collector.cancel() + let states: [ConnectionState] + switch await collector.result { + case .success(let collected): states = collected + case .failure: states = [] + } + + #expect(!states.contains(.disconnected(reason: .bluetoothUnavailable))) + #expect(!states.contains { if case .reconnecting = $0 { return true }; return false }) + // No demand after intentional disconnect → untracked (handle nil); stream stays quiet. + #expect(handle.connectionState == nil) + + // Force the mock peripheral fully idle so the next test's power-cycle starts clean. + await Mock.simulateDisconnection() + } + + @Test func willRestoreDefersScanUntilPoweredOn() async throws { + // Direct-handler unit test: CoreBluetoothMock sets `isScanning = true` synchronously + // inside central init when scan services are restored, so a faithful cold-relaunch cannot + // observe a deferred pending filter. Exercise our handler's powered-off deferral directly. + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulatePowerOff() + #expect(await Mock.waitForState("Powered Off", on: manager)) + + let scanUUID = CBUUID(string: "180D") + await manager.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) + + #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + #expect(!(await manager.bluetooth.testIsScanning())) + + await Mock.simulatePowerOn() + let becameScanning = await Mock.waitForState("Scanning", on: manager) + if !becameScanning { + #expect(await Mock.waitForState("Ready", on: manager)) + } + + let resumed = await pollUntil(timeout: 3.0) { + let pending = await manager.bluetooth.testPendingRestoredScanServices() + let scanning = await manager.bluetooth.testIsScanning() + return pending == nil && scanning + } + #expect(resumed) + #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) + #expect(await manager.bluetooth.testIsScanning()) + + await manager.stopScanning() + } + + @Test func willRestoreDisconnectedPeripheralSeedsNothing() async throws { + // Direct-handler: iOS never restores disconnected peripherals; this only exercises our + // defensive `.disconnected` switch (no connectionStates / reconnectEnabled seeding). + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + #expect(await manager.currentConnectionStates[handle.id] == nil) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) + + await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [handle.id]) + + #expect(await manager.currentConnectionStates[handle.id] == nil) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) + // Live reference remains registered from discovery. + #expect(await manager.bluetooth.testContainsCBPeripheral(handle.id)) + } + + @Test func restorePathInternsSameHandle() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + // Pre-create the handle — before any discovery or restore. + let handle = manager.peripheral(id: Mock.testPeripheralID) + #expect(handle.cbIdentifier == nil) + + // First discover so live refs and prior metadata exist. + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(snap) + await manager.stopScanning() + let priorRSSI = discovered.rssi + let priorAd = discovered.advertisement + #expect(handle.rssi != nil) + + // Drive restore via the test hook — this re-binds the live CBPeripheral. + await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [Mock.testPeripheralID]) + + // The same handle instance received cbIdentifier metadata from restore. + #expect(handle.cbIdentifier != nil) + #expect(await manager.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + + // Regression guard for the shared-helper merge rule: restoration carries no advertisement payload and no + // RSSI, so it must KEEP the values the earlier discovery established rather than wiping them. `#require` + // rather than `if let` — if discovery stopped producing these, the guard would silently pass and stop + // protecting anything. + let requiredRSSI = try #require(priorRSSI) + let requiredAd = try #require(priorAd) + #expect(handle.rssi == requiredRSSI) + #expect(handle.advertisement == requiredAd) + } + + // MARK: - Multi-Manager Isolation + + @Test func twoManagersWithDistinctRestoreIdsHaveIndependentState() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let restoreA = "com.five3apps.relia-ble.tests.iso-a" + let restoreB = "com.five3apps.relia-ble.tests.iso-b" + + let managerA = await Mock.makeManager(restoreIdentifier: restoreA, tearDownPrevious: true) + await Mock.ensureReady(managerA) + + // Second stack stays live alongside the first — validates instance isolation end-to-end. + let managerB = await Mock.makeManager(restoreIdentifier: restoreB, tearDownPrevious: false) + await Mock.ensureReady(managerB) + + #expect(await managerA.bluetooth.hasCentralManager) + #expect(await managerB.bluetooth.hasCentralManager) + #expect(await managerA.bluetooth.testRestoreIdentifier() == restoreA) + #expect(await managerB.bluetooth.testRestoreIdentifier() == restoreB) + + // A discovers while B is idle — B's discovered list must stay empty. + try await managerA.startScanning() + let discoveredOnA = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: managerA, + withinNanoseconds: 3_000_000_000 + ) + #expect(discoveredOnA != nil) + #expect(await managerA.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + #expect(await managerB.bluetooth.discoveredPeripherals.isEmpty) + #expect(!(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID))) + await managerA.stopScanning() + + // B discovers independently into its own maps. + try await managerB.startScanning() + let discoveredOnB = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: managerB, + withinNanoseconds: 3_000_000_000 + ) + #expect(discoveredOnB != nil) + #expect(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + await managerB.stopScanning() + + // Connect only on A; B must not observe connection state for that peripheral. + try await managerA.startScanning() + let connectableA = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: managerA, + withinNanoseconds: 3_000_000_000 + ) + let peripheralA = try #require(connectableA).peripheral + await managerA.stopScanning() + + try await peripheralA.connect() + #expect(await pollUntil(timeout: 3.0) { + await managerA.currentConnectionStates[peripheralA.id] == .connected + }) + #expect(await managerB.currentConnectionStates[peripheralA.id] == nil) + #expect(await managerB.bluetooth.testIsReconnectEnabled(peripheralA.id) == false) + + // Both stacks still alive after the cross-manager exercise. + #expect(await managerA.bluetooth.hasCentralManager) + #expect(await managerB.bluetooth.hasCentralManager) + + await Mock.tearDown(managerA) + await Mock.tearDown(managerB) + } + + @Test func authorizeCancellationDoesNotAffectOtherManager() async throws { + await Mock.simulateAuthorization(.notDetermined) + + let managerA = await Mock.makeManager(tearDownPrevious: true) + let managerB = await Mock.makeManager(tearDownPrevious: false) + + let taskA = Task { try await managerA.authorizeBluetooth() } + let taskB = Task { try await managerB.authorizeBluetooth() } + + // Both should be suspended on the undetermined decision. + try? await Task.sleep(nanoseconds: 150_000_000) + taskA.cancel() + let resultA = await taskA.result + switch resultA { + case .failure(let error): + #expect(error is CancellationError) + case .success: + // Already resolved if mock auth flipped early — still must not break B. + break + } + + // Cancelling A must leave B's waiter intact — grant auth and bounce power so B's + // central receives didUpdateState and resolvePendingAuthorization runs. + await Mock.simulateAuthorization(.allowedAlways) + await Mock.simulatePowerOff() + await Mock.simulatePowerOn() + + try await taskB.value + #expect(await managerB.bluetooth.hasCentralManager) + + await Mock.tearDown(managerA) + await Mock.tearDown(managerB) + } + + @Test func twoManagersIndependentHandleRegistries() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let managerA = await Mock.makeManager(tearDownPrevious: true) + await Mock.ensureReady(managerA) + + let managerB = await Mock.makeManager(tearDownPrevious: false) + await Mock.ensureReady(managerB) + + // Same id string → two distinct handle instances. + let handleA = managerA.peripheral(id: Mock.testPeripheralID) + let handleB = managerB.peripheral(id: Mock.testPeripheralID) + #expect(handleA !== handleB) + #expect(handleA == handleB) + #expect(handleA.hashValue == handleB.hashValue) + + let set: Set = [handleA, handleB] + #expect(set.count == 1, "id-only equality means same-id handles from different managers count as one in a Set") + + // Discover only on A. + try await managerA.startScanning() + _ = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: managerA, + withinNanoseconds: 3_000_000_000 + ) + await managerA.stopScanning() + + #expect(await managerA.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + #expect(!(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID))) + + // Only A's handle has live metadata. + #expect(handleA.rssi != nil) + #expect(handleB.rssi == nil) + + await Mock.tearDown(managerA) + await Mock.tearDown(managerB) + } + + // MARK: - Event Stream Broadcaster + + @Test func stateStreamReplaysToConcurrentSubscribers() async throws { + let manager = await Mock.makeManager() + + // Two independent streams from two separate property accesses. + var subscriberA = manager.state.makeAsyncIterator() + var subscriberB = manager.state.makeAsyncIterator() + + // Each subscriber replays the current state as its first element. A shared single stream + // could not replay to both, so independent replay proves each access mints a distinct stream. + let replayA = await subscriberA.next() + let replayB = await subscriberB.next() + + #expect(replayA != nil) + #expect(replayB != nil) + } + + @Test func stateBroadcastReachesAllSubscribers() async throws { + let manager = await Mock.makeManager() + + var subscriberA = manager.state.makeAsyncIterator() + var subscriberB = manager.state.makeAsyncIterator() + + // Drain the replayed element. Awaiting it also guarantees both continuations are registered + // (the replay is yielded during registration), so the broadcast below cannot be missed. + _ = await subscriberA.next() + _ = await subscriberB.next() + + // Force a state broadcast through the real actor path; both live subscribers receive it. + await manager.bluetooth.updateState() + + let broadcastA = await subscriberA.next() + let broadcastB = await subscriberB.next() + + #expect(broadcastA != nil) + #expect(broadcastB != nil) + } + + @Test func handleOrphansWhenManagerDeallocates() async throws { + // This test is the retain-graph leak detector: if anything reachable from the + // actor holds the manager strongly, the manager won't deallocate and this fails. + + let orphanedHandle: Peripheral = await Task { + let manager = await Mock.makeManager(tearDownPrevious: true) + let handle = manager.peripheral(id: "orphaned-by-deinit") + // Shut down the actor so every stream subscription ends — a live subscriber retains the actor by + // design, and the actor is the thing that would drag the manager along if the retain graph were wrong. + await manager.bluetooth.shutdown() + // Drop the harness's own strong reference; otherwise `activeManager` alone keeps the manager alive and + // this test would silently prove nothing. + Mock.releaseActiveManager() + + return handle + }.value + + // The manager has now fallen out of every scope that held it. If it deallocated, the handle's weak manager + // reference is nil and `connect()` reports `.bluetoothUnavailable`. Anything else — notably `.notFound`, + // which means the manager is somehow still alive and reachable — indicates something reachable from the + // actor is retaining the manager strongly, which is the leak this test exists to catch. + do { + try await orphanedHandle.connect() + Issue.record("Expected connect() on orphaned handle to throw") + } catch let error as PeripheralError { + #expect(error == .bluetoothUnavailable) + } + } + + // MARK: - Work-Driven Demand Substrate + + @Test func workLeaseAutoConnectsWithoutPriorConnect() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // No manual connect() — the lease alone must create demand and drive an auto-connect. + let token = try await handle.acquireWorkLease() + + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + await handle.releaseWorkLease(token) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 0) + } + + @Test func leaseOnNeverSeenIdThrowsNotFound() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + let handle = manager.peripheral(id: "never-seen-lease") + + await #expect(throws: PeripheralError.notFound) { + _ = try await handle.acquireWorkLease() + } + } + + @Test func doubleReleaseIsNoOp() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // Two leases held. A broken `Int` refcount would drop to zero on the double-release and tear + // down a link that still has work — the Set bookkeeping must keep it at 1. + let tokenA = try await handle.acquireWorkLease() + let tokenB = try await handle.acquireWorkLease() + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 2) + + await handle.releaseWorkLease(tokenA) + await handle.releaseWorkLease(tokenA) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + + await handle.releaseWorkLease(tokenB) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 0) + } + + @Test func connectPoweredOffSetsHoldAndRelinksOnPowerOn() async throws { + // D-hold, completed for step 6: a `connect()` issued while powered off registers the hold + // BEFORE the radio wait, throws `bluetoothPoweredOff`, and leaves durable demand — which the + // radio-return sweep (D-1 event 12) turns into a relink once the radio returns. The hold path + // also projects AwaitingRadio so stream-only UIs leave a terminal caption while demand is live. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + #expect(await pollUntil(timeout: 1.0) { + await manager.bluetooth.testConnectionStateSubscriberCount() >= 1 + }) + + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await handle.connect() + } + + // The hold is registered BEFORE the radio wait, so a thrown connect still leaves durable demand. + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + #expect(await manager.currentConnectionStates[handle.id] + == .reconnecting(source: .library, attempt: nil, nextRetryAt: nil)) + #expect(handle.connectionState + == .reconnecting(source: .library, attempt: nil, nextRetryAt: nil)) + + let projected = await firstConnectionStateChange(from: changes, withinNanoseconds: 2_000_000_000) + #expect(projected?.peripheralId == handle.id) + #expect(projected?.state == .reconnecting(source: .library, attempt: nil, nextRetryAt: nil)) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + + // Step 6: the radio-return sweep (D-1 event 12) re-links the held id without a second + // connect() call. The sweep re-evaluates demanded ids from the hold (not just tracked-state + // ids), so this hold — created after the earlier invalidate — is linked once the radio + // returns. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + } + + @Test func connectPoweredOffAutoReconnectFalseDoesNotProjectAwaitingRadio() async throws { + // Hold is still registered (idle suppression / durable intent), but without reconnectDesired + // there is no radio-return re-issue — stay terminal so UIs do not claim "waiting to reconnect". + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await handle.connect(autoReconnect: false) + } + + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + let state = await manager.currentConnectionStates[handle.id] + if case .reconnecting = state { + Issue.record("autoReconnect: false must not project AwaitingRadio, got \(String(describing: state))") + } + // Prefer a clean terminal or untracked — not reconnecting. + #expect(state == nil || state == .disconnected(reason: nil) + || { + if case .disconnected = state { return true } + if case .failed = state { return true } + return false + }()) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(await manager.currentConnectionStates[handle.id] != .connected) + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + + try? await handle.disconnect() + } + + @Test func manualDisconnectDuringRadioOutageSucceeds() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + + // A radio reset invalidates live references (clears cbPeripherals) but preserves the hold, + // and projects AwaitingRadio `.reconnecting(.library, nil, nil)`. + await Mock.simulateInitialState(.resetting) + _ = await Mock.waitForState("Resetting", on: manager) + #expect(!(await manager.bluetooth.testContainsCBPeripheral(handle.id))) + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + #expect(await pollUntil(timeout: 2.0) { + if case .reconnecting(.library, nil, nil) = await manager.currentConnectionStates[handle.id] { + return true + } + return false + }) + #expect(handle.connectionState == .reconnecting(source: .library, attempt: nil, nextRetryAt: nil)) + + // Subscribe *before* disconnect so the settle is not lost to registration lag; pin the + // stream event that stream-only UIs (Demo) rely on while the radio is still off. + let settleStream = manager.connectionStateChanges + #expect(await pollUntil(timeout: 1.0) { + await manager.bluetooth.testConnectionStateSubscriberCount() >= 1 + }) + + // Dropping the hold during a radio outage must succeed, not throw .notFound, and must + // immediately settle to clean `.disconnected` so stream-only UIs leave the reconnecting + // caption (and can start a new connect hold) while the radio is still off. + try await handle.disconnect() + #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) + #expect(await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil)) + #expect(handle.connectionState == .disconnected(reason: nil)) + + let settle = await firstConnectionStateChange(from: settleStream, withinNanoseconds: 2_000_000_000) + #expect(settle?.peripheralId == handle.id) + #expect(settle?.state == .disconnected(reason: nil)) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + // Hold was cleared: radio return must not re-link. + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil)) + #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) + } + + @Test func manualDisconnectClearsHoldAndTearsDown() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + + try await handle.disconnect() + #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) + let states = events.map { $0.state } + #expect(states.contains(.disconnecting)) + #expect(states.contains(.disconnected(reason: nil))) + let hasReconnecting = states.contains { + if case .reconnecting = $0 { return true } + return false + } + #expect(!hasReconnecting) + } + + // MARK: - Idle Disconnect (Grace Window & Teardown) + + @Test func idleDisconnectAfterLastLeaseReleased() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Releasing the last lease drops demand and starts the idle grace window. + await handle.releaseWorkLease(token) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 0) + + // The link tears down cleanly within the idle interval. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + } + + @Test func manualConnectHoldSuppressesIdle() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Quiet for 3x the interval — the manual-connect hold suppresses idle teardown. + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(await manager.currentConnectionStates[handle.id] == .connected) + } + + @Test func disconnectWithActiveLeaseRelinks() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + try await handle.disconnect() + #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + + // The link cancels once: exactly one .disconnecting. + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) + let states = events.map { $0.state } + #expect(states.filter { $0 == .disconnecting }.count == 1) + #expect(states.contains(.disconnected(reason: nil))) + // Step 6: the deferred half — work re-drives the link back to .connected. A Manual + // disconnect() that races pending work must not strand the lease. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + await handle.releaseWorkLease(token) + } + + @Test func tier0BlipDuringGraceRearmsIdle() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.3) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Release the lease (idle grace armed), then a Tier-0 reconnect lands during the grace window. + await handle.releaseWorkLease(token) + await manager.bluetooth.testInjectConnect(for: handle.id) + + // Event 8: the reconnect landing re-arms idle; the link is cancelled again. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + } + + @Test func restoredLinkRetainedWhenWorkDeclared() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.5) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Release (idle grace armed), then declare work again inside the grace window. + await handle.releaseWorkLease(token) + let token2 = try await handle.acquireWorkLease() + + // The link is retained — idle was cancelled, still connected. + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + await handle.releaseWorkLease(token2) + } + + @Test func idleWhileLibraryReconnectingSettlesCleanly() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 1.0 + let manager = await Mock.makeManager(reconnectPolicy: policy) + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Unexpected drop arms the Tier-1 ladder (lease held → wantsReconnect). + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, _, _) = await manager.currentConnectionStates[handle.id] { + return true + } + return false + }) + + // Demand drops while the ladder sleeps → settle cleanly, no stuck .disconnecting. + await handle.releaseWorkLease(token) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + #expect(await manager.currentConnectionStates[handle.id] != .disconnecting) + } + + // Defect 1: a manual disconnect issued while a connect is still pending (`.connecting`) must cancel + // the pending CoreBluetooth connect — not just settle the library state — so the OS cannot complete + // the link later (FR-1.2 / D-tier). + @Test func manualDisconnectDuringConnectingCancelsPendingConnect() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + + try await handle.disconnect() + + // Settled synchronously to a clean disconnected, never a stuck .disconnecting. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + #expect(await manager.currentConnectionStates[handle.id] != .disconnecting) + + // The pending connect must be suppressed: drain long enough for the mock to have completed the + // connect if the cancel had not fired, and assert no later `.connected` ever lands. + let later = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let anyConnected = later.contains { $0.state == .connected } + #expect(!anyConnected) + #expect(await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil)) + } + + // Defect 1b: manual disconnect while a cached Tier-0 / system reconnect is in limbo (.reconnecting(.system)) + // with a non-connecting live peripheral must cancel the OS's pending reconnect work (FR-1.2 / D-tier). + // This pins the `cachedSystemReconnect(id:)` arm of the `applyManualDisconnect` predicate — the same + // bug class as `fireIdle` had in bbff3d1. + @Test func manualDisconnectDuringCachedSystemReconnectCancels() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // White-box Tier-0-limbo setup: put ONLY the cached state into `.reconnecting(.system)` while + // the live `CBPeripheral` stays `.disconnected` (never connected in this scenario). + await manager.bluetooth.testSeedSystemReconnectState(for: handle.id) + + // Confirm the live peripheral is genuinely non-connecting AND non-connected when the manual + // disconnect fires, so the cancel can only be explained by the cached path — not the `.connecting` + // arm of the predicate. This cannot silently drift back onto the `.connecting` arm. + let liveState = await manager.bluetooth.testCBPeripheralState(for: handle.id) + #expect(liveState != .connecting, + "Live peripheral must be non-connecting so only the cached path can drive the cancel") + #expect(liveState != .connected) + + // Manual disconnect triggers `applyManualDisconnect`, which must clear the hold AND issue exactly + // one cancel to stop the OS's pending reconnect work, driven solely by the CACHED + // `.reconnecting(.system)` state. + try await handle.disconnect() + + #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id)), + "Manual disconnect must clear the manual-connect hold") + #expect(await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil)) + #expect(await manager.currentConnectionStates[handle.id] != .disconnecting) + #expect(await manager.bluetooth.testCancelPeripheralConnectionCount(for: handle.id) == 1, + "Manual disconnect must issue exactly one cancel for a CACHED Tier-0 reconnect with a non-connecting live peripheral") + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(handle.id) == false, + "Manual disconnect of a non-connected cached Tier-0 limbo must not mark the disconnect intentional") + } + + // Idle teardown while a Tier-0 / system reconnect is in flight must settle AND cancel, so the OS + // cannot relink a link nobody wants (FR-1.2 / D-tier). This is the CACHED arm of that predicate. + // + // Seeded white-box rather than driven through a real mock disconnection on purpose: CoreBluetoothMock + // always resolves a Tier-0 attempt one way or the other (relinking on success, or reporting + // `didFailToConnect` on failure), so the window in which the cached state is `.reconnecting(.system)` + // is timing-dependent and cannot be held open. An end-to-end version of this test raced the mock and + // failed intermittently on slower CI machines in both directions. The real-radio behaviour is covered + // as an on-device observation (see `docs/test-plans/`, Suite F3), and the live `.connecting` arm of the + // same predicate is pinned by `idleTeardownDuringLiveConnectingCancels`. + @Test func idleTeardownDuringCachedSystemReconnectCancels() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.01) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // White-box Tier-0-limbo setup: put ONLY the cached state into `.reconnecting(.system)` while + // the live `CBPeripheral` stays `.disconnected` (never connected in this scenario). + await manager.bluetooth.testSeedSystemReconnectState(for: handle.id) + + // A work lease creates demand, but `reevaluateLink` sees the cached `.reconnecting(.system)` + // and lets it run without issuing a connect — so the live peripheral never becomes `.connecting`. + let token = try await handle.acquireWorkLease() + #expect(await manager.currentConnectionStates[handle.id] + == .reconnecting(source: .system, attempt: nil, nextRetryAt: nil)) + + // Confirm the live peripheral is genuinely non-connecting AND non-connected when idle fires, so + // the cancel can only be explained by the cached path — not the `.connecting` arm of the + // predicate. This cannot silently drift back onto the `.connecting` arm. + let liveState = await manager.bluetooth.testCBPeripheralState(for: handle.id) + #expect(liveState != .connecting, + "Live peripheral must be non-connecting so only the cached path can drive the cancel") + #expect(liveState != .connected) + + // Demand drops mid-Tier-0-limbo: idle teardown must settle AND issue exactly one cancel to stop + // the OS's pending reconnect work, driven solely by the CACHED `.reconnecting(.system)` state. + await handle.releaseWorkLease(token) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + #expect(await manager.currentConnectionStates[handle.id] != .disconnecting) + #expect(await manager.bluetooth.testCancelPeripheralConnectionCount(for: handle.id) == 1, + "Idle teardown must issue exactly one cancel for a CACHED Tier-0 reconnect with a non-connecting live peripheral") + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(handle.id) == false, + "Idle teardown of a non-connected cached Tier-0 limbo must not mark the disconnect intentional") + } + + @Test func idleTeardownDuringLiveConnectingCancels() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.01) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + // Acquire work issues a plain (work-driven, non-manual-hold) connect: the cached state becomes + // `.connecting` while the live `CBPeripheral` also reports `.connecting`. Unlike the + // system-reconnect tests, cached state is NOT `.reconnecting(.system)`, so only the live + // `.connecting` arm of the teardown predicate can explain a cancel. + let token = try await handle.acquireWorkLease() + #expect(await manager.currentConnectionStates[handle.id] == .connecting) + if case .reconnecting = await manager.currentConnectionStates[handle.id] { + Issue.record("Cached state must be `.connecting` (not system reconnecting) so only the live arm drives the cancel") } - #expect(await manager1.bluetooth.testPersistedReconnectIntent().contains(handle.id)) + // The mock hands out `.connecting` synchronously on `issueConnect`; it resolves to `.connected` + // only after its ~45ms connection interval, which comfortably out-lasts our 10ms idle timer. + #expect((await manager.bluetooth.testCBPeripheralState(for: handle.id)) == .connecting, + "Scenario must leave the live peripheral `.connecting` so the live arm can drive the cancel") - // Cold relaunch: shut down stack 1. Central deinit may zero virtualConnections, so - // re-mark the spec connected before install — persisted intent survives in UserDefaults. - await Mock.tearDown(manager1, resetMockConnections: false) - Mock.connectionTestSpec.simulateConnection() + // Drop demand so idle teardown fires (10ms) before the pending connect resolves (~45ms). + await handle.releaseWorkLease(token) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + #expect(await manager.currentConnectionStates[handle.id] != .disconnecting) + #expect(await manager.bluetooth.testCancelPeripheralConnectionCount(for: handle.id) == 1, + "Idle teardown must issue exactly one cancel for a live `.connecting` peripheral") + // The settle path never flags the disconnect intentional. If this assert fails, idle has drifted + // onto the `.connected` branch (which DOES flag it) — meaning the live `.connecting` arm is not + // actually the thing being pinned. + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(handle.id) == false) + } - let scanUUID = CBMUUID(string: "180D") - Mock.installStateRestoration( - restoreIdentifier: restoreId, - peripherals: [Mock.connectionTestSpec], - scanServices: [scanUUID] + @Test func zeroIdleIntervalTearsDownImmediately() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Interval 0 tears the link down as soon as demand hits zero (still asynchronously). + await handle.releaseWorkLease(token) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + } + + @Test func idleDoesNotFireWhenDemandReturnsBeforeExpiry() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.5) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Release (idle armed), then re-acquire before expiry — the stale timer must not fire. + await handle.releaseWorkLease(token) + let token2 = try await handle.acquireWorkLease() + + // Wait past the original expiry; the generation guard keeps the link connected. + try? await Task.sleep(nanoseconds: 600_000_000) + #expect(await manager.currentConnectionStates[handle.id] == .connected) + + await handle.releaseWorkLease(token2) + } + + // Exploratory (#9): disconnect while the connect is still pending (.connecting). + @Test func disconnectDuringConnectingSettlesCleanly() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + try await handle.disconnect() + + // The settling rule must not leave a stuck .disconnecting — settle to .disconnected(reason: nil). + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + } + + // MARK: - Reconnect Gating (#59), Radio-Drop Projection, and Durable Holds (step 6) + + @Test func tier1DoesNotArmWhenQuiet() async throws { + // A `connect(autoReconnect: false)` hold yields `wantsReconnect == false` — an unexpected drop + // must NOT arm the library ladder (#59). + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 0.01 + let manager = await Mock.makeManager(reconnectPolicy: policy) + await Mock.ensureReady(manager) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect(autoReconnect: false) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected + + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let hasLibrary = events.contains { if case .reconnecting(.library, _, _) = $0.state { return true }; return false } + #expect(!hasLibrary) + + try? await handle.disconnect() + } + + @Test func tier1ArmsWhileLeaseHeld() async throws { + // A held work lease yields `wantsReconnect == true` — an unexpected drop arms the ladder (#59). + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 0.01 + let manager = await Mock.makeManager(reconnectPolicy: policy) + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, _, _) = await manager.currentConnectionStates[handle.id] { return true } + return false + }) + + await handle.releaseWorkLease(token) + } + + @Test func autoReconnectFalseHoldSuppressesBothTiers() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + let manager = await Mock.makeManager(reconnectPolicy: policy) + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect(autoReconnect: false) + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting + _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected + + // reconnectDesired false → no reconnect intent synced (no Tier-0 option / no Tier-1 intent). + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) + // The hold suppresses idle (quiet far past the interval). + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(await manager.currentConnectionStates[handle.id] == .connected) + // An unexpected drop still does not arm Tier-1. + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let hasLibrary = events.contains { if case .reconnecting(.library, _, _) = $0.state { return true }; return false } + #expect(!hasLibrary) + + try? await handle.disconnect() + } + + @Test func demandSurvivesRadioCycleAndRelinks() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + + // Demand survived the radio cycle; the sweep re-links without re-acquiring. + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + await handle.releaseWorkLease(token) + } + + @Test func radioDropWithWantsReconnectShowsReconnecting() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + let changes = manager.connectionStateChanges + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } + + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + // The stream shows .disconnected(.bluetoothUnavailable) THEN .reconnecting(.library, nil, nil), + // and the handle is not left nil. + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let states = events.map { $0.state } + let disconnectedIdx = states.firstIndex(of: .disconnected(reason: .bluetoothUnavailable)) + let reconnectingIdx = states.firstIndex { if case .reconnecting(.library, nil, nil) = $0 { return true }; return false } + if let disconnectedIdx, let reconnectingIdx { + #expect(reconnectingIdx > disconnectedIdx) + } else { + Issue.record("expected .disconnected then .reconnecting(.library, nil, nil)") + } + #expect(handle.connectionState != nil) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + try? await handle.disconnect() + } + + // INVARIANT COVERAGE (not a regression test): a reconnect ladder step must not issue a connect + // against a dead radio. This is an end-to-end behavioral check, but it does NOT pin the gate — + // the synchronous flip on `simulatePowerOff` races the deferred delegate invalidation, which can + // cancel the ladder before a sleeping step ever wakes, so `performReconnect`'s radio gate is not + // strictly what prevents the connect here. The gate itself is pinned deterministically by + // `reconnectLadderStepRefusesToIssueWhenRadioIsOff` (see that test). + @Test func reconnectLadderDoesNotIssueAgainstDeadRadio() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 0.1 + policy.jitter = 0.0 + let manager = await Mock.makeManager(reconnectPolicy: policy) + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // Unexpected drop arms the Tier-1 ladder. + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, _, _) = await manager.currentConnectionStates[handle.id] { + return true + } + return false + }) + + // Flip the radio off while the ladder is sleeping. The ladder's performReconnect reads + // centralManager.state synchronously (now .poweredOff) and must refuse to issue a connect. + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + // Allow any racing ladder step to run; then assert no `.connecting` was issued on the dead radio. + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(await manager.currentConnectionStates[handle.id] != .connecting) + + // Flush the events already observed (buffer includes the .connecting/.connected from lease + // acquisition), then assert no additional `.connecting` was issued after the radio died. + _ = await drainConnectionStateChanges(from: changes, withinNanoseconds: 400_000_000) + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let issuedConnecting = events.contains { $0.state == .connecting } + #expect(!issuedConnecting, "Ladder must not issue a connect against a dead radio") + + // Cleanup: restore the radio and tear down the lease. + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + await handle.releaseWorkLease(token) + } + + // WHITE-BOX regression test for the reconnect ladder's dead-radio gate. The end-to-end test + // (`reconnectLadderDoesNotIssueAgainstDeadRadio`) cannot pin this gate because the delegate-driven + // invalidation cancels a sleeping ladder before its step wakes — so we drive one ladder step + // deterministically via the test-only `testInvokeLadderStep` hook while the radio is off, and + // assert the gate refuses to issue a connect and instead surfaces `.failed(.bluetoothPoweredOff)`. + // If the `.poweredOff` gate in `performReconnect` is removed, this fails. + @Test func reconnectLadderStepRefusesToIssueWhenRadioIsOff() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + var policy = ReconnectPolicy() + policy.maxAttempts = 5 + policy.initialDelay = 0.1 + policy.jitter = 0.0 + let manager = await Mock.makeManager(reconnectPolicy: policy) + await Mock.ensureReady(manager) + await manager.bluetooth.setIdleDisconnectInterval(0.1) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) - CBMCentralManagerMock.simulateAuthorization(.notDetermined) - let manager2 = await Mock.makeManager(restoreIdentifier: restoreId) - let baseConn = await manager2.bluetooth.testConnectionStateSubscriberCount() - let basePeriph = await manager2.bluetooth.testPeripheralsSubscriberCount() - var peripherals = manager2.discoveredPeripherals.makeAsyncIterator() - var connectionChanges = manager2.connectionStateChanges.makeAsyncIterator() - let subscriptionsReady = await pollUntil(timeout: 3.0) { - let connReady = await manager2.bluetooth.testConnectionStateSubscriberCount() > baseConn - let periphReady = await manager2.bluetooth.testPeripheralsSubscriberCount() > basePeriph - return connReady && periphReady + // Unexpected drop arms the Tier-1 ladder. + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.library, _, _) = await manager.currentConnectionStates[handle.id] { + return true + } + return false + }) + + // Flip the radio off and let the delegate-driven invalidation settle deterministically, so the + // ladder bookkeeping is stationary before we drive the step ourselves. + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + // Drive one ladder step on a dead radio. The gate must refuse to issue a connect. + await manager.bluetooth.testInvokeLadderStep(for: handle.id) + + // The gate deterministically settles the ladder to a terminal `.failed(.bluetoothPoweredOff)` + // (via `failLadderRadio`) on this actor turn. If the `.poweredOff` gate were removed, + // `performReconnect` would fall through and `issueConnect` would instead publish `.connecting` + // — so the `.connecting` absence is the load-bearing regression check. Read the actor's + // authoritative state rather than racing a time-bounded stream drain. + let settledToPoweredOff = await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .failed(reason: .bluetoothPoweredOff) } - #expect(subscriptionsReady) + #expect(settledToPoweredOff, "Ladder step on a dead radio must fail via .bluetoothPoweredOff, not connect") + #expect(await manager.currentConnectionStates[handle.id] != .connecting, + "Ladder step must not issue a connect against a dead radio") - CBMCentralManagerMock.simulateAuthorization(.allowedAlways) - CBMCentralManagerMock.simulatePowerOn() - try await manager2.authorizeBluetooth() + // Cleanup: restore the radio and tear down the lease. + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + await handle.releaseWorkLease(token) + } - let connectionSeeded = await pollUntil(timeout: 3.0) { - let state = await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] - return state == .connected || state == .connecting - } - #expect(connectionSeeded) - // Mock may restore as .connecting when virtualConnections was cleared by central deinit; - // simulateConnection before install prefers .connected. Either way maps rehydrate. - #expect(await manager2.bluetooth.testContainsCBPeripheral(Mock.connectionTestPeripheralID)) - #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + // Priority A (D-1 event 9 middle branch): a disconnect reporting `isReconnecting == true` while + // `wantsReconnect(id)` is false must NOT trust Tier-0. The library publishes `.disconnected(reason: nil)` + // immediately, suppresses the OS's pending reconnect by cancelling fire-and-forget, and does NOT insert + // into `intentionalDisconnects`. The setup uses a manual hold with `autoReconnect: false` — the only + // deterministic way to hold `.connected` while `wantsReconnect` is false without racing an idle timer. + @Test func untrustedTier0ReconnectIsSuppressedWhenNothingWantsIt() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let restoredList = await manager2.bluetooth.discoveredPeripherals - #expect(restoredList.contains(where: { $0.id == Mock.connectionTestPeripheralID })) + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) - // Restored peripherals are kept off the advertisement feed; discoveredPeripherals replays. - let peripheralsEvent = await peripherals.next() - #expect(peripheralsEvent?.contains(where: { $0.id == Mock.connectionTestPeripheralID }) == true) + let changes = manager.connectionStateChanges - let connectionEvent = await connectionChanges.next() - #expect(connectionEvent?.peripheralId == Mock.connectionTestPeripheralID) - #expect( - connectionEvent?.state == .connected - || connectionEvent?.state == .connecting + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 ) + let handle = try #require(snap).peripheral + await manager.stopScanning() - let scanSettled = await pollUntil(timeout: 3.0) { - let scanning = await manager2.bluetooth.testIsScanning() - let pending = await manager2.bluetooth.testPendingRestoredScanServices() - return scanning && pending == nil + try await handle.connect(autoReconnect: false) + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + // No live work and the hold is reconnectDesired:false, so NOTHING wants a link back. + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 0) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) + + // A Tier-0 reconnect event lands while nothing wants the link back. + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: true) + + // Untrusted Tier-0: publish `.disconnected(reason: nil)`, never `.reconnecting(.system)`. + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil) + }) + if case .reconnecting = await manager.currentConnectionStates[handle.id] { + Issue.record("Must not publish any .reconnecting for an untrusted Tier-0 event") } - #expect(scanSettled) + // And the connection-state stream carries no `.reconnecting(.system)` either. + let later = await drainConnectionStateChanges(from: changes, withinNanoseconds: 800_000_000) + let sysReconnect = later.contains { if case .reconnecting(.system, _, _) = $0.state { return true }; return false } + #expect(!sysReconnect, "Untrusted Tier-0 must not publish .reconnecting(.system)") + #expect(later.contains { $0.state == .disconnected(reason: nil) }) + + // Exactly one fire-and-forget suppression cancel; never marked intentional. + #expect(await manager.bluetooth.testCancelPeripheralConnectionCount(for: handle.id) == 1, + "Untrusted Tier-0 must issue exactly one suppression cancel") + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(handle.id) == false, + "Untrusted Tier-0 suppression must not mark the disconnect intentional") - await manager2.bluetooth.testClearPersistedReconnectIntent() - await Mock.tearDown(manager2) + try? await handle.disconnect() } - @Test func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { - // Faithful path: connected restore re-arms Tier-1 from persisted intent. - // Disconnected-peripheral seeding is a direct-handler unit test (item 4) — iOS never - // restores disconnected peripherals, and the mock always restores specs as - // connected/connecting based on virtualConnections. + // Ladder gate: a step on a shut-down / no-central stack must fail to `.bluetoothUnavailable` rather + // than issue against a torn-down stack (the `guard !isShutdown, let centralManager` in `performReconnect`). + @Test func reconnectLadderStepAfterShutdownFailsUnavailable() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - defer { - Mock.connectionTestDelegate.connectionResult = .success(()) - Mock.clearStateRestoration() - } + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let restoreId = "com.five3apps.relia-ble.tests.restore-seeding" - let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) - await Mock.ensureReady(manager1) - await manager1.bluetooth.testClearPersistedReconnectIntent() + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) - await manager1.startScanning() - let connectionPeripheral = await Mock.waitForDiscovered( + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, - on: manager1, + on: manager, withinNanoseconds: 3_000_000_000 ) - let connected = try #require(connectionPeripheral).peripheral - await manager1.stopScanning() + let handle = try #require(snap).peripheral + await manager.stopScanning() - try await connected.connect() - _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[connected.id] == .connected - } + await manager.bluetooth.shutdown() + await manager.bluetooth.testInvokeLadderStep(for: handle.id) - await Mock.tearDown(manager1, resetMockConnections: false) - Mock.connectionTestSpec.simulateConnection() + // The ladder must settle to a terminal `.failed(.bluetoothUnavailable)`, never issue a connect. + #expect(await manager.currentConnectionStates[handle.id] == .failed(reason: .bluetoothUnavailable)) + #expect(await manager.currentConnectionStates[handle.id] != .connecting) + } - Mock.installStateRestoration( - restoreIdentifier: restoreId, - peripherals: [Mock.connectionTestSpec], - scanServices: nil + /// White-box ladder radio-gate coverage shared by `.unsupported` / `.unauthorized`. + /// Drives one ladder step via ``BluetoothActor/testInvokeLadderStep(for:)`` against a given radio + /// state and asserts the step neither issues a connect nor publishes `.connecting`. + @MainActor private func assertLadderRadioGate( + mockState: CBMManagerState, + stateDescription: String, + expected: PeripheralError + ) async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 ) + let handle = try #require(snap).peripheral + await manager.stopScanning() - let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + Mock.simulateInitialState(mockState) + _ = await Mock.waitForState(stateDescription, on: manager) + + await manager.bluetooth.testInvokeLadderStep(for: handle.id) #expect(await pollUntil(timeout: 3.0) { - await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected - }) - #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + await manager.currentConnectionStates[handle.id] == .failed(reason: expected) + }, "Ladder step on \(stateDescription) must fail via \(expected), not connect") + #expect(await manager.currentConnectionStates[handle.id] != .connecting) + + // Restore the baseline so the next test starts from a known-good radio. + // Prefer power cycle over sticky `simulateInitialState(.poweredOn)` so the live central + // and process-wide mock agree on `.poweredOn`. + Mock.simulatePowerOff() + Mock.simulatePowerOn() + await manager.bluetooth.updateState() + _ = await Mock.waitForState("Ready", on: manager) + } - await manager2.bluetooth.testClearPersistedReconnectIntent() - await Mock.tearDown(manager2) + @Test func reconnectLadderStepRefusesToIssueWhenUnsupported() async throws { + try await assertLadderRadioGate(mockState: .unsupported, stateDescription: "Unsupported", expected: .bluetoothUnsupported) + } + + @Test func reconnectLadderStepRefusesToIssueWhenUnauthorized() async throws { + try await assertLadderRadioGate(mockState: .unauthorized, stateDescription: "Unauthorized", expected: .bluetoothUnavailable) } - @Test func willRestoreDoesNotRearmReconnectWithoutPersistedIntent() async throws { + // Ladder gate: a transient `.unknown` radio is not a failure — the step returns in place, leaving the + // cached `.reconnecting(.library)` state intact for the radio-return sweep to re-drive later. + @Test func reconnectLadderStepDefersWhileRadioTransient() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - defer { - Mock.connectionTestDelegate.connectionResult = .success(()) - Mock.clearStateRestoration() - } + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let restoreId = "com.five3apps.relia-ble.tests.restore-no-intent" - let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) - await Mock.ensureReady(manager1) - await manager1.bluetooth.testClearPersistedReconnectIntent() + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) - await manager1.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, - on: manager1, + on: manager, withinNanoseconds: 3_000_000_000 ) let handle = try #require(snap).peripheral - await manager1.stopScanning() + await manager.stopScanning() - try await handle.connect(autoReconnect: false) - _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[handle.id] == .connected - } - #expect(!(await manager1.bluetooth.testPersistedReconnectIntent().contains(handle.id))) + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) - await Mock.tearDown(manager1, resetMockConnections: false) - Mock.connectionTestSpec.simulateConnection() + await manager.bluetooth.testInvokeLadderStep(for: handle.id) - Mock.installStateRestoration( - restoreIdentifier: restoreId, - peripherals: [Mock.connectionTestSpec], - scanServices: nil + // The step defers (no failure, no connect): the ladder stays `.reconnecting(.library)`. + let state = await manager.currentConnectionStates[handle.id] + let isLibrary = { if case .reconnecting(.library, _, _) = state { return true }; return false }() + #expect(isLibrary, "Ladder step on a transient radio must defer, leaving .reconnecting(.library)") + #expect(await manager.currentConnectionStates[handle.id] != .connecting) + + // `simulateInitialState` is sticky process-wide — restore with power cycle so later tests + // that assume a live `.poweredOn` central (ladder gates) are not left on `.unknown`. + await Mock.simulatePowerOff() + await Mock.simulatePowerOn() + await manager.bluetooth.updateState() + _ = await Mock.waitForState("Ready", on: manager) + } + + // reevaluateLink transient-radio arm (D-radio): a work-lease acquisition that calls reevaluateLink + // directly (no radio wait) while the radio is `.unknown` must defer — return a token without issuing + // a connect — rather than throw or connect against a transient radio. + @Test func acquireWorkLeaseDefersWhileRadioTransient() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 ) - let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + let handle = try #require(snap).peripheral + await manager.stopScanning() - #expect(await pollUntil(timeout: 3.0) { - await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected - }) - #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) - await manager2.bluetooth.testClearPersistedReconnectIntent() - await Mock.tearDown(manager2) + // The actor's acquireWorkLease calls reevaluateLink directly; on a transient radio it must + // return a token (deferring the connect), not throw and not publish `.connecting`. + let token = try await manager.bluetooth.acquireWorkLease(id: handle.id) + #expect(await manager.currentConnectionStates[handle.id] != .connecting) + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + + await manager.bluetooth.releaseWorkLease(token) + + await Mock.simulatePowerOff() + await Mock.simulatePowerOn() + await manager.bluetooth.updateState() + _ = await Mock.waitForState("Ready", on: manager) } - @Test func willRestoreIgnoresEmptyScanServiceFilter() async throws { - // Direct-handler unit test: CoreBluetoothMock treats a non-nil (even empty) scan-services - // array as `isScanning = true` at restore-init, so the faithful fixture cannot express - // "empty filter ignored" without fighting the mock. Production still ignores empty filters. + // Ladder gate: a `.reconnecting(.library)` step for an id with no live `CBPeripheral` must fail to + // `.notFound` (D-never — no scan, demand retained) rather than issue a connect. + @Test func reconnectLadderStepWithMissingPeripheralFailsNotFound() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.testHandleWillRestoreState(scanServices: []) + // Prior tests can leave the process-wide mock radio sticky (e.g. `.unknown` via + // `simulateInitialState`). The ladder intentionally no-ops on transient radio, so require a + // live powered-on central before the step — `ensureReady` alone is not always enough after + // sticky initial-state pollution. + await Mock.simulatePowerOff() + await Mock.simulatePowerOn() + await manager.bluetooth.updateState() + #expect(await manager.bluetooth.isCentralPoweredOn) + #expect(await Mock.waitForState("Ready", on: manager)) - #expect(!(await manager.bluetooth.testIsScanning())) - #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) + // A ghost id never discovered has no live `CBPeripheral` while the radio is powered on. + let ghostId = Mock.connectionTestPeripheralID + ".ghost" + let after = await manager.bluetooth.testInvokeLadderStepReturningState(for: ghostId) + + // Read the state the actor observed at the end of the ladder step (same turn), not a later + // `currentConnectionStates` sample — a subsequent mock `poweredOn` can re-enter + // `sweepRadioReturnedDemand` → `beginIdleGrace` and rewrite `.failed` to `.disconnected(nil)`. + #expect(after == .failed(reason: .notFound), "Ladder step with no live peripheral must fail via .notFound") + #expect(after != .connecting) } - @Test func invalidatePeripheralsClearsRestoredStateAndIntent() async throws { - Mock.connectionTestDelegate.connectionResult = .success(()) - defer { - Mock.connectionTestDelegate.connectionResult = .success(()) - Mock.clearStateRestoration() + // Radio regression surfaced through `connect()`: with the radio `.unsupported`, the connect waits for + // a usable radio and fails fast with `.bluetoothUnsupported` rather than hanging or no-op'ing (D-radio). + @Test func connectFailsWhenUnsupported() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + let handle = manager.peripheral(id: Mock.connectionTestPeripheralID) + + await Mock.simulateInitialState(.unsupported) + _ = await Mock.waitForState("Unsupported", on: manager) + + await #expect(throws: PeripheralError.bluetoothUnsupported) { + try await handle.connect() } - let restoreId = "com.five3apps.relia-ble.tests.restore-invalidate" - let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) - await Mock.ensureReady(manager1) - await manager1.bluetooth.testClearPersistedReconnectIntent() + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + try? await handle.disconnect() + } + + // Radio regression surfaced through `connect()`: `.unauthorized` fails fast with `.bluetoothUnavailable`. + @Test func connectFailsWhenUnauthorized() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + let handle = manager.peripheral(id: Mock.connectionTestPeripheralID) + + await Mock.simulateInitialState(.unauthorized) + _ = await Mock.waitForState("Unauthorized", on: manager) + + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await handle.connect() + } + + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + try? await handle.disconnect() + } + + // A `.unknown`-parked connect waiter that resolves to `.unsupported` must fail with `.bluetoothUnsupported` + // (resolvePoweredOnWaiters fails terminal-state waiters — the transient-vs-terminal analogue of the + // existing `connectTransientResolvingToPoweredOffThrows`). + @Test func connectTransientResolvingToUnsupportedThrows() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) - await manager1.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, - on: manager1, + on: manager, withinNanoseconds: 3_000_000_000 ) let handle = try #require(snap).peripheral - await manager1.stopScanning() + await manager.stopScanning() - try await handle.connect() - _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[handle.id] == .connected + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let connectTask = Task { try await handle.connect() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 } - await Mock.tearDown(manager1, resetMockConnections: false) - Mock.connectionTestSpec.simulateConnection() + await Mock.simulateInitialState(.unsupported) + await #expect(throws: PeripheralError.bluetoothUnsupported) { + try await connectTask.value + } - Mock.installStateRestoration( - restoreIdentifier: restoreId, - peripherals: [Mock.connectionTestSpec], - scanServices: nil + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } + + // A `.unknown`-parked connect waiter that resolves to `.unauthorized` must fail with `.bluetoothUnavailable`. + @Test func connectTransientResolvingToUnauthorizedThrows() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 ) - let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + let handle = try #require(snap).peripheral + await manager.stopScanning() - #expect(await pollUntil(timeout: 3.0) { - await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID) - }) + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let connectTask = Task { try await handle.connect() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 + } - // Direct-handler: stash a pending restored scan, then invalidate (faithful second restore - // while powered off is awkward because mock forces isScanning at restore-init). - let scanUUID = CBUUID(string: "180D") - CBMCentralManagerMock.simulatePowerOff() - #expect(await Mock.waitForState("Powered Off", on: manager2)) - await manager2.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) - #expect(await manager2.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + await Mock.simulateInitialState(.unauthorized) + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await connectTask.value + } - await manager2.bluetooth.testInvalidatePeripherals() - #expect(await manager2.bluetooth.testPendingRestoredScanServices() == nil) - #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) - #expect(await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == nil) - #expect(await manager2.bluetooth.testPersistedReconnectIntent().isEmpty) + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) - CBMCentralManagerMock.simulatePowerOn() - await manager2.bluetooth.testClearPersistedReconnectIntent() - await Mock.tearDown(manager2) + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) } - @Test func invalidatePeripheralsClearsHandleConnectionStateButKeepsMetadata() async throws { - // The two halves of a radio reset pull in opposite directions, and the handle must honor both: - // last-known metadata survives (it is still the best thing known about the device), while connection - // state must NOT — a handle stuck reporting `.connected` after the library tore the connection down is - // not stale, it is false. + // Cancelling a `connect()` parked on a transient radio fires the handle's `onCancel`, which cancels + // the parked powered-on continuation (the `Peripheral.connect` cancellation/orphan path). + @Test func connectCancellationWhileParkedOnTransientRadio() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1842,32 +4593,40 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() - #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) - let priorName = handle.name - let priorRSSI = try #require(handle.rssi) + let connectTask = Task { try await handle.connect() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 + } - await manager.bluetooth.testInvalidatePeripherals() + connectTask.cancel() + do { + try await withTimeout(nanoseconds: 4_000_000_000) { _ = try await connectTask.value } + Issue.record("connect() must throw CancellationError when cancelled while parked") + } catch is CancellationError { + // expected + } catch { + throw error + } - #expect(handle.connectionState == nil) - #expect(await manager.currentConnectionStates[handle.id] == nil) - #expect(handle.name == priorName) - #expect(handle.rssi == priorRSSI) + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) } - @Test func invalidatePeripheralsEmitsTerminalConnectionStateChange() async throws { - // Clearing tracked connection state is the one transition a subscriber cannot infer on its own: a cleared - // peripheral produces no further events, so without an explicit emit a UI driven only by - // `connectionStateChanges` renders `.connected` forever after a radio reset. The handle reverting to `nil` - // is not enough — nothing tells the app to go re-read it. + // Cancelling an `acquireWorkLease()` parked on a transient radio fires the handle's `onCancel`, which + // cancels the parked powered-on continuation (the `Peripheral.acquireWorkLease` cancellation/orphan path). + @Test func acquireWorkLeaseCancellationWhileParkedOnTransientRadio() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1876,347 +4635,526 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() - #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) - // Subscribe before invalidating — `connectionStateChanges` has no replay, so a stream created afterwards - // would miss the very event under test, and creating one only *enqueues* registration. - let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() - let changes = manager.connectionStateChanges - #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + let leaseTask = Task { try await handle.acquireWorkLease() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 + } - let id = handle.id - let collector = Task { () -> ConnectionStateChange? in - for await change in changes - where change.peripheralId == id && change.state == .disconnected(reason: .bluetoothUnavailable) { - return change - } - return nil + leaseTask.cancel() + do { + try await withTimeout(nanoseconds: 4_000_000_000) { _ = try await leaseTask.value } + Issue.record("acquireWorkLease() must throw CancellationError when cancelled while parked") + } catch is CancellationError { + // expected + } catch { + throw error } - await manager.bluetooth.testInvalidatePeripherals() + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) - // Bound the wait: a regression that drops the emit must fail this test, not hang the suite. - let watchdog = Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) - collector.cancel() + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } + + // shutdown() must fail a parked scan waiter with `.bluetoothUnavailable` (its `scanWaiter` resume + // path), so a scan parked on a transient radio does not hang forever if the stack is torn down. + @Test func shutdownFailsParkedScanWaiter() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanTask = Task { try await manager.startScanning() } + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + await manager.bluetooth.shutdown() + + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await scanTask.value } - let terminal = await collector.value - watchdog.cancel() + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) - #expect(terminal?.state == .disconnected(reason: .bluetoothUnavailable)) - // The event describes the transition; the handle reports "no longer tracked". - #expect(handle.connectionState == nil) + await Mock.simulateInitialState(.poweredOn) } - @Test func willRestoreDefersScanUntilPoweredOn() async throws { - // Direct-handler unit test: CoreBluetoothMock sets `isScanning = true` synchronously - // inside central init when scan services are restored, so a faithful cold-relaunch cannot - // observe a deferred pending filter. Exercise our handler's powered-off deferral directly. + // startScanning() with an `.unauthorized` radio fails fast with `.bluetoothUnavailable` (D-radio). + @Test func startScanningFailsWhenUnauthorized() async throws { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulatePowerOff() - #expect(await Mock.waitForState("Powered Off", on: manager)) + await Mock.simulateInitialState(.unauthorized) + _ = await Mock.waitForState("Unauthorized", on: manager) - let scanUUID = CBUUID(string: "180D") - await manager.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await manager.startScanning() + } + #expect(await manager.bluetooth.testIsScanning() == false) - #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) - #expect(!(await manager.bluetooth.testIsScanning())) + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } - CBMCentralManagerMock.simulatePowerOn() - let becameScanning = await Mock.waitForState("Scanning", on: manager) - if !becameScanning { - #expect(await Mock.waitForState("Ready", on: manager)) - } + // startScanning() with a torn-down stack fails fast with `.bluetoothUnavailable` (the + // `guard !isShutdown` in `startScanning`), so a scan cannot outlive its manager. + @Test func startScanningAfterShutdownThrows() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) - let resumed = await pollUntil(timeout: 3.0) { - let pending = await manager.bluetooth.testPendingRestoredScanServices() - let scanning = await manager.bluetooth.testIsScanning() - return pending == nil && scanning + await manager.bluetooth.shutdown() + + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await manager.startScanning() } - #expect(resumed) - #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) - #expect(await manager.bluetooth.testIsScanning()) + await Mock.simulateInitialState(.poweredOn) + } + + @Test func radioDropWithoutWantsReconnectShowsNoReconnecting() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let changes = manager.connectionStateChanges + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral await manager.stopScanning() + + try await handle.connect(autoReconnect: false) + _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } + + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) + let hasLibrary = events.contains { if case .reconnecting(.library, _, _) = $0.state { return true }; return false } + #expect(!hasLibrary) + #expect(events.contains { $0.state == .disconnected(reason: .bluetoothUnavailable) }) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + try? await handle.disconnect() } - @Test func willRestoreDisconnectedPeripheralSeedsNothing() async throws { - // Direct-handler: iOS never restores disconnected peripherals; this only exercises our - // defensive `.disconnected` switch (no connectionStates / reconnectEnabled seeding). + @Test func noReconnectHoldIsNotResurrectedByRadioCycle() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() - let snap = await Mock.waitForDiscovered( - id: Mock.testPeripheralID, - on: manager, - withinNanoseconds: 3_000_000_000 - ) + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) let handle = try #require(snap).peripheral await manager.stopScanning() - #expect(await manager.currentConnectionStates[handle.id] == nil) - #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) + try await handle.connect(autoReconnect: false) + _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } - await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [handle.id]) + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) - #expect(await manager.currentConnectionStates[handle.id] == nil) + // wantsReconnect false → reason .radioReturned does not satisfy the issue gate. + #expect(await manager.currentConnectionStates[handle.id] != .connected) #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) - // Live reference remains registered from discovery. - #expect(await manager.bluetooth.testContainsCBPeripheral(handle.id)) + try? await handle.disconnect() } - @Test func restorePathInternsSameHandle() async throws { + @Test func strandedLeaseSurfacesFailure() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + let manager = await Mock.makeManager() await Mock.ensureReady(manager) - // Pre-create the handle — before any discovery or restore. - let handle = manager.peripheral(id: Mock.testPeripheralID) - #expect(handle.cbIdentifier == nil) + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() - // First discover so live refs and prior metadata exist. - await manager.startScanning() - let snap = await Mock.waitForDiscovered( - id: Mock.testPeripheralID, - on: manager, - withinNanoseconds: 3_000_000_000 - ) - let discovered = try #require(snap) + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + // Invalidate (clears live refs, retains demand), then make the device non-retrievable, then + // drive the radio-return sweep: the strand surfaces as .failed(.notFound). + await manager.bluetooth.testInvalidatePeripherals() + await manager.bluetooth.testClearDiscoveredPeripherals() + await manager.bluetooth.testSimulateRadioReturn() + + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .failed(reason: .notFound) }) + // Demand is retained — no scan, no retry loop. The lease is still held. + #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) + + await handle.releaseWorkLease(token) + } + + @Test func discoveryRelinksDemandedPeripheral() async throws { + // D-1 event 15 (polish): an id stranded at .failed(.notFound) with demand relinks when an + // app-driven scan rediscovers it. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral await manager.stopScanning() - let priorRSSI = discovered.rssi - let priorAd = discovered.advertisement - #expect(handle.rssi != nil) - // Drive restore via the test hook — this re-binds the live CBPeripheral. - await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [Mock.testPeripheralID]) + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) - // The same handle instance received cbIdentifier metadata from restore. - #expect(handle.cbIdentifier != nil) - #expect(await manager.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + await manager.bluetooth.testInvalidatePeripherals() + await manager.bluetooth.testClearDiscoveredPeripherals() + await manager.bluetooth.testSimulateRadioReturn() + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .failed(reason: .notFound) }) + + // Force the (still virtually-connected) spec to advertise again so an app-driven scan can + // rediscover it — event 15 then relinks the demanded, stranded id. + await Mock.simulateDisconnection() + try await manager.startScanning() + let rediscovered = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + #expect(rediscovered != nil) + await manager.stopScanning() - // Regression guard for the shared-helper merge rule: restoration carries no advertisement payload and no - // RSSI, so it must KEEP the values the earlier discovery established rather than wiping them. `#require` - // rather than `if let` — if discovery stopped producing these, the guard would silently pass and stop - // protecting anything. - let requiredRSSI = try #require(priorRSSI) - let requiredAd = try #require(priorAd) - #expect(handle.rssi == requiredRSSI) - #expect(handle.advertisement == requiredAd) + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + await handle.releaseWorkLease(token) } - // MARK: - Multi-Manager Isolation + @Test func shutdownLeavesPersistedHoldsIntact() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - @Test func twoManagersWithDistinctRestoreIdsHaveIndependentState() async throws { + let restoreId = "com.five3apps.relia-ble.tests.shutdown-holds" + let manager = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager) + await manager.bluetooth.testClearPersistedReconnectIntent() + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } + #expect(await manager.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) + + // Shutdown must NOT write an empty flush. + await Mock.tearDown(manager) + #expect(await manager.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) + await manager.bluetooth.testClearPersistedReconnectIntent() + } + + @Test func invalidateDoesNotWipePersistedHolds() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - let restoreA = "com.five3apps.relia-ble.tests.iso-a" - let restoreB = "com.five3apps.relia-ble.tests.iso-b" + let restoreId = "com.five3apps.relia-ble.tests.invalidate-holds" + let manager = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager) + await manager.bluetooth.testClearPersistedReconnectIntent() - let managerA = await Mock.makeManager(restoreIdentifier: restoreA, tearDownPrevious: true) - await Mock.ensureReady(managerA) + try await manager.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager.stopScanning() - // Second stack stays live alongside the first — validates instance isolation end-to-end. - let managerB = await Mock.makeManager(restoreIdentifier: restoreB, tearDownPrevious: false) - await Mock.ensureReady(managerB) + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } + #expect(await manager.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) - #expect(await managerA.bluetooth.hasCentralManager) - #expect(await managerB.bluetooth.hasCentralManager) - #expect(await managerA.bluetooth.testRestoreIdentifier() == restoreA) - #expect(await managerB.bluetooth.testRestoreIdentifier() == restoreB) + await manager.bluetooth.testInvalidatePeripherals() - // A discovers while B is idle — B's discovered list must stay empty. - await managerA.startScanning() - let discoveredOnA = await Mock.waitForDiscovered( - id: Mock.testPeripheralID, - on: managerA, - withinNanoseconds: 3_000_000_000 - ) - #expect(discoveredOnA != nil) - #expect(await managerA.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) - #expect(await managerB.bluetooth.discoveredPeripherals.isEmpty) - #expect(!(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID))) - await managerA.stopScanning() + #expect(await manager.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) + await manager.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager) + } - // B discovers independently into its own maps. - await managerB.startScanning() - let discoveredOnB = await Mock.waitForDiscovered( - id: Mock.testPeripheralID, - on: managerB, - withinNanoseconds: 3_000_000_000 - ) - #expect(discoveredOnB != nil) - #expect(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) - await managerB.stopScanning() + @Test func deferredRestoredScanSurvivesPowerCycle() async throws { + // D-1 event 13: invalidate preserves the deferred restored scan. + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let scanUUID = CBUUID(string: "180D") + await Mock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + await manager.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) + #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + + // A power-cycle invalidate must not drop the deferred scan. + await manager.bluetooth.testInvalidatePeripherals() + #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + + await Mock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + let resumed = await pollUntil(timeout: 3.0) { await manager.bluetooth.testPendingRestoredScanServices() == nil } + #expect(resumed) + await manager.stopScanning() + } + + @Test @MainActor func restoredLinkWithoutHoldIdlesOut() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } + + let restoreId = "com.five3apps.relia-ble.tests.restore-idle-out" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - // Connect only on A; B must not observe connection state for that peripheral. - await managerA.startScanning() - let connectableA = await Mock.waitForDiscovered( - id: Mock.connectionTestPeripheralID, - on: managerA, - withinNanoseconds: 3_000_000_000 - ) - let peripheralA = try #require(connectableA).peripheral - await managerA.stopScanning() + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } - try await peripheralA.connect() + // Wipe the hold so the restored link has no explicit ask. + await manager1.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) + + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId, idleDisconnectInterval: 0.3) #expect(await pollUntil(timeout: 3.0) { - await managerA.currentConnectionStates[peripheralA.id] == .connected + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + // No rehydrated hold → the restored link idles out (D-restore case 2). + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .disconnected(reason: nil) }) - #expect(await managerB.currentConnectionStates[peripheralA.id] == nil) - #expect(await managerB.bluetooth.testIsReconnectEnabled(peripheralA.id) == false) - - // Both stacks still alive after the cross-manager exercise. - #expect(await managerA.bluetooth.hasCentralManager) - #expect(await managerB.bluetooth.hasCentralManager) - await Mock.tearDown(managerA) - await Mock.tearDown(managerB) + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } - @Test func authorizeCancellationDoesNotAffectOtherManager() async throws { - CBMCentralManagerMock.simulateAuthorization(.notDetermined) - - let managerA = await Mock.makeManager(tearDownPrevious: true) - let managerB = await Mock.makeManager(tearDownPrevious: false) + @Test @MainActor func restoredManualHoldSurvivesRelaunch() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } - let taskA = Task { try await managerA.authorizeBluetooth() } - let taskB = Task { try await managerB.authorizeBluetooth() } + let restoreId = "com.five3apps.relia-ble.tests.restore-manual-hold" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - // Both should be suspended on the undetermined decision. - try? await Task.sleep(nanoseconds: 150_000_000) - taskA.cancel() - let resultA = await taskA.result - switch resultA { - case .failure(let error): - #expect(error is CancellationError) - case .success: - // Already resolved if mock auth flipped early — still must not break B. - break - } + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } + #expect(await manager1.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) - // Cancelling A must leave B's waiter intact — grant auth and bounce power so B's - // central receives didUpdateState and resolvePendingAuthorization runs. - CBMCentralManagerMock.simulateAuthorization(.allowedAlways) - CBMCentralManagerMock.simulatePowerOff() - CBMCentralManagerMock.simulatePowerOn() + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) - try await taskB.value - #expect(await managerB.bluetooth.hasCentralManager) + // Short idle interval: a rehydrated hold must suppress idle, so the link stays up. + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId, idleDisconnectInterval: 0.2) + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID)) + try? await Task.sleep(nanoseconds: 600_000_000) + #expect(await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected) - await Mock.tearDown(managerA) - await Mock.tearDown(managerB) + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } - @Test func twoManagersIndependentHandleRegistries() async throws { + @Test @MainActor func restoredHoldWithAutoReconnectFalseSuppressesIdleButNotTier1() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } - let managerA = await Mock.makeManager(tearDownPrevious: true) - await Mock.ensureReady(managerA) + let restoreId = "com.five3apps.relia-ble.tests.restore-hold-false" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - let managerB = await Mock.makeManager(tearDownPrevious: false) - await Mock.ensureReady(managerB) + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + try await handle.connect(autoReconnect: false) + _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } + #expect(await manager1.bluetooth.testPersistedManualConnectHolds()[handle.id] == false) - // Same id string → two distinct handle instances. - let handleA = managerA.peripheral(id: Mock.testPeripheralID) - let handleB = managerB.peripheral(id: Mock.testPeripheralID) - #expect(handleA !== handleB) - #expect(handleA == handleB) - #expect(handleA.hashValue == handleB.hashValue) + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) - let set: Set = [handleA, handleB] - #expect(set.count == 1, "id-only equality means same-id handles from different managers count as one in a Set") + // Case 4: reconnectDesired:false rehydrates — idle suppressed (no teardown) but nothing armed. + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId, idleDisconnectInterval: 0.2) + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) + try? await Task.sleep(nanoseconds: 600_000_000) + #expect(await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected) - // Discover only on A. - await managerA.startScanning() - _ = await Mock.waitForDiscovered( - id: Mock.testPeripheralID, - on: managerA, - withinNanoseconds: 3_000_000_000 - ) - await managerA.stopScanning() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } - #expect(await managerA.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) - #expect(!(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID))) + @Test @MainActor func workLeasesDoNotSurviveRelaunch() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } - // Only A's handle has live metadata. - #expect(handleA.rssi != nil) - #expect(handleB.rssi == nil) + let restoreId = "com.five3apps.relia-ble.tests.work-not-restored" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - await Mock.tearDown(managerA) - await Mock.tearDown(managerB) - } + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + let token = try await handle.acquireWorkLease() + _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } + // Work leases are never persisted. + #expect(await manager1.bluetooth.testPersistedManualConnectHolds().isEmpty) - // MARK: - Event Stream Broadcaster + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) - @Test func stateStreamReplaysToConcurrentSubscribers() async throws { - let manager = await Mock.makeManager() + // Case 3: leases are never rehydrated — the restored residual link has no demand and idles out. + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId, idleDisconnectInterval: 0.3) + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(!(await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID))) + #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .disconnected(reason: nil) + }) - // Two independent streams from two separate property accesses. - var subscriberA = manager.state.makeAsyncIterator() - var subscriberB = manager.state.makeAsyncIterator() + _ = token + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } - // Each subscriber replays the current state as its first element. A shared single stream - // could not replay to both, so independent replay proves each access mints a distinct stream. - let replayA = await subscriberA.next() - let replayB = await subscriberB.next() + /// Force-quit style relaunch with **no** `willRestoreState` still rehydrates the durable hold + /// and reconnects once the peripheral is rediscovered (or retrieved). + @Test @MainActor func coldStartRehydratesHoldWithoutWillRestore() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } - #expect(replayA != nil) - #expect(replayB != nil) - } + let restoreId = "com.five3apps.relia-ble.tests.cold-start-hold" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - @Test func stateBroadcastReachesAllSubscribers() async throws { - let manager = await Mock.makeManager() + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } + #expect(await manager1.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) + let persistedUUID = await manager1.bluetooth.testPersistedHoldCbUUID(for: handle.id) + #expect(persistedUUID != nil, "Hold row must store cbUUID for stable rediscovery") - var subscriberA = manager.state.makeAsyncIterator() - var subscriberB = manager.state.makeAsyncIterator() + // Full process death simulation: tear down stack, reset mock links, **no** state-restoration fixture. + await Mock.tearDown(manager1, resetMockConnections: true) + Mock.clearStateRestoration() - // Drain the replayed element. Awaiting it also guarantees both continuations are registered - // (the replay is yielded during registration), so the broadcast below cannot be missed. - _ = await subscriberA.next() - _ = await subscriberB.next() + let manager2 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager2) - // Force a state broadcast through the real actor path; both live subscribers receive it. - await manager.bluetooth.updateState() + // Disk → memory without willRestore. + #expect(await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID)) + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) - let broadcastA = await subscriberA.next() - let broadcastB = await subscriberB.next() + // Rediscovery (or retrieve) must re-issue connect under the durable hold. + try await manager2.startScanning() + _ = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager2, withinNanoseconds: 3_000_000_000) + #expect(await pollUntil(timeout: 5.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) - #expect(broadcastA != nil) - #expect(broadcastB != nil) + await manager2.stopScanning() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } - @Test func handleOrphansWhenManagerDeallocates() async throws { - // This test is the retain-graph leak detector: if anything reachable from the - // actor holds the manager strongly, the manager won't deallocate and this fails. + /// Hold persisted under a different app-facing id but same `cbUUID` rebinds on rediscovery and + /// still auto-connects. + @Test @MainActor func holdRebindsAcrossNameDerivedIdDrift() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } - let orphanedHandle: Peripheral = await Task { - let manager = await Mock.makeManager(tearDownPrevious: true) - let handle = manager.peripheral(id: "orphaned-by-deinit") - // Shut down the actor so every stream subscription ends — a live subscriber retains the actor by - // design, and the actor is the thing that would drag the manager along if the retain graph were wrong. - await manager.bluetooth.shutdown() - // Drop the harness's own strong reference; otherwise `activeManager` alone keeps the manager alive and - // this test would silently prove nothing. - Mock.releaseActiveManager() + let restoreId = "com.five3apps.relia-ble.tests.hold-id-drift" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - return handle - }.value + try await manager1.startScanning() + let snap = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000) + let handle = try #require(snap).peripheral + await manager1.stopScanning() + try await handle.connect() + _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } + let cbUUID = try #require(await manager1.bluetooth.testPersistedHoldCbUUID(for: handle.id)) + + await Mock.tearDown(manager1, resetMockConnections: true) + Mock.clearStateRestoration() + + // Rewrite disk as if the previous session keyed the hold under a different name-derived id. + let key = "com.five3apps.relia-ble.reconnect-intent.\(restoreId)" + UserDefaults.standard.set( + [[ + "id": "StaleAdvertisedName", + "reconnectDesired": true, + "cbUUID": cbUUID.uuidString, + ]], + forKey: key + ) - // The manager has now fallen out of every scope that held it. If it deallocated, the handle's weak manager - // reference is nil and `connect()` reports `.bluetoothUnavailable`. Anything else — notably `.notFound`, - // which means the manager is somehow still alive and reachable — indicates something reachable from the - // actor is retaining the manager strongly, which is the leak this test exists to catch. - do { - try await orphanedHandle.connect() - Issue.record("Expected connect() on orphaned handle to throw") - } catch let error as PeripheralError { - #expect(error == .bluetoothUnavailable) - } + let manager2 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager2) + + // Rehydrate loads the stale-id row; `retrieveAndBindHeldPeripherals` and/or scan then + // rebind by cbUUID onto the live name-derived id. Either may win first depending on mock timing. + let hasStaleHold = await manager2.bluetooth.testHasManualConnectHold(for: "StaleAdvertisedName") + let hasLiveHold = await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID) + #expect(hasStaleHold || hasLiveHold, "Durable hold must be in memory under the stale id and/or already rebound") + + try await manager2.startScanning() + _ = await Mock.waitForDiscovered(id: Mock.connectionTestPeripheralID, on: manager2, withinNanoseconds: 3_000_000_000) + + // Eventually demand lives only under the resolved id and the link comes back. + #expect(await pollUntil(timeout: 5.0) { + let onLive = await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID) + let onStale = await manager2.bluetooth.testHasManualConnectHold(for: "StaleAdvertisedName") + return onLive && !onStale + }) + #expect(await pollUntil(timeout: 5.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + // Disk should be rewritten under the live id. + #expect(await manager2.bluetooth.testPersistedManualConnectHolds()[Mock.connectionTestPeripheralID] == true) + #expect(await manager2.bluetooth.testPersistedManualConnectHolds()["StaleAdvertisedName"] == nil) + + await manager2.stopScanning() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } } @@ -2228,7 +5166,29 @@ struct ReliaBLEManagerTests { /// instance before a test runs. The delegate is registered once in ``SimulationConfig/ensureConfigured()`` /// and shared across all connection tests via ``Mock/connectionTestDelegate``. final class ConnectionTestDelegate: @unchecked Sendable { - var connectionResult: Result = .success(()) + + /// The connection outcome the mock's main-thread delegate callback returns for an incoming + /// connection request. + /// + /// Written from async test bodies (setting `.failure(...)` before a test and resetting to + /// `.success(())` in `defer` blocks) while read on the main thread by the mock's timer-driven + /// `peripheralDidReceiveConnectionRequest` callback. Access is guarded by `lock` so the two + /// threads never race on the underlying stored value. + var connectionResult: Result { + get { + lock.lock() + defer { lock.unlock() } + return storedConnectionResult + } + set { + lock.lock() + storedConnectionResult = newValue + lock.unlock() + } + } + + private let lock = NSLock() + private var storedConnectionResult: Result = .success(()) } extension ConnectionTestDelegate: CBMPeripheralSpecDelegate { @@ -2325,7 +5285,7 @@ enum Mock { activeManager = nil } - static func tearDown(_ manager: ReliaBLEManager, resetMockConnections: Bool = true) async { + @MainActor static func tearDown(_ manager: ReliaBLEManager, resetMockConnections: Bool = true) async { if resetMockConnections { connectionTestSpec.simulateDisconnection() } @@ -2339,7 +5299,7 @@ enum Mock { /// ``CBMPeripheralSpec``s and scan-service UUIDs (no live `CBPeripheral` / actor state). /// /// **Always** pair with `defer { Mock.clearStateRestoration() }`. - static func installStateRestoration( + @MainActor static func installStateRestoration( restoreIdentifier: String, peripherals: [CBMPeripheralSpec] = [], scanServices: [CBMUUID]? = nil @@ -2357,10 +5317,46 @@ enum Mock { } } - static func clearStateRestoration() { + @MainActor static func clearStateRestoration() { CBMCentralManagerMock.simulateStateRestoration = nil } + // MARK: - Main-Actor Simulation Wrappers + // + // CoreBluetoothMock's simulation API is not thread-safe and must be driven from the main + // thread, where its advertisement `NSTimer` fires. Routing every `simulate*` call through these + // `@MainActor` wrappers serializes the test-driven mutations (made from async test bodies on a + // background concurrency executor) against the mock's own main-thread timers, eliminating the + // SIGSEGV-causing data race on the mock's global mutable state. Tests call these with `await`. + + @MainActor static func simulateAuthorization(_ authorization: CBMManagerAuthorization) { + CBMCentralManagerMock.simulateAuthorization(authorization) + } + + @MainActor static func simulateInitialState(_ state: CBMManagerState) { + CBMCentralManagerMock.simulateInitialState(state) + } + + @MainActor static func simulatePeripherals(_ peripherals: [CBMPeripheralSpec]) { + CBMCentralManagerMock.simulatePeripherals(peripherals) + } + + @MainActor static func simulatePowerOn() { + CBMCentralManagerMock.simulatePowerOn() + } + + @MainActor static func simulatePowerOff() { + CBMCentralManagerMock.simulatePowerOff() + } + + @MainActor static func simulateConnection() { + connectionTestSpec.simulateConnection() + } + + @MainActor static func simulateDisconnection() { + connectionTestSpec.simulateDisconnection() + } + /// Builds manager 2 for a cold relaunch: restores under `restoreIdentifier` when the central /// is created. Caller must have already torn down stack 1 (typically with /// `resetMockConnections: false`) and installed the restoration fixture. @@ -2368,16 +5364,18 @@ enum Mock { /// Leaves authorization undetermined until after stream subscribers are registered, then /// authorizes so `willRestoreState` fires during central init. Poll for settled actor state /// after return — restore side effects are applied asynchronously relative to authorize. - static func makeRestoredManager( + @MainActor static func makeRestoredManager( restoreIdentifier: String, loggingEnabled: Bool = false, - reconnectPolicy: ReconnectPolicy? = nil + reconnectPolicy: ReconnectPolicy? = nil, + idleDisconnectInterval: TimeInterval? = nil ) async throws -> ReliaBLEManager { CBMCentralManagerMock.simulateAuthorization(.notDetermined) let manager = await makeManager( loggingEnabled: loggingEnabled, reconnectPolicy: reconnectPolicy, restoreIdentifier: restoreIdentifier, + idleDisconnectInterval: idleDisconnectInterval, tearDownPrevious: true ) CBMCentralManagerMock.simulateAuthorization(.allowedAlways) @@ -2404,13 +5402,14 @@ enum Mock { /// - Parameter tearDownPrevious: When `true` (default), tears down the suite's previous /// active stack first. Pass `false` only for multi-stack scenarios that keep two managers /// alive (and call ``tearDown(_:)`` on each when done). - static func makeManager( + @MainActor static func makeManager( loggingEnabled: Bool = false, reconnectPolicy: ReconnectPolicy? = nil, restoreIdentifier: String? = nil, + idleDisconnectInterval: TimeInterval? = nil, tearDownPrevious: Bool = true ) async -> ReliaBLEManager { - await SimulationConfig.shared.ensureConfigured() + SimulationConfig.shared.ensureConfigured() if tearDownPrevious, let previous = activeManager { await tearDown(previous) @@ -2419,6 +5418,9 @@ enum Mock { var config = ReliaBLEConfig() config.loggingEnabled = loggingEnabled config.restoreIdentifier = restoreIdentifier + if let idleDisconnectInterval { + config.idleDisconnectInterval = idleDisconnectInterval + } if let reconnectPolicy { config.reconnectPolicy = reconnectPolicy } else { @@ -2440,7 +5442,7 @@ enum Mock { /// earlier test), ensures power is on, triggers central creation if needed, clears any leaked scan, then waits for /// the powered-on state. With `.allowedAlways`, `authorizeBluetooth()` sets up the central and returns without /// suspending. - static func ensureReady(_ manager: ReliaBLEManager) async { + @MainActor static func ensureReady(_ manager: ReliaBLEManager) async { CBMCentralManagerMock.simulateAuthorization(.allowedAlways) // Drop any lingering mock connection so the connectable spec advertises again, then // bounce power so advertising resumes cleanly for a fresh central. @@ -2549,14 +5551,14 @@ enum Mock { /// Process-wide sentinel that performs the Nordic mock's one-time global configuration exactly once. /// -/// `CBMCentralManagerMock.simulateInitialState(_:)` and `simulatePeripherals(_:)` must run once, before any central +/// `await Mock.simulateInitialState(_:)` and `simulatePeripherals(_:)` must run once, before any central /// is created. Keying this off "a central exists yet" is wrong — tests that never create a central (or that create one /// lazily via `authorize()`) would let these run repeatedly. This actor provides a correct one-shot guard. actor SimulationConfig { static let shared = SimulationConfig() - private var configured = false + nonisolated(unsafe) private var configured = false - func ensureConfigured() { + @MainActor func ensureConfigured() { guard !configured else { return } configured = true CBMCentralManagerMock.simulateInitialState(.poweredOn) @@ -2576,6 +5578,31 @@ extension BluetoothActor { var isCentralPoweredOn: Bool { centralManager?.state == .poweredOn } } +// MARK: - Timeout Helper + +/// Sentinel thrown by ``withTimeout(nanoseconds:_:)`` when an operation does not complete in time. +struct TimedOut: Error {} + +/// Executes `operation` but fails with ``TimedOut`` if it does not complete within `nanoseconds`. +/// +/// Prevents an await that *should* return (or throw) from hanging the whole test run when the +/// underlying behavior regresses — the racing sleep converts a wedge into an explicit, bounded failure. +func withTimeout( + nanoseconds: UInt64, + _ operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: nanoseconds) + throw TimedOut() + } + let result = try await group.next()! + group.cancelAll() + return result + } +} + // MARK: - Polling Helper /// Repeatedly evaluates `predicate` until it returns `true` or `timeout` seconds elapse. diff --git a/docs/plans/work-driven-connection-lifecycle-2026-08-02.md b/docs/plans/work-driven-connection-lifecycle-2026-08-02.md new file mode 100644 index 0000000..e9933d2 --- /dev/null +++ b/docs/plans/work-driven-connection-lifecycle-2026-08-02.md @@ -0,0 +1,563 @@ +# Phase 2: Work-Driven Connection Lifecycle (Approach B) — Plan + +Tracking: [#51](https://github.com/Five3Apps/ReliaBLE/issues/51) (parent) with sub-issues +[#57](https://github.com/Five3Apps/ReliaBLE/issues/57) PoweredOn await, +[#58](https://github.com/Five3Apps/ReliaBLE/issues/58) Idle + Manual connect hold, +[#59](https://github.com/Five3Apps/ReliaBLE/issues/59) Approach B reconnect gating, +[#60](https://github.com/Five3Apps/ReliaBLE/issues/60) Work-driven auto-connect. + +## Goal + +Make work drive the link: a `Peripheral` connects because pending work needs a connection (not because the app called `connect`), tears the link down after a configurable idle interval (default 5s) when no work and no manual-connect hold remain, gates Approach B reconnect tiers on that same work/hold signal, and awaits a usable radio instead of silently no-op'ing when Bluetooth is not `poweredOn`. + +## Background + +Curated from Phase 2 exploration. All `file:line` refs are against the `50-peripheral-handle-type-model` branch (Phase 1 landed here). + +### PRD requirements in scope + +- **Architecture / Connection model (work-driven primary):** non-empty per-`Peripheral` command queue causes auto-connect; empty queue + no manual-connect hold starts idle disconnect (global config, default 5s); Manual connect uses the *same* ensure-linked path — "not a second connection stack or either-or mode enum". +- **FR-1.2 (Approach B):** Tier-0 = OS `CBConnectPeripheralOptionEnableAutoReconnect`, enabled on work-driven connects while linked, **ended** when idle teardown or intentional disconnect cancels the connection. Tier-1 = library exponential-backoff ladder, armed on unexpected disconnect **only while** work pending (or a manual-connect hold with reconnect). Disarmed when quiet. Accepted gap: during the idle grace window Tier-0 may reconnect once with an empty queue — if still quiet and no manual-connect hold, cancel again. On reconnection, services/characteristics must be re-discovered (FR-10.6/FR-10.3) — *Phase 3 work, not this plan*. +- **FR-1.3.1 / FR-11.5:** connection-state observation remains; must distinguish intentional disconnect, unexpected drop, and reconnecting. +- **FR-1.4 / FR-8.6:** scan, connect, and command submission **await** `PoweredOn` rather than silently no-op'ing. Terminal unusable states fail promptly with typed errors. Bluetooth state remains observable for UI gating. +- **FR-1.5:** idle disconnect when no pending/queued commands and no manual-connect hold; **default 5 seconds**; configuration is **global**, not per-peripheral. +- **FR-4.4:** enqueueing/running a command on a disconnected `Peripheral` must auto-connect (and run discovery to ready as needed) without a prior Manual `connect`, "unless product policy for never-seen ids chooses fail-fast (implementation planning)". +- **FR-5.2.1:** minimum viable command queue is **serial (one-wide) per peripheral** — ship that before prioritization. Full FR-4/FR-5 must not ship before FR-10 (Phase 3). +- **FR-9.2:** log idle connect/disconnect and Manual connect/disconnect (connection/disconnection and scan start/stop logging already done). +- **FR-11.1–11.5:** work-driven connect without prior Manual `connect`; `Peripheral.connect(autoReconnect:)` sets a manual-connect hold suppressing idle, `Peripheral.disconnect()` clears the hold and intentionally cancels; idle teardown cancels the connection (dropping Tier-0); **single ensure-linked state machine** shared by both paths; connection-state observation distinguishes intentional/unexpected/reconnecting. +- **NFR-2.1:** unit tests for all public API methods, ≥80% of code paths. **NFR-1.3:** all CoreBluetooth objects stay in one internal isolation domain; public handles forward by id; no per-peripheral actors owning `CBPeripheral`. + +Concrete numbers the PRD actually names: idle default **5s**, coverage **80%**. It names **no** backoff intervals, retry caps, idle-grace duration, or connect/discovery timeouts — those are implementation choices for this plan. + +### Current implementation surface + +**`Sources/ReliaBLE/BluetoothActor.swift`** (single per-manager `actor`, owns all CB objects): + +- Connect/disconnect: `func connect(id:autoReconnect:) throws` (:1068), `func disconnect(id:) throws` (:1106). Guards are `!isShutdown`, `centralManager != nil` (→ `PeripheralError.bluetoothUnavailable`), and a live `cbPeripherals[id]` (→ `notFound`, :1116). **Neither checks `centralManager.state`.** The full connect path is: `Peripheral.connect` → `ensureCentralManager()` → actor `connect` → guards → mutate `reconnectEnabled` / `intentionalDisconnects` → optimistic `.connecting` → `centralManager.connect` with the optional Tier-0 option → delegate → `handleDidConnect` / `handleDidFailToConnect` / `handleDidDisconnect` → `setConnectionState` and, if `reconnectEnabled`, `armReconnect`. +- Tier-0 today: `connect` sets `[CBConnectPeripheralOptionEnableAutoReconnect: true]` under `#available(macOS 14.0, iOS 17.0, *)` when `autoReconnect == true` (:1093), then `centralManager.connect(cbPeripheral, options:)` (:1095). There is **no** path that later cancels Tier-0 on idle. +- Connection state: `var connectionStates: [String: ConnectionState]` (:201); single write path `setConnectionState(_:for:)` (:1136) which mirrors to the handle registry and broadcasts a `ConnectionStateChange`; `clearConnectionStates()` (:1158). +- Reconnect state: `reconnectEnabled: Set` (:211), `intentionalDisconnects: Set` (:212), `reconnectAttempts: [String: Int]` (:213), `taskRegistry` (`nonisolated let`, NSLock-protected `[String: Task]`, :149). +- Tier-1 ladder: `armReconnect(id:)` (:1245) → `scheduleReconnect(id:attempt:)` (:1263) computes jittered exponential delay, sets `.reconnecting(source: .library, attempt:nextRetryAt:)`, spawns `Task { try await Task.sleep(nanoseconds:); await performReconnect(...) }` (:1290) registered in `taskRegistry`; `performReconnect` (:1305); `clearReconnectState(for:)` (:1324) cancels the task and clears attempts/intent. +- Intentional vs unexpected: `disconnect` inserts into `intentionalDisconnects` (:1120); `handleDidDisconnect` removes + early-returns to `.disconnected(reason: nil)` (:1173); otherwise arms Tier-1. `payload.isReconnecting` maps to `.reconnecting(source: .system)` (:1184). +- Radio gating today: `startScanning` has `guard centralManager.state == .poweredOn else { warn; return }` (:625) — **the silent no-op FR-1.4 targets**. `resumeRestoredScan` stashes into `pendingRestoredScanServices` and defers (:819). `updateState()` (:690) maps `CBManagerState` → `BluetoothState`. `handleCentralManagerStateUpdate()` (:837): on `.poweredOn` refreshes peripherals + resumes restored scan; on `.resetting`/`.unsupported`/`.unauthorized` calls `invalidatePeripherals()`; always calls `updateState()` + `resolvePendingAuthorization()`. +- **Existing await-until-ready precedent:** `suspendForAuthorizationDecision(id:)` (:530) uses `withCheckedThrowingContinuation` into `authorizationContinuations[id]`, resolved by `resolvePendingAuthorization()` (:586) on every state update, with cancellation wired through `cancelAuthorizationContinuation` from `ReliaBLEManager.authorizeBluetooth`'s `withTaskCancellationHandler`. This is the pattern a PoweredOn await should mirror. +- Delegate plumbing: two `NSObject` shims — `BluetoothDelegateShim` (:1527) and `RestoringBluetoothDelegateShim` (:1581, adds `willRestoreState` at :1624) — forward via `DelegateEventForwarder` into an `AsyncStream` drained by a single consumer `Task` calling `process(event)` (:478/:502/:521). Shims must be kept in sync. + +**Other files:** + +- `Sources/ReliaBLE/ReliaBLEConfig.swift`: `ReliaBLEConfig` (:48) — `logLevels`, `logWriters`, `logQueue`, `loggingEnabled`, `reconnectPolicy`, `restoreIdentifier`. `ReconnectPolicy` (:83) — `maxAttempts = 5`, `initialDelay = 1.0`, `maxDelay = 30.0`, `jitter = 0.2`. No idle-interval knob yet. +- `Sources/ReliaBLE/Models/Peripheral.swift`: `public final class Peripheral: Sendable, Identifiable, Hashable` (:104) with sync `Mutex`-backed metadata (`cbIdentifier`, `name`, `rssi`, `lastSeen`, `advertisement`, `connectionState`), `public func connect(autoReconnect: Bool = true) async throws` (:165) and `public func disconnect() async throws` (:176), each doing `await manager.bluetooth.ensureCentralManager()` then forwarding by id (:170/:181). +- `Sources/ReliaBLE/PeripheralHandleRegistry.swift`: `peripheral(id:)` (:140) interns one `Peripheral` per id per manager behind a `Mutex`; `applyDiscovery(...)`, `applyConnectionState(id:state:)` (skips interning on `nil` clear); `removeAllHandles` on shutdown. +- `Sources/ReliaBLE/Models/PeripheralError.swift` (:32): `public enum PeripheralError: Error, Sendable, Equatable` — `notFound`, `bluetoothUnavailable`, `connectionFailed`, `connectionTimeout`, `peripheralDisconnected`, `unknown`, plus `fromCBError(_:)`. `ReliaBLEManager.swift:296`: `public enum AuthorizationError` — `denied`, `restricted`, `unknown`. These are the only error types. +- `Sources/ReliaBLE/ReliaBLEManager.swift`: public surface is `loggingService` (:39), `state` (:101), `currentState` (:108), `connectionStateChanges` (:122), `currentConnectionStates` (:128), `authorizeBluetooth()` (:142), `peripheralDiscoveries` (:165), `discoveredPeripherals` (:172), `startScanning(services:)` (:183), `stopScanning()` (:189), `peripheral(id:)` (:219). `BluetoothState` enum (:236): `scanning`, `ready`, `poweredOff`, `resetting`, `unauthorized(AuthorizationStatus)`, `unsupported`, `unknown`. +- **No time/clock abstraction exists.** The only scheduling call sites in `Sources/` are `scheduleReconnect` (:1263) and its `Task.sleep(nanoseconds:)` (:1290). + +**Test harness** — `Tests/ReliaBLETests/ReliaBLEManagerTests.swift` (single 2689-line file; `Tests/ReliaBLETests/Mocks/` is empty): + +- swift-testing (`import Testing`, :28), `@Suite(.serialized)`, `@testable import ReliaBLEMock`. +- `Mock.makeManager(loggingEnabled:reconnectPolicy:restoreIdentifier:tearDownPrevious:)` (:2445); one-time `SimulationConfig.ensureConfigured()` (:2529) doing `CBMCentralManagerMock.simulateInitialState(.poweredOn)` + `simulatePeripherals([...])` + authorization. +- Peripheral specs via `CBMPeripheralSpec.simulatePeripheral(proximity:).advertising(...).connectable(name:services:delegate:).build()` (:2494/:2505); connection outcomes driven by `ConnectionTestDelegate` (:2230) whose `connectionResult` is a settable `Result`. +- Timing is all wall-clock: `pollUntil(timeout:)` (:2604), `firstEvent(from:withinNanoseconds:)` (:2623/:2640), `drainConnectionStateChanges(...)` (:2655), ~50 raw `Task.sleep` / `withinNanoseconds` sites. No `confirmation()`, no XCTest expectations. +- `Package.swift`: three targets (`ReliaBLE`, `ReliaBLEMock` excluding `CBCentralManagerFactory.swift` + `Documentation.docc`, `ReliaBLETests` depending only on `ReliaBLEMock`), all with `.swiftLanguageMode(.v6)` + `.enableExperimentalFeature("StrictConcurrency")`. + +### Prior-art decisions that constrain this work + +- **`docs/plans/auto-reconnect-backoff-2026-07-05.md`**: established the two-tier model, `ConnectionState.reconnecting(source:attempt:nextRetryAt:)`, `armReconnect` as the single choke point, and `intentionalDisconnects` for clean-vs-unexpected. Explicitly left open: `handleDidDisconnect` honoring `isReconnecting`, and the OS give-up budget (undocumented, needs on-device verification). Its critique (`docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md`) flagged `reconnectTasks` races (cancel during sleep + second schedule) and incomplete `intentionalDisconnects` clearing paths on fail/give-up/success — both still relevant to the gating work. +- **`docs/plans/connection-lifecycle-stream-2026-06-30.md`**: `connectionStateChanges` is the no-replay primary stream, `currentConnectionStates` the snapshot; optimistic `.connecting`/`.disconnecting` before the CB call; ordering guaranteed by actor serialization; only `PeripheralError` surfaces (never raw `Error`). +- **`docs/plans/background-scanning-state-restoration-2026-07-13.md`**: `willRestoreState` re-wires delegate-less `CBPeripheral`s, seeds `connectionStates` from `peripheral.state`, and **re-arms `reconnectEnabled`** for standing connects (persisted in `UserDefaults` keyed by `restoreIdentifier`). Restored links are not re-issued. Any work/hold gating must decide what a restored link means when there is no work and no hold. +- **`docs/plans/peripheral-handle-type-model-2026-07-31.md`** (Phase 1, just landed): handles interned per id per manager; `connect`/`disconnect` moved onto `Peripheral` and the manager-level methods were **removed outright**; connection state mirrored to the handle; registry cleared on shutdown; lock order registry-then-handle; retain graph is actor → bridge → registry → **weak** manager. It explicitly deferred to #57: "connect does NOT await; preserves today's ensure-then-throw". +- **Mock limitations (`docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md`, issues [#40](https://github.com/Five3Apps/ReliaBLE/issues/40) / [#42](https://github.com/Five3Apps/ReliaBLE/issues/42))**: CoreBluetoothMock simulates `isReconnecting: true` but **never emits the true→false give-up transition** (#40), so "OS gave up → hand off to Tier-1" is untestable through normal mock disconnects; the accepted workaround is an internal `testInjectDisconnect(for:isReconnecting:error:)` hook that bypasses the shim. `willRestoreState` cannot be synthesized post-init (#42). Simulating connection-cancel semantics and timers is called out as *not* provided by the mock — library-side timing is ours to control. + +## Design + +### D-0 Resolved decisions + +Every open question this plan started with is resolved here. Decisions marked **(user)** were confirmed directly and override the generated draft; the rest are plan defaults with their rationale. + +| ID | Decision | Rationale | +|----|----------|-----------| +| **D-deliv** **(user)** | **One branch `51-work-driven-connection-lifecycle`, one PR** closing #51, with #57–#60 as ordered green commits referenced as checklist items in the PR body. | #58/#59/#60 all read the same link-demand signal and the same `ensureLinked` path. Splitting them forces a throwaway dual connect path between merges. #57 is separable in commits but its only call sites are scan/connect, which the same PR rewrites. | +| **D-work** **(user)** | Interim work signal is an **internal, actor-isolated refcounted work lease**: `acquireWorkLease(id:)` / `releaseWorkLease(_:)`. **No public API this phase.** Test access via `@testable` hooks. | Zero public surface to un-ship when FR-4/FR-5 land. The lease refcount is the exact dual of "command queue non-empty", so Phase 3 makes the queue one lease source without redesigning `ensureLinked`. #60's "documented way for work to drive connect" is satisfied by internal docs + tests until the queue ships. | +| **D-radio** **(user)** | **Fail fast, typed:** `.poweredOff` → `PeripheralError.bluetoothPoweredOff`; `.unsupported` → `PeripheralError.bluetoothUnsupported`; `.unauthorized` → `PeripheralError.bluetoothUnavailable`; no central / shut down → `.bluetoothUnavailable`. **Await:** `.resetting`, `.unknown`, and the pre-first-`centralManagerDidUpdateState` window. **No timeout** on the await; task cancellation is the exit. A waiter parked on `.resetting`/`.unknown` that resolves to `.poweredOff` **fails** with `bluetoothPoweredOff` rather than continuing to wait. | User-disabled Bluetooth is a decision the app must surface, not a condition to hang on — a scan that silently blocks forever is the same usability failure as today's silent no-op, just relocated. `.resetting`/`.unknown` are genuinely transient and self-resolve, satisfying #57's "transient unknown/resetting can complete when PoweredOn arrives". | +| **D-restore** **(user, revised 2026-08-05; identity/cold-start 2026-08-11)** | **Manual-connect holds are durable; work leases are not.** Persistence stores a **hold record list** (`id`, `reconnectDesired`, `cbUUID`), not a reconnect-enabled set. Holds are rehydrated whenever `restoreIdentifier` is set: `handleWillRestoreState` **and** first `.poweredOn` load disk even if the OS restores **no** peripherals (typical after user force-quit). Four cases when the OS *does* restore a peripheral: (1) hold persisted for that radio (by id or `cbUUID`) → rehydrate `manualConnectHold`, sync intent, **no idle timer**, `reevaluateLink` if not linked. (2) OS restores a link with **no** persisted hold → **idle timer** starts. (3) Work leases **never** survive process death. (4) A persisted hold with `reconnectDesired: false` survives: it suppresses idle but arms neither tier and is not re-issued on radio return. Name-derived `id` may drift across launches; `cbUUID` is the stable match key (interim until FR-8.5). | A manual `connect` is an explicit "keep this link" instruction; when the app has configured state restoration, that instruction should outlive relaunch **even when CoreBluetooth delivers an empty or missing restore dictionary**. NFR-3.2 is still honored where it matters — restored links the app never explicitly asked for still idle out. Work is inherently process-scoped, so leases must not be resurrected. **This reverses the earlier decision that restoration never re-applies a hold and that the restore-time read should be deleted.** | +| **D-never** | Work-driven connect (and lease acquisition) on an id with no live `CBPeripheral` **fails fast** with the existing `PeripheralError.notFound`. No await-for-discovery, no implicit scan, **and no scan-on-`notFound` retry loop**. Recovery after an invalidate is **retrieve-only** — `refreshPeripherals()` → `retrievePeripherals(withIdentifiers:)` recovers known ids after a power cycle and ids still in the CoreBluetooth system cache; anything not retrievable is terminal for the automatic path. | FR-4.4 explicitly delegates this to implementation planning. Awaiting discovery couples the work path to scan policy and creates an unbounded wait with no cancellation story distinct from D-radio's. **Library-owned continuous or demand-driven scanning is FR-8.2 / FR-4 territory, deliberately not Phase 2** — inventing a "scan until found" stack here would be the second connection stack FR-11.4 forbids. The door stays open: FR-8.2 can later add scan policy and call `reevaluateLink` on discovery, which is purely additive. | +| **D-idle** | `ReliaBLEConfig.idleDisconnectInterval: TimeInterval = 5.0`, global (FR-1.5). The Tier-0 grace window reuses the **same** interval — one knob, not two. | PRD names 5s and names no grace duration. A second knob would be unexplained configuration surface. | +| **D-time** | **No `Clock` protocol this phase.** Add a test-only `setIdleDisconnectInterval(_:)` actor hook mirroring the existing `setReconnectPolicy(_:)`; production reads the value from config at init. Tests run idle at 0.05–0.2s using the existing `pollUntil` / `drain*` helpers. | The only scheduling in `Sources/` today is `scheduleReconnect`'s `Task.sleep`, and `ReconnectPolicy` already proves the interval-override pattern works for timing tests. A full injectable clock is a larger refactor than this phase needs and would touch every existing reconnect test. | +| **D-hold** **(user, revised 2026-08-05)** | `Peripheral.connect(autoReconnect:)` sets a manual-connect hold whose `reconnectDesired` is the `autoReconnect` argument. `Peripheral.disconnect()` clears the hold and intentionally cancels. Work leases **never** set a hold. **The hold is registered *before* the radio wait, not after** — see D-3 for the normative call sequence. The public parameter name stays `autoReconnect`. | FR-11.2 verbatim, keeping "hold" and "work" as two independent demand sources feeding one derived signal. The ordering matters: if the hold were set only after `waitUntilPoweredOn()` succeeded, calling `connect(autoReconnect: true)` while Bluetooth is off would throw and leave **no** demand behind, so the radio returning would do nothing. That contradicts what `autoReconnect: true` means — connect whenever this peripheral is available. Setting the hold first makes the throw informational rather than destructive. | +| **D-tier** | Tier-0 (`CBConnectPeripheralOptionEnableAutoReconnect`) is passed on every connect issued through `issueConnect` when `wantsReconnect(id)` is true. Tier-1 `armReconnect` is gated on the same predicate. Idle teardown and intentional disconnect both cancel the connection, which is what ends Tier-0. | FR-1.2 / FR-11.3. Replaces today's meaning of `reconnectEnabled` ("the last connect asked for autoReconnect") with "demand currently wants a link back". | +| **D-ensure** | A single `ensureLinked(id:)` / `reevaluateLink(id:)` path. Manual connect and work-lease acquisition both call it. `centralManager.connect` is called from exactly one private function, `issueConnect(id:enableAutoReconnect:)`. | FR-11.4 — "no parallel connection stacks" is enforceable only if there is one call site. | + +### D-1 The ensure-linked state machine + +All state and mutation is actor-isolated on `BluetoothActor`. Per peripheral id: + +**Demand is derived, never stored directly:** + +```swift +// demand(id) = workCount[id, default: 0] > 0 || manualConnectHold[id] != nil +// wantsReconnect(id) = workCount[id, default: 0] > 0 || manualConnectHold[id]?.reconnectDesired == true +``` + +**New per-id bookkeeping on the actor:** + +```swift +struct ManualConnectHold: Sendable { + var reconnectDesired: Bool + var cbIdentifier: UUID? // CoreBluetooth peripheral UUID when known +} + +/// Live lease IDs per peripheral. `workCount(id)` is `activeLeases[id]?.count ?? 0`. +/// A `Set` rather than a bare `Int` so an already-released token is *detectable*: +/// a refcount alone cannot distinguish a double-release of token A from a legitimate +/// release of token B, and would tear down a link that still has work outstanding. +private var activeLeases: [String: Set] = [:] +private var manualConnectHold: [String: ManualConnectHold] = [:] +private var idleGeneration: [String: UInt64] = [:] +private nonisolated let idleTaskRegistry = TaskRegistry() +``` + +**Why `reevaluateLink` needs a reason.** A hold created by `connect(autoReconnect: false)` makes `demand` true but `wantsReconnect` false. If the connect-issuing arm were gated on bare `demand`, the radio-return sweep would silently reconnect a link the app explicitly declined to auto-reconnect — while events 9–11 refuse to. If it were gated on `wantsReconnect`, the *initial* `connect(autoReconnect: false)` would never link at all. The caller's reason disambiguates: + +```swift +enum LinkReason: Sendable { + case explicitConnect // Peripheral.connect — always issues + case workAcquired // lease taken — wantsReconnect is true by construction + case radioReturned // sweep after .poweredOn + case relinkAfterIntentional // Manual disconnect that raced pending work + case discoveredWhileDemanded // optional polish — see event 15 +} + +private func reevaluateLink(id: String, reason: LinkReason) throws +``` + +The issue arm requires `wantsReconnect(id) || reason == .explicitConnect`. Bare `demand` governs idle suppression only. + +`reconnectEnabled` stops being the gate and becomes a value **synced** from `wantsReconnect(id)` by `syncReconnectIntent(id:)` after every demand change. `intentionalDisconnects`, `reconnectAttempts`, and the existing `taskRegistry` keep their current meaning. + +**Phases** (conceptual — deliberately not a public enum; the app-visible surface stays `ConnectionState`): + +| Phase | Meaning | +|-------|---------| +| `Quiet` | No demand. May still be CB-connected during a grace window. | +| `AwaitingRadio` | Demand present, parked on a PoweredOn continuation, or waiting for the radio to return after an invalidate. **Publicly visible as `.reconnecting(source: .library, attempt: nil, nextRetryAt: nil)` when `wantsReconnect(id)`** — see event 13. | +| `Linking` | Connect in flight — `.connecting`, or `.reconnecting` from either tier. | +| `Linked` | `.connected` with demand present. | +| `IdleGrace` | Was linked or linking; demand dropped to zero; idle timer running. | +| `TearingDown` | Intentional cancel in flight — `.disconnecting`. | + +**Events and transitions (normative):** + +1. **`acquireWorkLease(id:)`** — await radio per D-radio; fail `notFound` if no live `CBPeripheral`; insert a fresh `UUID` into `activeLeases[id]`; cancel any idle timer; `syncReconnectIntent`; `reevaluateLink(id:reason: .workAcquired)`; return the token. +2. **`releaseWorkLease(_:)`** — remove `token.leaseID` from `activeLeases[token.id]`; if it was not present, **no-op** (unknown or already-released token) and return. Otherwise `syncReconnectIntent`, and if demand is now false → `beginIdleGrace(id:)`. +3. **`applyManualConnectHold(id:reconnectDesired:)`** — hold registration **only**: set `manualConnectHold[id] = ManualConnectHold(reconnectDesired:)`; cancel any idle timer; clear `intentionalDisconnects[id]`; `syncReconnectIntent` (the **sole** persistence writer — see D-4). It deliberately does **not** wait for the radio and does **not** issue a connect. `Peripheral.connect` calls this *before* `waitUntilPoweredOn()`, then calls `reevaluateLink(id:reason: .explicitConnect)` after the wait succeeds (D-hold, D-3). Splitting the two is what lets a `connect` that throws `bluetoothPoweredOff` still leave durable demand behind. +4. **`applyManualDisconnect(id:)`** — clear `manualConnectHold[id]` **unconditionally**; `syncReconnectIntent`; cancel the idle timer and the Tier-1 ladder; then tear down per the settling rule: if the peripheral is `.connected`, run the intentional-cancel path (`intentionalDisconnects.insert`, `.disconnecting`, `cancelPeripheralConnection`); otherwise settle synchronously to `.disconnected(reason: nil)`. **Returns success when there is no live `CBPeripheral` or nothing to cancel** — dropping a hold during a radio outage must not throw `notFound`, because that is the only way to drop demand while the radio is down. Does not start an idle timer. +5. **`reevaluateLink(id:reason:)`** — the single ensure-linked entry point. + - If `!demand(id)` → return. + - Re-check `centralManager.state` per D-radio. A waiter resumed at `.poweredOn` runs on a later actor turn, by which time the radio may have flipped again; on regression, throw the matching typed error rather than issuing against a dead radio. + - No `cbPeripherals[id]` → throw `notFound`. + - Already `.connected` → verify against `cbPeripherals[id]?.state`, not the cached `connectionStates` value (see D-7's radio-cycle row); if genuinely connected, `syncReconnectIntent` only. **Tier-0 cannot be flipped on a live link** — CoreBluetooth has no API to change connect options mid-connection. If a link is up without Tier-0 and demand now wants it, the option applies on the next connect issue. Documented limitation, not a bug. + - Already `.connecting`, or `.reconnecting(source: .system)` → `syncReconnectIntent` only. + - Otherwise → issue only if `wantsReconnect(id) || reason == .explicitConnect`, via `issueConnect(id:enableAutoReconnect: wantsReconnect(id))`. + - **Error surfacing:** two callers (event 9's relink branch and event 12's sweep) run in delegate context and cannot propagate a throw. They must publish `.failed(reason: .notFound)` through `setConnectionState` rather than logging and dropping — otherwise a work lease is stranded with no signal on any stream. + - **`notFound` is terminal for the automatic path.** There is no scan and no retry loop behind it (D-never). Crucially, **demand is retained** — the lease or hold stays exactly as the app left it, and is dropped only by an explicit `disconnect()` or lease release. If the app later scans and the peripheral is rediscovered, event 15 relinks it. Phase 3 should treat `.failed(reason: .notFound)` as a *command* failure, decided independently of lease lifetime. +6. **`beginIdleGrace(id:)`** — only when `!demand(id)` **and** the peripheral is `.connected`, `.connecting`, or `.reconnecting(source: .system)`. If the state is library-`.reconnecting`, `.disconnected`, or `.failed`, there is nothing to tear down: cancel the ladder task and settle to `.disconnected(reason: nil)` synchronously, with no timer and no CB cancel. Otherwise increment `idleGeneration[id]`, capture it, cancel any prior idle task, and schedule `Task { try await Task.sleep(for: .seconds(idleDisconnectInterval)); await fireIdle(id:generation:) }` in `idleTaskRegistry`. Log at info per FR-9.2. +7. **`fireIdle(id:generation:)`** — no-op if the generation is stale or demand returned. Otherwise log the idle disconnect and run the intentional-cancel path (which drops Tier-0). Resulting state is `.disconnecting` → `.disconnected(reason: nil)` — indistinguishable from an app disconnect to the reconnect policy, which is exactly FR-11.5's "intentional". +8. **`handleDidConnect`** — clear reconnect attempts as today. If `!demand(id)` → `beginIdleGrace(id:)` immediately (this is the accepted Tier-0 blip path from FR-1.2). Else remain Linked and `syncReconnectIntent`. +9. **`handleDidDisconnect`** — + - Intentional → clean `.disconnected(reason: nil)`, clear attempts. **Then, if any lease is live for that id, call `reevaluateLink(id:reason: .relinkAfterIntentional)`** so a Manual `disconnect()` that races pending work does not strand it (see the edge-case table). + - `isReconnecting == true` (Tier-0 in progress) → if `wantsReconnect(id)`, publish `.reconnecting(source: .system)` and let the OS work. If **not**, do not trust Tier-0: publish `.disconnected(reason: nil)` immediately and call `cancelPeripheralConnection` as fire-and-forget suppression. Per the settling rule, do **not** insert into `intentionalDisconnects` — the peripheral is already physically disconnected, so there is no `.disconnecting` to settle, and a late callback classified as unexpected is harmless because `armReconnect` is gated off by `wantsReconnect`. This branch cannot loop: `!wantsReconnect` implies no live leases, so the relink branch above is unreachable from it. + - Otherwise unexpected → set disconnected with the mapped reason, and `armReconnect(id:)` **only if `wantsReconnect(id)`**. +10. **`handleDidFailToConnect`** — `.failed`, then `armReconnect(id:)` only if `wantsReconnect(id)`. +11. **`armReconnect(id:)`** — gate changes from `reconnectEnabled.contains(id)` to `wantsReconnect(id)`. `ReconnectPolicy` (attempts, delay, jitter) is unchanged. `performReconnect` routes through `issueConnect(id:enableAutoReconnect: wantsReconnect(id))`. +12. **Radio reaches `.poweredOn`** — resume PoweredOn waiters; **rehydrate disk holds if not already loaded** (covers relaunch with no `willRestoreState`); `retrievePeripherals` for held `cbUUID`s and bind them (prefer the durable hold id when there is no advertisement); then for every id with demand that is not linked, `reevaluateLink(id:reason: .radioReturned)`; for restored links with no demand, `beginIdleGrace(id:)` (D-restore). Automatic `reevaluateLink` with a hold but no live `CBPeripheral` waits for retrieve/discovery rather than publishing terminal `.notFound`; explicit `connect` / work-lease still throw `.notFound`. + +15. **Discovery upserts an id that already has demand** *(optional Phase 2 polish — implement if cheap, otherwise defer to FR-8.2)* — call `reevaluateLink(id:reason: .discoveredWhileDemanded)`. This starts **no** scan of its own; it only links opportunistically when something else (an app-driven scan, typically) discovers a device that demand is already waiting on. It is the cheap recovery path for the `notFound` terminal case above, and it is the exact hook FR-8.2 will reuse when library-owned scanning arrives. +13. **`invalidatePeripherals()`** — the normative per-id sequence, in order: + 1. For each tracked id, emit `.disconnected(reason: .bluetoothUnavailable)`. The link really is dead; observers must be told. + 2. Clear the CB maps; cancel idle and ladder tasks; **preserve `activeLeases` and `manualConnectHold`**. Demand survives a radio outage — this mirrors how handles keep their metadata across invalidation. + 3. For each id where `wantsReconnect(id)`, immediately publish `.reconnecting(source: .library, attempt: nil, nextRetryAt: nil)`. This is the public projection of the `AwaitingRadio` phase and holds until `issueConnect` moves it to `.connecting`, the ladder supplies real attempt values, the link succeeds, or demand is cleared. + 4. Ids with demand but **not** `wantsReconnect` — i.e. a `connect(autoReconnect: false)` hold — get **no** `.reconnecting` signal. Settling at disconnected is the honest state: nothing will re-issue for them (event 12's issue gate refuses), so claiming "reconnecting" would be a lie. + 5. Ids with no demand are simply untracked: the handle's `connectionState` goes `nil` after the clear, and step 1's emission is the only stream event. + 6. On radio return, event 12 drives `issueConnect` → `.connecting` → `.connected` / failure / ladder exactly as today. + + **Why step 3 exists.** Without it, a radio drop leaves every auto-relinking peripheral looking permanently gone — `.disconnected(.bluetoothUnavailable)` on the stream and `nil` on the handle — with no way for the app to distinguish "the library will bring this back" from "this is over." There is no public demand API in Phase 2 (D-work), so `ConnectionState` is the *only* channel that can carry that distinction. This likely means splitting or replacing the current bulk `clearConnectionStates()` with a per-id policy rather than a blanket nil-out. + + Three changes to the existing implementation are required: + - **Add `.poweredOff` to the invalidate triggers.** Today `handleCentralManagerStateUpdate` (near `:849`) invalidates on `.resetting` / `.unsupported` / `.unauthorized` and explicitly skips `.poweredOff`. CoreBluetooth invalidates `CBPeripheral` objects across a power cycle, and without this the cached `connectionStates[id]` still reads `.connected` after off→on, so event 12's `reevaluateLink` takes the already-connected arm and a demanded link is **never** re-established. Existing tests that power-cycle will observe the new emissions and need updating. + - **Stop writing persistence from invalidate.** `invalidatePeripherals` currently does `reconnectEnabled.removeAll()` followed by `persistReconnectIntent()` (near `:1000`), which writes an *empty* payload to UserDefaults — so a transient `.resetting` blip erases the persisted hold map and breaks D-restore across a relaunch. Drop that call entirely; **invalidate must never touch disk.** + - **Preserve the deferred restored scan.** `invalidatePeripherals` currently nils `pendingRestoredScanServices` and its options, so a warm power-off can silently drop a restored scan that had not yet resumed. On the `.poweredOff` / `.resetting` paths, **preserve** them. This is a deliberately minimal change: it does not create continuous-scan demand and does not make a lease or hold imply "keep scanning" — that policy is FR-8.2's. If preserving turns out to require inventing scan-demand semantics, leave the clearing as-is and record a one-line open item owned by FR-8.2: *power-cycle may drop a deferred restored scan until continuous-scan demand is defined.* +14. **`shutdown()`** — additionally fail all pending PoweredOn waiters, clear holds and leases, and clear both task registries. **`shutdown()` clears volatile state only and must never write to UserDefaults.** In particular, clearing the in-memory hold map must not route through `syncReconnectIntent` or any other persistence writer — an empty flush at shutdown would erase exactly the restore intent D-restore depends on, and a manager torn down in the background would silently destroy the app's standing session. The existing `testClearPersistedReconnectIntent` hook remains for tests; an explicit public reset API, if ever wanted, is out of scope here. + +**Invariant to assert in review:** `centralManager.connect(_:options:)` appears exactly once in the codebase, inside `issueConnect`. This is achievable today — `connect` (`BluetoothActor.swift:1095`) is the only call site, the restore path deliberately never connects (see its "Do not reconnect here" comment near `:764`), and `performReconnect` reaches CoreBluetooth through `connect`. **The extraction must be side-effect free:** today's `connect` body also mutates `reconnectEnabled` (`:1083`–`:1085`), calls `persistReconnectIntent()` (`:1087`), and clears `intentionalDisconnects` (`:1088`). If `performReconnect` routed through an un-stripped extraction, every ladder attempt on a *work-driven* link would persist reconnect intent and violate D-4's hold-driven-only persistence rule. `issueConnect` must be exactly: optimistic `.connecting` → build options → `centralManager.connect`. Nothing else. + +**Settling optimistic states.** Three paths cancel a connection that may not be in a state CoreBluetooth acknowledges — idle fire, the untrusted-Tier-0 branch, and idle grace armed during `.connecting`. CoreBluetooth does not guarantee a `didDisconnect` callback for a cancel issued against a pending connect or a peripheral in OS-reconnect limbo. **Rule: never publish an optimistic `.disconnecting` unless the peripheral is currently `.connected`.** Where it is not, settle synchronously to `.disconnected(reason: nil)` and do **not** insert into `intentionalDisconnects` — if CoreBluetooth does deliver a late `didDisconnect`, it is then classified as unexpected, and `armReconnect` is already gated off by `wantsReconnect`, so the late callback is a no-op instead of a stranded flag. + +### D-2 PoweredOn await (#57) + +**New actor API:** + +```swift +/// Suspends until the central is usable, or fails with a typed error for terminal states. +/// - Throws: `PeripheralError.bluetoothPoweredOff`, `.bluetoothUnsupported`, +/// `.bluetoothUnavailable`, or `CancellationError`. +func waitUntilPoweredOn() async throws +``` + +**Implementation** — mirrors the existing authorization continuation pattern (`suspendForAuthorizationDecision` at `BluetoothActor.swift:530` / `resolvePendingAuthorization` at `:586`): + +- `private var poweredOnContinuations: [UUID: CheckedContinuation]`. +- On entry: shut down → `bluetoothUnavailable`. No central → `bluetoothUnavailable` (callers reach here only after `ensureCentralManager()`). Then switch on `centralManager.state`: `.poweredOn` → return immediately; `.poweredOff` → throw `bluetoothPoweredOff`; `.unsupported` → throw `bluetoothUnsupported`; `.unauthorized` → throw `bluetoothUnavailable`; `.resetting` / `.unknown` → park a continuation. +- `resolvePoweredOnWaiters()` runs from `handleCentralManagerStateUpdate` (`:837`) alongside `resolvePendingAuthorization()`: `.poweredOn` resumes every waiter successfully; `.poweredOff` / `.unsupported` / `.unauthorized` **fail** every waiter with the matching typed error; `.resetting` / `.unknown` leave them parked. +- Cancellation: façade methods wrap the call in `withTaskCancellationHandler` with a `cancelPoweredOnContinuation(id:)` onCancel, exactly as `ReliaBLEManager.authorizeBluetooth` (`:142`) does today. +- **Resumed waiters re-check before acting.** A continuation resumed from the state handler runs at a later actor turn, so the radio may have flipped again before the caller issues `scanForPeripherals` or `issueConnect`. Both resumption paths re-read `centralManager.state` and either re-park (transient) or throw the matching typed error (terminal). `reevaluateLink` does the same check for the connect path (D-1 event 5). +- **Scan filter precedence.** A restored scan (`pendingRestoredScanServices`) and an awaited `startScanning` can both fire at `.poweredOn`, and CoreBluetooth has a single scan — last writer wins. **The app-requested filter wins:** resolve the app waiter after the restored scan resumes, and clear `pendingRestoredScanServices` when an app scan supersedes it. +- **A parked scan waiter has four distinct outcomes, and only one of them throws `CancellationError`.** Model this with an explicit resume reason (`.poweredOn` / `.superseded` / `.stopped` / `.failed(PeripheralError)`) rather than overloading cancellation: + +| Cause | `startScanning` result | +|-------|------------------------| +| Radio reaches `.poweredOn` | Success — scan starts | +| `stopScanning()` called while parked | **Success** (void), no scan started. The app asked for a scan and then asked to stop; that sequence completed as requested and is not an error | +| Superseded by a later `startScanning` | Earlier waiter completes **successfully** without scanning (or is coalesced with the newer request — document whichever the implementation picks) | +| The calling task is cancelled (`Task.cancel`) | **`CancellationError`** — the only throwing-cancellation case | +| Radio resolves to `.poweredOff` (or another terminal state) | **`bluetoothPoweredOff`** / matching typed error — not success | + +**Call sites:** + +| API | Today | After | +|-----|-------|-------| +| `ReliaBLEManager.startScanning` | `ensureCentralManager()` then scan; **silently returns** if not `poweredOn` (`BluetoothActor.swift:625`) | `ensureCentralManager()` → `waitUntilPoweredOn()` → scan; propagates typed errors | +| `ReliaBLEManager.stopScanning` | ensure + stop | No radio wait, but it now **resolves any parked scan waiter successfully** — otherwise a `startScanning` suspended on `.resetting` would start scanning after the app said stop. The waiter returns void without scanning; it does **not** throw (see the resume-reason table below). | +| `Peripheral.connect` | ensure + forward (`Peripheral.swift:165`) | ensure → **`applyManualConnectHold`** → wait → `reevaluateLink(.explicitConnect)`. The hold is registered before the wait so a `bluetoothPoweredOff` throw still leaves durable demand (D-hold) | +| `acquireWorkLease` | n/a | ensure → wait → acquire → `reevaluateLink` | +| Future FR-4/5 command submission | n/a | same wait at submission | +| `resumeRestoredScan` (`:819`) | stashes into `pendingRestoredScanServices`, defers | unchanged — this is an internal deferral, not an app-visible no-op | + +**Signature changes:** + +```swift +// ReliaBLEManager — breaking, acceptable pre-release +public func startScanning(services: sending [CBUUID]? = nil) async throws +``` + +**Error additions** in `Sources/ReliaBLE/Models/PeripheralError.swift`: + +```swift +case bluetoothPoweredOff +case bluetoothUnsupported +``` + +Both go on `PeripheralError` rather than a new type or `AuthorizationError`: `bluetoothUnavailable` already lives there and is already thrown from the scan-adjacent paths, so splitting radio errors across two enums would make `startScanning` throw from two unrelated domains. `AuthorizationError` stays scoped to `authorizeBluetooth()`. + +The Bluetooth state stream (`ReliaBLEManager.state`, `:101`) is unchanged and remains the supported way to gate UI — FR-1.4 requires both. + +### D-3 Idle disconnect and Manual connect (#58) + +**Config** — `Sources/ReliaBLE/ReliaBLEConfig.swift`: + +```swift +public var idleDisconnectInterval: TimeInterval = 5.0 +``` + +Validate finite and `>= 0` at actor init; `0` means "tear down as soon as demand hits zero". `BluetoothActor.init` takes it alongside `reconnectPolicy`. + +**Handle methods** — `Sources/ReliaBLE/Models/Peripheral.swift`: + +```swift +public func connect(autoReconnect: Bool = true) async throws { + // 1. ensureCentralManager() + // 2. applyManualConnectHold(id:reconnectDesired: autoReconnect) — hold set FIRST + // 3. try await waitUntilPoweredOn() — may throw bluetoothPoweredOff / unsupported / unavailable + // 4. try await reevaluateLink(id:reason: .explicitConnect) +} + +public func disconnect() async throws { + // applyManualDisconnect(id:) — no radio wait; cancelling is always allowed +} +``` + +**Ordering is normative (D-hold).** Registering the hold at step 2 rather than after step 3 changes what a failed connect leaves behind: + +| `autoReconnect` | Wait throws | App sees | When the radio returns | +|-----------------|-------------|----------|------------------------| +| `true` | Hold is already set and persisted | The typed error (e.g. `bluetoothPoweredOff`) | Event 12 re-issues — `wantsReconnect` is true | +| `false` | Hold is already set (demand true, `wantsReconnect` false) | The typed error | **No** re-issue — the issue gate refuses; idle stays suppressed | + +The throw is informational, not destructive: the app learns Bluetooth is off *and* its intent is recorded. + +**Logging (FR-9.2)** — info level, tagged `.category(.connection)` + `.peripheral(id)`, with distinct messages so the three causes are separable in Console: idle timer armed, idle disconnect fired, Manual connect, Manual disconnect. Connection/disconnection and scan start/stop logging already exist and is unchanged. + +**DocC** — `GettingStarted.md` leads with the work-driven model and presents `connect`/`disconnect` as the Manual connect pair with an explicit "expected to be rare" framing, noting that a manual `connect` is **durable across relaunch** when `restoreIdentifier` is configured. `Topics/Background.md` documents the restore matrix (D-restore): a persisted manual-connect hold rehydrates and keeps the link; a restored link without a hold idles out; work never survives process death. It also states in one sentence that true continuous background scanning is FR-8.2 territory, not something a hold or lease implies. + +### D-4 Reconnect gating (#59) + +- Introduce `syncReconnectIntent(id:)`, called after every demand change: when `wantsReconnect(id)` is true, insert into `reconnectEnabled`; when false, remove and cancel the ladder task. When the change is **hold-driven** and `restoreIdentifier != nil`, it also writes the persisted hold map. **Hold registration/clear and hold-id rebind are the only writers** — `invalidatePeripherals` and `shutdown()` must not write. (Prose may call this "sync persisted holds" where that reads more clearly.) + +**Persistence model (D-restore).** What is persisted is a **hold record list**, not a reconnect-enabled set: + +- **Encoding:** an array of records `{ id, reconnectDesired, cbUUID }`, namespaced by `restoreIdentifier`. `cbUUID` is the CoreBluetooth peripheral identifier used to rebind when the name-derived `id` changes across launches. A set of true-only ids is **insufficient** — it cannot represent a `connect(autoReconnect: false)` hold, which must survive restore as "suppress idle, but arm nothing." Unrecognized on-disk values are treated as "no persisted holds". No migration from earlier encodings (pre-release). +- **Meaning:** "the app made a manual `connect` for this radio and has not disconnected it," together with the `autoReconnect` value it asked for. This is durable demand, not a hint. +- **Rehydrate independently of OS restore.** Load disk holds on `handleWillRestoreState` **and** on the first `.poweredOn` if restore never fired. Then retrieve held UUIDs and/or wait for discovery. This reverses the earlier plan's "delete the restore-time read" and extends it: durability must not depend on CoreBluetooth delivering a peripheral list after force-quit. +- **Invalidate and shutdown must not write.** `invalidatePeripherals` must **stop** writing (D-1 event 13); `shutdown()` must **never** write (D-1 event 14). Idle-timing out one restored link removes that id from the map — and must leave every other id's entry untouched, which the earlier bulk `removeAll()`-then-persist pattern did not guarantee. +- **Interim identity (until FR-8.5):** prefer advertisement local name over GAP/`CBPeripheral.name` (avoids OS default names like `"iPhone"`). When a hold's `cbUUID` matches a live peripheral under a different app id, rebind demand to the resolved id (or keep the hold id on retrieve with no ad). A later scan with a local name may upgrade a GAP-only hold key. FR-8.5 manufacturer-data identity is still open. +- Rewrite the restore expectations `willRestoreSeedingReconnectOnlyForConnectedOrConnecting` and `willRestoreDoesNotRearmReconnectWithoutPersistedIntent` against hold rehydration rather than deleting them — the second in particular still has a job: a restored link with no persisted hold must **not** come back armed. +- `armReconnect` gates on `wantsReconnect(id)`, so a quiet peripheral never runs the ladder — the #59 acceptance criterion. +- Tier-0 ends because idle teardown routes through the intentional-cancel path, not because of any separate option-clearing call (none exists in CoreBluetooth). +- The grace-window blip that FR-1.2 explicitly accepts is handled by event 8: a Tier-0 reconnect landing with zero demand immediately re-arms the idle timer and is cancelled again. + +**Carry-over from the prior critique** (`docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md`) that this work must close, since it is touching the same code: the `taskRegistry` cancel-during-sleep race (a cancel landing while the ladder task is sleeping, followed by a second schedule) and the incomplete `intentionalDisconnects` clearing paths on fail / give-up / success. The `idleGeneration` counter is the same defense applied to the idle timer. + +### D-5 Work-driven auto-connect (#60) + +**Internal types** on `BluetoothActor`: + +```swift +struct WorkLeaseToken: Sendable, Hashable { + let id: String + let leaseID: UUID +} + +func acquireWorkLease(id: String) async throws -> WorkLeaseToken +func releaseWorkLease(_ token: WorkLeaseToken) async + +// Test-only hooks, matching the existing setReconnectPolicy / testInjectDisconnect style +func testWorkCount(for id: String) -> Int +func testHasManualConnectHold(for id: String) -> Bool +func setIdleDisconnectInterval(_ interval: TimeInterval) +``` + +Releasing an unknown or already-released token is a no-op (plus a debug log). This is enforceable only because `activeLeases` stores lease UUIDs (D-1): a bare `Int` refcount cannot tell a double-release of token A from a legitimate release of token B, and clamping at zero does not help — with two leases held, a double-release would drop the count to zero and tear down a link that still has work. + +**Lease holders get no completion or failure signal.** `acquireWorkLease` returns once the connect has been *issued*, not completed; if the connect fails and the ladder exhausts, the holder learns nothing except through the connection-state stream. That is acceptable for an internal primitive whose only Phase-2 consumers are tests, but it is a deliberate limitation, not an oversight. **Phase 3 note:** the command queue will need an *await-linked* primitive (or per-command failure delivery) layered on top of the lease — do not let `ensureLinked`'s fire-and-forget shape get baked into the queue design by default. + +Optional `@testable`-visible wrappers on `Peripheral` so lease tests read naturally without reaching through `manager.bluetooth`. + +**Behavior:** acquiring a lease creates demand, which drives `reevaluateLink` — an auto-connect with no prior Manual `connect`, satisfying FR-11.1. Releasing the last lease drops demand and starts the idle grace, satisfying FR-1.5's "no pending work" clause. + +**Leases are never persisted and never survive process death** (D-restore). Work is inherently process-scoped: a relaunched app has no in-flight commands, so resurrecting a lease would fabricate demand nobody asked for. Only manual-connect holds are durable. + +**Phase 3/4 replacement path:** the per-peripheral serial command queue (FR-5.2.1) acquires a lease when it becomes non-empty and releases when it drains — either one queue-lifetime lease or per-command leases against the same refcount. `ensureLinked`, idle, and both reconnect tiers need no change; the queue simply becomes a second lease source alongside any future internal work. + +### D-6 Concurrency and cancellation + +- All demand, idle, and reconnect state is actor-isolated; no new locks and no cross-actor coordination. `Peripheral`'s `Mutex` state is untouched by this phase beyond the existing `connectionState` mirroring. +- **Two task registries, not one.** Idle tasks go in a separate `idleTaskRegistry` (same `TaskRegistry` type) rather than sharing keys with the reconnect ladder in `taskRegistry` — a peripheral can legitimately have both a ladder task and an idle task pending, and key collision would silently cancel the wrong one. +- Idle tasks capture `[weak self]` and re-check `idleGeneration` on wake, so a cancelled-then-rescheduled timer cannot fire from a stale task. +- PoweredOn waiters unwind on task cancellation and on `shutdown()`. +- Lease release after shutdown is a no-op. +- Actor serialization already guarantees `ConnectionStateChange` ordering (established in `docs/plans/connection-lifecycle-stream-2026-06-30.md`); nothing here introduces a second emission path — `setConnectionState` remains the sole write. + +### D-7 Errors and edge cases + +| Case | Behavior | +|------|----------| +| Scan while `.poweredOff` | Throws `bluetoothPoweredOff` immediately (D-radio) | +| Scan while `.resetting` / `.unknown` | Awaits; resolves to scanning on `.poweredOn`, or throws the matching typed error if the transient resolves to a terminal state | +| Scan while `.unsupported` | Throws `bluetoothUnsupported` | +| Scan while unauthorized | Throws `bluetoothUnavailable` (authorization is `authorizeBluetooth()`'s domain) | +| Waiting scan task cancelled via `Task.cancel` | `CancellationError`; continuation removed; no scan started | +| `stopScanning()` while a scan is parked on a transient state | Waiter resolves **successfully** (void), no scan starts — not an error (D-2) | +| A second `startScanning` supersedes a parked one | Earlier waiter completes successfully without scanning, or is coalesced — documented either way (D-2) | +| Connect / lease on never-seen id | `notFound` (D-never), raised after the radio gate | +| Connect on an orphaned handle (manager deallocated) | `bluetoothUnavailable` — existing Phase 1 behavior, unchanged | +| Last lease released while `.connecting` | Idle grace starts; if the connect completes, event 8 re-arms the timer and the link is cancelled | +| Manual `disconnect()` while work leases are held | Hold clears and the link is intentionally cancelled **once**; on the intentional `handleDidDisconnect` branch, a live lease triggers `reevaluateLink`, so work re-drives the connection. "Drop the hold" must not mean "strand pending work." | +| `connect(autoReconnect: false)` | Hold with `reconnectDesired == false`: suppresses idle, no Tier-0 option, no Tier-1 ladder — matching today's `autoReconnect: false` semantics | +| Demand present but radio drops to `.poweredOff` | Requires the D-1 event 13 change adding `.poweredOff` to the invalidate triggers: CB state and `connectionStates` clear (emitting `.disconnected(reason: .bluetoothUnavailable)`), demand is preserved, and `reevaluateLink` re-issues when the radio returns. **Without that change this path silently fails** — the stale `.connected` cache makes event 12 a no-op | +| Tier-1 gives up at `maxAttempts` | Existing terminal behavior; demand may still be present, and no stuck `.reconnecting` state (existing test coverage) | +| `connect(autoReconnect: false)` hold, then an unexpected drop, then a radio cycle | The link stays down. `reason == .radioReturned` with `wantsReconnect == false` does not satisfy the issue gate, so the sweep does not resurrect a link the app declined to auto-reconnect (D-1) | +| Restored link, no persisted hold | Idle timer starts at `.poweredOn`; link tears down after the interval (D-restore case 2) | +| Restored link **with** a persisted manual-connect hold | Hold rehydrates with its `reconnectDesired`; **no** idle timer; `reevaluateLink` if not already linked (D-restore case 1) | +| Force-quit relaunch, OS restores no peripherals, hold on disk | Hold still rehydrates; retrieve/discovery re-issues if `reconnectDesired` (cold-start path) | +| Same `cbUUID`, name-derived id changed (`"ReliaBLE Demo"` ↔ `"iPhone"`) | Rebind hold to the resolved id (or keep hold id when there is no ad); one demand, no second device | +| Restored persisted hold with `reconnectDesired: false` | Rehydrates: idle suppressed, but no Tier-0/Tier-1 arming and no radio-return re-issue (D-restore case 4) | +| Work leases at relaunch | Never restored — leases do not survive process death (D-restore case 3) | +| `connect(autoReconnect: true)` while `.poweredOff` | Hold is set and persisted, **then** the call throws `bluetoothPoweredOff`; when the radio returns, event 12 re-issues (D-hold) | +| `connect(autoReconnect: false)` while `.poweredOff` | Hold is set, then the call throws; the radio returning does **not** re-issue, but idle stays suppressed (D-hold) | +| Radio drops while a manual hold or lease wants reconnect | Event 13 emits `.disconnected(reason: .bluetoothUnavailable)` then `.reconnecting(source: .library, attempt: nil, nextRetryAt: nil)` — the app can tell "coming back" from "gone" | +| Radio drops with a `reconnectDesired: false` hold | Settles disconnected with **no** `.reconnecting` signal — nothing will re-issue, so claiming otherwise would be false | +| Peripheral not retrievable after `refreshPeripherals` | `.failed(reason: .notFound)` on the stream; **no scan, no retry loop**; the lease or hold is **retained** until the app releases it (D-never) | +| Manager shutdown with a persisted hold map | Volatile state clears; the persisted map on disk is **untouched** (D-1 event 14) | +| Manual `disconnect()` during a radio outage (no live `CBPeripheral`) | Hold clears, demand drops, returns **success** — nothing to tear down is not an error (D-1 event 4) | +| Demand drops while the state is library-`.reconnecting` | Ladder cancelled, settle synchronously to `.disconnected(reason: nil)`; no idle timer, no CB cancel against a disconnected peripheral | +| `reevaluateLink` throws `notFound` in delegate context | Publish `.failed(reason: .notFound)` on the connection-state stream so a stranded lease is observable | +| Double-release of a lease token while another lease is live | Removal of an absent UUID is a no-op; the surviving lease keeps demand and no idle teardown occurs | +| Restored link, app re-declares work or a manual-connect hold inside the grace window | Idle cancelled; link retained | +| `idleDisconnectInterval == 0` | Teardown fires as soon as demand hits zero (still asynchronously, via the same task) | +| Manager shutdown with leases outstanding | Waiters fail, tasks cancel, holds and counts clear; subsequent releases no-op | + +### D-8 Rejected alternatives + +| Alternative | Why rejected | +|-------------|--------------| +| One PR per sub-issue | Forces a throwaway dual connect path between merges; the demand signal is read by three of the four issues (**confirmed by user**) | +| `.poweredOff` awaits indefinitely | Relocates today's silent no-op into a silent hang; the app cannot distinguish "waiting for the user" from "wedged" without the state stream anyway (**overridden by user**) | +| Bounded `radioReadyTimeout` config | Adds a knob and a timeout error the PRD does not ask for, once `.poweredOff` fails fast; `.resetting`/`.unknown` are short-lived by construction | +| Restoration never re-applies a manual-connect hold (idle-always) | **Superseded 2026-08-05.** A manual `connect` is an explicit "keep this link" instruction and should outlive relaunch when restoration is configured; idle-always made restoration unable to deliver the standing-session behavior it exists for. NFR-3.2 is preserved for restored links the app never explicitly requested, which still idle out | +| Persisting a true-only set of reconnect-enabled ids | Cannot represent a `connect(autoReconnect: false)` hold, which must survive restore as "suppress idle, arm nothing". The hold record (`id`, `reconnectDesired`, `cbUUID`) can | +| Registering the manual-connect hold only after the radio wait succeeds | **Superseded 2026-08-05.** A `connect(autoReconnect: true)` issued while Bluetooth is off would throw and leave no demand, so the radio returning would do nothing — contradicting what `autoReconnect: true` promises | +| Throwing `CancellationError` from a parked scan waiter when `stopScanning` supersedes it | **Superseded 2026-08-05.** Start-then-stop is a sequence the app requested and completed; only `Task.cancel` is a true cancellation | +| Leaving auto-relinking peripherals at `.disconnected(.bluetoothUnavailable)` with a `nil` handle after a radio drop | With no public demand API in Phase 2, `ConnectionState` is the only channel that can distinguish "will come back" from "gone"; a bulk nil-out destroys that information | +| A library-owned scan to recover a `notFound` peripheral under demand | Would be a second connection/scan stack in Phase 2, which FR-11.4 forbids; continuous and demand-driven scan policy belongs to FR-8.2 / FR-4. The additive hook (event 15) keeps the door open | +| Public work-lease API now (`Peripheral.withLink { }`) | Public surface that FR-4/FR-5 would immediately supersede; pre-release breakage is cheap but unnecessary surface is not (**confirmed by user**) | +| Await discovery for never-seen ids | Unbounded wait coupled to scan policy, with a second cancellation story | +| Full injectable `Clock` abstraction | Larger than this phase needs and would churn every existing reconnect test; `ReconnectPolicy` already proves interval override is sufficient | +| Separate idle-grace interval config | PRD names no grace duration; a second knob would be unexplained surface | +| Sharing `taskRegistry` between idle and reconnect tasks | Key collision silently cancels the wrong task when a peripheral has both pending | +| Leaving `.poweredOff` non-invalidating and instead verifying `cbPeripherals[id]?.state` inside `reevaluateLink` | A viable fix for the same bug, but it leaves `connectionStates` reporting `.connected` for a dead link on the public stream. Invalidating is both simpler and more honest to observers. The state verification is retained anyway as a belt-and-braces check | +| Tracking work as a bare `Int` refcount | Cannot distinguish a double-release of one token from a legitimate release of another; tears down links that still have work | +| Approach A (app-only explicit connect) | PRD mandates Approach B | + +## File-by-file impact + +| File | Change | Driven by | Depends on | +|------|--------|-----------|------------| +| `Sources/ReliaBLE/ReliaBLEConfig.swift` | Add `idleDisconnectInterval: TimeInterval = 5.0` + docs | FR-1.5 | — | +| `Sources/ReliaBLE/Models/PeripheralError.swift` | Add `bluetoothPoweredOff`, `bluetoothUnsupported`; document wait-vs-fail policy | #57 | — | +| `Sources/ReliaBLE/BluetoothActor.swift` | PoweredOn continuations + resolver (re-check on resume, resume-reason enum); `activeLeases` / `manualConnectHold` / `idleGeneration` / `idleTaskRegistry`; extract a side-effect-free `issueConnect`; add `reevaluateLink(id:reason:)`, `beginIdleGrace`, `fireIdle`, `syncReconnectIntent`, `applyManualConnectHold`, `applyManualDisconnect`, lease API; gate `armReconnect` on `wantsReconnect`; add `.poweredOff` to the invalidate triggers; **stop** writing persistence from `invalidatePeripherals` and from `shutdown()`; preserve `pendingRestoredScanServices` across power-cycle invalidate; per-id `.reconnecting(.library, nil, nil)` projection replacing the bulk `clearConnectionStates` nil-out; change persistence to a hold map and **keep** the restore-time read, rehydrating holds; replace the scan `.poweredOn` guard; `stopScanning` resolves parked waiters successfully; FR-9.2 logging; test hooks | all four issues | config, errors | +| `Sources/ReliaBLE/Models/Peripheral.swift` | `connect` becomes ensure → `applyManualConnectHold` → wait → `reevaluateLink` (hold before wait); `disconnect` routes to `applyManualDisconnect`; optional `@testable` lease wrappers | #57, #58 | actor | +| `Sources/ReliaBLE/ReliaBLEManager.swift` | `startScanning` becomes `async throws` with `withTaskCancellationHandler`; pass `idleDisconnectInterval` into `BluetoothActor.init` | #57, #58 | actor | +| `Sources/ReliaBLE/Models/ConnectionState.swift` | Docs only — idle teardown surfaces as an intentional `.disconnected(reason: nil)`; and on `.reconnecting`, a `nil` `attempt` / `nextRetryAt` means "waiting for the radio, not yet on the backoff ladder," distinct from an armed ladder step that carries real values | FR-11.5, D-1 event 13 | — | +| `Sources/ReliaBLE/Documentation.docc/GettingStarted.md` | Work-driven model first; `connect`/`disconnect` reframed as Manual connect hold; `try` on scan examples | FR-11.2 | API | +| `Sources/ReliaBLE/Documentation.docc/Topics/Background.md` | Manual-connect holds survive relaunch when restoration is configured; restored links **without** a hold idle out; work never survives process death; one sentence that true continuous background scanning is FR-8.2, not Phase 2 demand | D-restore | API | +| `Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md` | Scan now throws; cancellation unwinds a pending radio wait | #57 | API | +| `Tests/ReliaBLETests/ReliaBLEManagerTests.swift` | `try` on all `startScanning` sites; new PoweredOn, idle, lease, gating, and restore tests; short idle interval via the test hook | NFR-2.1 | all | +| `PRD.md` | Check off FR-1.4, FR-1.5, FR-11.1–11.5 as delivered | bookkeeping | after impl | +| `Demo/` | Handle `try await startScanning`; optionally surface the idle interval in Settings | compile | library | +| `Package.swift`, `CBCentralManagerFactory.swift`, `CoreBluetoothMockAliases.swift` | **No change** | — | — | + +## Risks and migration + +- **Breaking:** `startScanning` becomes `async throws`. Every call site in tests, the Demo, and DocC examples must add `try`. Pre-release, so no deprecation path — see AGENTS.md. +- **Persistence changes shape.** The `restoreIdentifier`-keyed UserDefaults value is a **hold record list** (`id`, `reconnectDesired`, `cbUUID`). Unrecognized encodings are "no persisted holds". Pre-release: no migration shim — one relaunch after upgrading may lose standing holds until the next explicit `connect`. +- **Restored holds keep links alive across relaunch.** This is the intended reversal (D-restore), but it does mean a manual `connect` now has battery consequences that outlive the process. NFR-3.2 is preserved only for links the app never explicitly requested. DocC must make the durability explicit so the cost is a choice, not a surprise. +- **`shutdown()` must never write to disk.** An empty flush at shutdown would erase the persisted hold map and silently destroy a standing session — the failure would surface only after a relaunch, making it easy to miss in review. Worth an explicit test (`shutdownLeavesPersistedHoldsIntact`). +- **Tier-0 cannot be enabled on an already-live link.** CoreBluetooth has no API to change connect options mid-connection, so a link established without Tier-0 that later gains reconnect-wanting demand only picks up the option on the next connect issue. Document; do not work around by cycling the link. +- **Idle-timing flakiness.** Mitigated by the interval override, the `idleGeneration` guard, and reusing the existing `pollUntil` helpers rather than fixed sleeps. +- **Adding `.poweredOff` to the invalidate triggers changes observable behavior.** A power-off now emits `.disconnected(reason: .bluetoothUnavailable)` and clears `cbPeripherals`, where today it emits nothing. This is required for demand to survive a radio cycle (D-1 event 13), but existing power-cycle tests will see the new emission and the recovery path depends on `refreshPeripherals` re-populating from `retrievePeripherals`. +- **Cancel-without-callback.** CoreBluetooth does not guarantee a terminal delegate callback for a cancel issued against a pending connect or a peripheral in OS-reconnect limbo. The settling rule (D-1) confines optimistic `.disconnecting` to genuinely `.connected` peripherals so that a missing callback cannot strand state, but the `.connecting`-cancel case still depends on CB delivering `didFailToConnect` or `didDisconnect` — see the unknowns table. +- **Manual disconnect racing pending work** is the subtlest behavior in the plan and needs its own test (`disconnectWithActiveLeaseRelinks`). +- **Mock coverage gaps (#40, #42)** mean the Tier-0 give-up handoff and the real grace-window blip cannot be exercised end-to-end. Use `testInjectDisconnect(for:isReconnecting:error:)` for the ladder paths and record the rest as on-device checks. +- **Rollback:** revert the PR. No persisted-data migration to undo. + +## Implementation order + +One branch, `51-work-driven-connection-lifecycle`. Each numbered step is a green commit (`swift build` + `swift test` pass); the whole set lands as a single PR because public connect semantics are inconsistent mid-sequence. + +1. **Config + errors** — `idleDisconnectInterval`, `bluetoothPoweredOff`, `bluetoothUnsupported`. No behavior change. +2. **PoweredOn await infrastructure** — continuations, `resolvePoweredOnWaiters` wired into `handleCentralManagerStateUpdate`, cancellation helper, test hook for waiter count. Not yet called from app paths. +3. **Wire the radio gate into scan and connect** — `startScanning` becomes `async throws`; `Peripheral.connect` awaits; migrate existing call sites and Demo. → **checkpoint #57**. +4. **Demand substrate** — `activeLeases`, `manualConnectHold`, `syncReconnectIntent`, extract `issueConnect`, add `reevaluateLink(id:reason:)`, `applyManualConnectHold` / `applyManualDisconnect`, lease API and test hooks. Manual connect now sets a hold **before** the radio wait; no idle behavior yet. +5. **Idle grace and teardown** — `idleTaskRegistry`, `idleGeneration`, `beginIdleGrace` / `fireIdle`, the event-8 blip path, FR-9.2 logging, `setIdleDisconnectInterval` test hook. → **checkpoint #58**. +6. **Reconnect gating, radio-drop projection, and restore** — `armReconnect` gates on `wantsReconnect`; `handleDidDisconnect` handles the untrusted-Tier-0 and work-relink branches; `.poweredOff` joins the invalidate triggers, `invalidatePeripherals` stops writing persistence and preserves the deferred restored scan, and the per-id `.reconnecting(.library, nil, nil)` projection replaces the bulk nil-out; persistence becomes a hold map that restore reads back to rehydrate holds; `shutdown()` is audited for disk writes; close the two carry-over items from the prior reconnect critique. → **checkpoint #59, and #60's idle-cycle criterion**. +7. **DocC, Demo, PRD checkboxes.** +8. **Full `swift test` + DocC build with warnings as errors.** + +## Verification + +### Test cases to add or adapt — `Tests/ReliaBLETests/ReliaBLEManagerTests.swift` + +| Test | Asserts | +|------|---------| +| `startScanningFailsWhenPoweredOff` | `simulatePowerOff` → `startScanning` throws `bluetoothPoweredOff`; no scan started | +| `startScanningFailsWhenUnsupported` | throws `bluetoothUnsupported` | +| `startScanningAwaitsTransientState` | parked in `.resetting`/`.unknown` → resolves on `.poweredOn` → scanning | +| `transientStateResolvingToPoweredOffFailsWaiter` | parked waiter fails `bluetoothPoweredOff` rather than hanging | +| `stopScanningCompletesParkedScanWaiterSuccessfully` | `startScanning` parked on a transient state, then `stopScanning` → waiter returns **successfully**, no scan starts, no error thrown (D-2) | +| `supersededScanWaiterCompletesWithoutScanning` | a second `startScanning` while the first is parked → the first completes successfully (or is coalesced, per implementation) | +| `connectAwaitsTransientState` | `Peripheral.connect` parked on `.resetting`/`.unknown` proceeds on `.poweredOn` — a distinct code path from scan | +| `connectPoweredOffSetsHoldAndRelinksOnPowerOn` | `connect(autoReconnect: true)` while powered off throws `bluetoothPoweredOff`, **the hold is still set and persisted**, and the link establishes when the radio returns (**D-hold**) | +| `connectAutoReconnectFalsePoweredOffDoesNotRelinkOnPowerOn` | same setup with `autoReconnect: false` → hold set, throws, and the radio returning does **not** re-issue | +| `connectTransientResolvingToPoweredOffThrows` | connect-side analog of the scan waiter failure | +| `startScanningCancellationUnblocksWaiter` | cancelling the awaiting task throws `CancellationError` and leaves no continuation | +| `connectFailsWhenPoweredOff` | same contract on `Peripheral.connect` | +| `workLeaseAutoConnectsWithoutPriorConnect` | acquire a lease on a discovered id → reaches `.connected` with no `connect()` call (**#60**) | +| `idleDisconnectAfterLastLeaseReleased` | short interval; release → clean `.disconnected(reason: nil)` within the interval (**#58**, **#60**) | +| `manualConnectHoldSuppressesIdle` | `connect()` then quiet for 3× the interval → still `.connected` | +| `manualDisconnectClearsHoldAndTearsDown` | `disconnect()` → intentional clean disconnect, no ladder armed | +| `disconnectWithActiveLeaseRelinks` | hold + lease → `disconnect()` → link cancels once then work re-drives it back to `.connected` | +| `tier1DoesNotArmWhenQuiet` | no demand → `testInjectDisconnect(isReconnecting: false)` → no `.reconnecting(.library)` emitted (**#59**) | +| `tier1ArmsWhileLeaseHeld` | lease held → injected drop → ladder runs | +| `tier0BlipDuringGraceRearmsIdle` | connect via lease, release, simulate a reconnect landing during grace → idle timer re-arms and the link is cancelled again | +| `autoReconnectFalseHoldSuppressesBothTiers` | `connect(autoReconnect: false)` → no Tier-0 option, no ladder, idle still suppressed | +| `restoredLinkWithoutHoldIdlesOut` | restore a connected peripheral with **no** persisted hold → disconnected after the short interval (**D-restore case 2**) | +| `restoredManualHoldSurvivesRelaunch` | persist a hold, restore → hold rehydrates, **no** idle teardown, link retained or re-established (**D-restore case 1**) | +| `coldStartRehydratesHoldWithoutWillRestore` | persist a hold, relaunch **without** a restore fixture → hold in memory; scan/retrieve reconnects | +| `holdRebindsAcrossNameDerivedIdDrift` | persist hold under a stale name-derived id + same `cbUUID` → rediscovery rebinds demand and reconnects | +| `restoredHoldWithAutoReconnectFalseSuppressesIdleButNotTier1` | persisted hold with `reconnectDesired: false` → idle suppressed, no ladder, no radio-return re-issue (**D-restore case 4**) | +| `workLeasesDoNotSurviveRelaunch` | leases are never rehydrated from disk (**D-restore case 3**) | +| `restoredLinkRetainedWhenWorkDeclared` | restore with no hold, acquire a lease inside the grace window → link retained | +| `shutdownLeavesPersistedHoldsIntact` | hold persisted → `shutdown()` → the on-disk map is unchanged (**D-1 event 14**) | +| `invalidateDoesNotWipePersistedHolds` | hold persisted → transient `.resetting` → the on-disk map is unchanged | +| `demandSurvivesRadioCycleAndRelinks` | lease held → `simulatePowerOff` → `.disconnected(reason: .bluetoothUnavailable)` → `simulatePowerOn` → link re-established without re-acquiring. Guards the D-1 event 13 `.poweredOff` invalidate fix; **automatable today** — the mock supports power cycling and existing tests already use it | +| `radioDropWithWantsReconnectShowsReconnecting` | hold or lease with reconnect → power off → stream shows `.disconnected(.bluetoothUnavailable)` **then** `.reconnecting(source: .library, attempt: nil, nextRetryAt: nil)`, and the handle is not left `nil` (**D-1 event 13 step 3**) | +| `radioDropWithoutWantsReconnectShowsNoReconnecting` | `connect(autoReconnect: false)` hold → power off → settles disconnected with **no** `.reconnecting` emission (**step 4**) | +| `deferredRestoredScanSurvivesPowerCycle` | restored scan pending, power off/on → the scan still resumes (**D-1 event 13**); skip with the FR-8.2 open item if preservation is deferred | +| `manualDisconnectDuringRadioOutageSucceeds` | hold held, radio `.resetting`, `disconnect()` returns success and drops demand | +| `noReconnectHoldIsNotResurrectedByRadioCycle` | `connect(autoReconnect: false)` → drop → power cycle → link stays down | +| `idleWhileLibraryReconnectingSettlesCleanly` | demand drops during a Tier-1 backoff sleep → ladder cancelled, `.disconnected(reason: nil)`, no stuck `.disconnecting` | +| `strandedLeaseSurfacesFailure` | lease held, peripheral no longer retrievable after invalidate → `.failed(reason: .notFound)` on the stream, **and the lease is still held afterwards** — demand must not be silently dropped, and no scan or retry loop starts (**D-never**) | +| `discoveryRelinksDemandedPeripheral` | demand held on an id that failed with `notFound` → an app-driven scan rediscovers it → `reevaluateLink` links it (**event 15**; skip if the optional polish is deferred) | +| `leaseOnNeverSeenIdThrowsNotFound` | **D-never** | +| `doubleReleaseIsNoOp` | **two** leases held; releasing token A twice leaves demand intact via lease B and triggers no idle teardown. Written with a single lease this test passes even against the broken refcount design, so two is load-bearing | +| Existing reconnect + connection-lifecycle tests | Updated for `try startScanning` and for demand-gated arming (`autoReconnect: false` ⇒ hold without `reconnectDesired`) | + +### Commands + +```sh +swift build +swift test +swift test --filter ReliaBLETests.workLeaseAutoConnectsWithoutPriorConnect +swift test --filter ReliaBLETests.idleDisconnectAfterLastLeaseReleased +swift test --filter ReliaBLETests.tier1DoesNotArmWhenQuiet +swift test --filter ReliaBLETests.restoredLinkWithoutDemandIdlesOut +swift package generate-documentation --target ReliaBLE --warnings-as-errors +``` + +Demo builds go through XcodeBuildMCP per `Demo/AGENTS.md` — delegate that to a sub-agent and have it read `Demo/AGENTS.md` first. + +### Manual / on-device (non-blocking, mock-limited) + +- Tier-0 OS give-up budget and the true→false `isReconnecting` handoff (blocked by #40). +- A real Tier-0 reconnect landing inside the idle grace window. +- `willRestoreState` end-to-end after a real relaunch, confirming that a persisted manual-connect hold rehydrates and keeps the link, while a restored link without a hold idles out (blocked by #42). +- Battery impact of durable holds across relaunch on a real device, since D-restore intentionally keeps those links alive (NFR-3.2). +- Cancel-callback behavior on a real radio for a pending connect and an OS-reconnecting link (the mock half is an automated exploratory test). + +(The Bluetooth off/on cycle is **not** listed here — the mock supports `simulatePowerOff`/`simulatePowerOn`, so it is covered by the automated `demandSurvivesRadioCycleAndRelinks`.) + +## Issue-to-work mapping + +| Issue | Commits | Public API delta | +|-------|---------|------------------| +| #57 | 1–3 | `startScanning` becomes `async throws`; `bluetoothPoweredOff` / `bluetoothUnsupported` error cases | +| #58 | 1, 5, 7 | `ReliaBLEConfig.idleDisconnectInterval`; `connect`/`disconnect` reframed as a Manual connect hold; FR-9.2 logging; DocC | +| #59 | 6 | No new API — behavioral reconnect gating and restore semantics | +| #60 | 4, 6 | None (internal lease + test hooks only, per D-work) | + +## Unknowns to validate during implementation + +| Unknown | How to settle it | +|---------|------------------| +| Callback guarantees for `cancelPeripheralConnection` against a **pending connect** and against a peripheral in **OS-reconnect limbo** — on the mock *and* on device | One exploratory test for the mock during commit 5; on-device check for the OS. The settling rule (D-1) already covers the limbo case by settling synchronously; the open half is whether cancelling during `.connecting` reliably produces `didFailToConnect`/`didDisconnect`, and if not, whether `.disconnecting` needs a watchdog | +| Whether `refreshPeripherals` reliably re-populates `cbPeripherals` after a `.poweredOff` invalidate | Covered by `demandSurvivesRadioCycleAndRelinks`; if `retrievePeripherals` does not return the id, the `.failed(reason: .notFound)` surfacing path (D-1 event 5) is what the app sees — terminal, with demand retained and no scan | +| Whether preserving `pendingRestoredScanServices` across a power-cycle invalidate is achievable without inventing scan-demand semantics | Attempt during commit 6. If it is not clean, leave today's clearing behavior and file the one-line open item owned by FR-8.2: *power-cycle may drop a deferred restored scan until continuous-scan demand is defined* | +| Whether any existing persistence writer is reachable from `shutdown()` | Audit during commit 6; `shutdownLeavesPersistedHoldsIntact` is the regression guard | +| Whether `startScanning` should throw `bluetoothUnavailable` or an authorization error when the central cannot be created due to authorization | Match `connect`'s existing contract — `bluetoothUnavailable` — and keep `AuthorizationError` on `authorizeBluetooth()` only | +| Whether `resumeRestoredScan`'s deferral needs to become a real waiter now that scan awaits | Read the restore path during commit 3; if the deferral and the waiter can both be pending, collapse to one mechanism | +| DocC symbol-link fallout from `startScanning` gaining `throws` | The docs build in step 8 | + +> Line numbers in `## Background` were captured on the `50-peripheral-handle-type-model` branch and will drift. Navigate by symbol name. + +## References + +- `PRD.md` — Architecture (Connection model), FR-1.2/1.3.1/1.4/1.5, FR-4.4–4.7, FR-5.2, FR-8.2.1/8.6, FR-9.2, FR-11, NFR-1.3/2.1/3.2 +- GitHub: [#51](https://github.com/Five3Apps/ReliaBLE/issues/51), [#57](https://github.com/Five3Apps/ReliaBLE/issues/57), [#58](https://github.com/Five3Apps/ReliaBLE/issues/58), [#59](https://github.com/Five3Apps/ReliaBLE/issues/59), [#60](https://github.com/Five3Apps/ReliaBLE/issues/60); blocked-by [#50](https://github.com/Five3Apps/ReliaBLE/issues/50), next [#52](https://github.com/Five3Apps/ReliaBLE/issues/52); mock gaps [#40](https://github.com/Five3Apps/ReliaBLE/issues/40), [#42](https://github.com/Five3Apps/ReliaBLE/issues/42) +- `docs/plans/auto-reconnect-backoff-2026-07-05.md` + `docs/reviews/auto-reconnect-backoff-plan-critique-2026-07-05.md` +- `docs/plans/connection-lifecycle-stream-2026-06-30.md` +- `docs/plans/background-scanning-state-restoration-2026-07-13.md` +- `docs/plans/peripheral-handle-type-model-2026-07-31.md` + `docs/reviews/peripheral-handle-type-model-plan-critique-2026-07-31.md` +- `docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md` +- `docs/reviews/work-driven-connection-lifecycle-poweredoff-feedback-2026-08-05.md` — user review of the powered-off behavior, rolled into this plan on 2026-08-05. It **reverses** the original D-restore (holds are now durable and the restore-time read stays), moves manual-hold registration ahead of the radio wait, adds the `.reconnecting(.library, nil, nil)` projection on radio drop, changes parked-scan-waiter resolution, preserves the deferred restored scan, and hardens `shutdown()` against disk writes. Where it conflicts with the earlier critique, this feedback wins. +- `docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md` — the design critique of this plan. Its five confirmed findings (stale `.poweredOff` state, self-erasing persisted intent, the `reconnectDesired: false` contradiction, lease double-release bookkeeping, and cancel-against-not-connected teardown) are all resolved inline above; the critique is retained for the code-level evidence behind each correction. +- Hardware F5.2 (force-quit + relaunch) on 2026-08-11 showed OS restore often empty or absent; durability therefore requires disk-hold rehydrate + `retrievePeripherals` / discovery, plus `cbUUID` matching when the name-derived id drifts. Rolled into D-restore / D-4 above. +- DocC catalog: `Sources/ReliaBLE/Documentation.docc/` — `GettingStarted.md`, `Topics/Background.md`, `Topics/Concurrency.md`, `Topics/Multi-Manager.md` diff --git a/docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md b/docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md new file mode 100644 index 0000000..5de823f --- /dev/null +++ b/docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md @@ -0,0 +1,308 @@ +# Critique: Work-Driven Connection Lifecycle Plan (2026-08-02) + +Reviewed: `docs/plans/work-driven-connection-lifecycle-2026-08-02.md` against the generated-plan +baseline in `prompt-exports/oracle-plan-2026-08-02-104458-phase-2-work-driven-1d95.md` (from +`## Generated Plan`, line 164 onward) and the current code in `Sources/ReliaBLE/`. Line numbers +below are from today's `BluetoothActor.swift`; navigate by symbol. + +The four user decisions (one PR; `.poweredOff` fails fast; internal-only work primitive; +restoration restores reconnect intent only, never a hold) are treated as fixed. This critique +checks follow-through, not the decisions themselves. + +## Verdict summary + +The plan is a faithful, mostly tightened rendering of the export. The export's two known-wrong +sections (poweredOff-awaits, D-restore hold re-application) are correctly overridden everywhere, +including the rejected-alternatives table. The serious problems are not export-vs-plan drift but +five shared blind spots: **poweredOff state staleness**, **self-erasing persisted intent**, +**teardown against a not-actually-connected peripheral**, **the `reconnectDesired: false` hold +contradiction**, and **lease double-release bookkeeping**. Each is detailed below with a concrete +correction. + +--- + +## 1. Export content missing or weakened in the plan + +The plan is close to a superset of the export. Only one real loss: + +- **Connect-side radio-await test coverage dropped.** The export's test table had + `connectAwaitsPoweredOn`. Under D-radio that exact test is obsolete (poweredOff now throws), but + the plan replaced it with only the fail-fast case (`connectFailsWhenPoweredOff`). The transient + analogs are missing: *connect awaits `.resetting`/`.unknown` and proceeds on `.poweredOn`*, and + *connect waiter parked on a transient that resolves to `.poweredOff` throws*. The plan has both + variants for scan (`startScanningAwaitsTransientState`, + `transientStateResolvingToPoweredOffFailsWaiter`) but neither for `Peripheral.connect`, whose + await path routes through `applyManualConnect` and is a distinct code path. **Add + `connectAwaitsTransientState` and a connect-side transient→poweredOff failure test.** + +Non-losses worth recording so they aren't re-litigated: + +- The export's §3.2 entry-check nuance ("no central and auth not allowed → unavailable; don't + create central from scan without auth") is compressed in the plan to "No central → + `bluetoothUnavailable` (callers reach here only after `ensureCentralManager()`)". Equivalent + given the existing ensure rules; fine. +- The export's "atomic landings" warning survives as the plan's "single PR because public connect + semantics are inconsistent mid-sequence". Kept. +- The export's optional `idleGeneration` guard was made mandatory in the plan. Improvement, not + drift. +- The export's `restoreWithPersistedHoldKeepsLink` test is correctly deleted (D-restore override), + but see §3 on what should replace it. + +## 2. Under-specified seams, contradictions, and missing dependencies in the plan + +### 2.1 The `reconnectDesired: false` hold contradicts demand-driven `reevaluateLink` (material) + +A hold from `connect(autoReconnect: false)` makes `demand(id) == true` but +`wantsReconnect(id) == false`. Walk an unexpected drop: + +1. `handleDidDisconnect`, `isReconnecting == true` → event 9's untrusted-Tier-0 branch fires + (`!wantsReconnect`) and **cancels the connection** — even though demand is present. +2. Or `isReconnecting == false` → no ladder (`armReconnect` gated off). Link stays down. +3. Later, *any* `reevaluateLink(id:)` trigger — most concretely event 12's radio-return sweep + ("for every id with demand that is not linked, `reevaluateLink`") — sees `demand == true`, link + down, and **issues a fresh connect**. + +So a no-reconnect hold both refuses reconnection (events 9–11) and silently reconnects after a +radio cycle (event 12). The edge-case row "`connect(autoReconnect: false)` … matching today's +`autoReconnect: false` semantics" is not achievable as specified. **Decide and write down one +rule.** The clean fix: after an unexpected drop, a `reconnectDesired == false` hold does not +constitute *re-issue* demand — gate the "Otherwise → `issueConnect`" arm of `reevaluateLink` on +`workCount > 0 || hold?.reconnectDesired == true` (i.e., `wantsReconnect`), keeping bare `demand` +only for idle suppression. That preserves FR-11.2 ("hold suppresses idle") without inventing a +reconnect the app declined. + +### 2.2 Untrusted-Tier-0 branch: published state and stranded `intentionalDisconnects` + +Event 9's middle branch says "insert `intentionalDisconnects[id]` and call +`cancelPeripheralConnection`" but never says what `ConnectionState` is published. Today's +`isReconnecting` branch publishes `.reconnecting(source: .system)` (`handleDidDisconnect`, :1210); +the new branch presumably publishes `.disconnecting`, but that must be stated — it is the only +place a `.disconnecting` would be emitted without an app call. Worse: if CoreBluetooth delivers +**no follow-up callback** for a cancel issued against a peripheral in OS-reconnect limbo, the +state sticks at `.disconnecting` forever and the stale `intentionalDisconnects` entry misclassifies +the *next* real drop as intentional. The plan's unknown covers only the **mock's** behavior; +extend it to on-device behavior, and specify a fallback (e.g., if no callback arrives, settle to +`.disconnected(reason: nil)` directly and remove the intentional flag). Note the loop question is +otherwise fine: `!wantsReconnect` implies `workCount == 0`, so the follow-up intentional branch +cannot relink — no cancel/reconnect cycle (see §Spot-checks). + +### 2.3 Idle teardown while `.reconnecting(source: .library)` — cancel against a disconnected peripheral + +Event 2 starts idle grace when the link is "connected/connecting/reconnecting", and event 6/7 fire +by "running the intentional-cancel path". But in library-`.reconnecting` the peripheral is +*physically disconnected* (the ladder task is sleeping between attempts). `syncReconnectIntent` on +demand-drop cancels the ladder task, which leaves `connectionStates[id]` frozen at +`.reconnecting(.library, …)` — nothing updates it — and then `fireIdle` calls +`cancelPeripheralConnection` on a peripheral that is not connected or connecting. CoreBluetooth +does not guarantee a `didDisconnect` for that; result is a permanent `.disconnecting` plus a +stranded intentional flag (same failure shape as §2.2). **Specify:** when demand drops while the +state is library-`.reconnecting` (or `.disconnected`/`.failed`), skip the CB cancel and the idle +timer entirely — cancel the ladder and publish `.disconnected(reason: nil)` synchronously. Idle +grace should only ever be armed against `.connected`/`.connecting`/system-`.reconnecting`. + +### 2.4 `reevaluateLink` failure handling in non-throwing contexts + +`reevaluateLink` throws `notFound` (event 5). Two of its call sites cannot propagate: the +intentional-relink branch of `handleDidDisconnect` (event 9) and the radio-return sweep +(event 12) — both run in delegate/event context with no caller to receive the error. After an +`invalidatePeripherals`, `refreshPeripherals` (:1037) only re-fills `cbPeripherals` for entries +`retrievePeripherals` still returns, so `notFound` is *reachable* on the event-12 path with a +work lease outstanding. The plan must say what happens: log-and-drop leaves the lease holder +waiting forever with no signal; publishing `.failed(reason: .notFound)` through +`setConnectionState` at least surfaces it on the stream. Pick one. + +### 2.5 Persistence has two write choke points + +Event 3 (`applyManualConnect`) says "persist reconnect intent if `autoReconnect` and +`restoreIdentifier != nil`", and D-4's `syncReconnectIntent` also persists ("insert … and persist +if the intent is hold-driven"). Since `applyManualConnect` already calls `syncReconnectIntent`, +the explicit persist in event 3 is redundant and invites drift. Make `syncReconnectIntent` the +sole writer (the plan already gives it the sole-remover role). + +### 2.6 `disconnect()` during a radio outage cannot drop the hold cleanly + +The demand-preserving invalidate (event 13) makes this state reachable: hold held, radio hits +`.resetting`, `cbPeripherals` cleared. The app calls `Peripheral.disconnect()` → event 4 "run the +existing intentional-cancel path" → today's `disconnect(id:)` throws `notFound` when +`cbPeripherals[id]` is nil (:1116). Is the hold cleared before the throw? Event 4's step order +(clear hold first) suggests yes, but then the app gets an error for a disconnect that actually +succeeded in dropping demand — and this is the *only* way to drop demand during an outage. +**Specify:** `applyManualDisconnect` clears the hold unconditionally and returns success when +there is no live peripheral or no CB-level connection to cancel (nothing to tear down is not an +error). + +### 2.7 `stopScanning` vs parked scan waiters + +`startScanning` can now be suspended in `waitUntilPoweredOn`. The plan leaves `stopScanning` +"unchanged", which means a `stopScanning` call does not cancel a parked `startScanning` — the scan +will start later, after the app said stop. Either document that the caller must cancel the task, +or have `stopScanning` fail/cancel pending scan waiters. One sentence in D-2 settles it. + +## 3. Details the code disproves, or that should be corrected + +### 3.1 `.poweredOff` does **not** invalidate peripherals — the edge-case row is wrong, and the relink path breaks (CONFIRMED, most important finding) + +The D-7 row "Demand present but radio drops to `.poweredOff` → `invalidatePeripherals` clears CB +state; demand is preserved; `reevaluateLink` re-runs when the radio returns (event 12)" is +disproved by `handleCentralManagerStateUpdate` (:849–:853): `.poweredOff` and `.unknown` are the +explicit *do-not-invalidate* branch; only `.resetting`/`.unsupported`/`.unauthorized` invalidate. +Consequence under the plan as written: radio cycles off→on with no invalidate; `connectionStates` +still says `.connected` for the (dead) link; event 12's `reevaluateLink` hits "Already +`.connected` → `syncReconnectIntent` only" and **never re-issues the connect**. The manual +on-device check the plan lists ("toggling Bluetooth off during an active work-driven link… +re-links on return") would fail. Correction — pick one and state it: + +- (a) Add `.poweredOff` to the invalidate triggers (with event 13's demand-preserving semantics), + or +- (b) keep `.poweredOff` non-invalidating but make `reevaluateLink`'s "already connected" arm + verify `cbPeripherals[id]?.state == .connected` rather than trusting `connectionStates`. + +(a) is simpler and matches event 13's design; note it also emits +`.disconnected(reason: .bluetoothUnavailable)` per `clearConnectionStates` (:1157), which is the +right app-visible signal for a radio-off drop. Either way, this is **automatable now** — the mock +supports `simulatePowerOff`/`simulatePowerOn` (existing tests use it, e.g. around +`ReliaBLEManagerTests.swift:315`) — so promote the "toggle Bluetooth during a work-driven link" +item from manual-only to a named test (e.g. `demandSurvivesRadioCycleAndRelinks`). + +### 3.2 `invalidatePeripherals` wipes the persisted intent set + +`invalidatePeripherals` (:993) does `reconnectEnabled.removeAll()` **then** +`persistReconnectIntent()` (:1000), writing an empty array to UserDefaults. Under the plan's new +semantics (persisted set = "last Manual connect wanted reconnect"), a transient `.resetting` +blip erases the persisted Manual-session intent; if the process dies before the radio returns, +it is gone across relaunch. Event 13 lists what invalidate must *preserve* (`workCount`, +`manualConnectHold`) but says nothing about persistence. Correction: invalidate must not write the +persisted set (drop the `persistReconnectIntent()` call there); with `syncReconnectIntent` as the +sole writer (§2.5), the set self-heals from demand anyway. + +### 3.3 The `issueConnect` extraction must strip side effects — the invariant is otherwise achievable (CONFIRMED) + +Verified: `centralManager.connect` has exactly one call site today (`connect`, :1095); the restore +path never issues connects (comment at :764: "Do not reconnect here"); `performReconnect` (:1305) +reaches CB via `connect(id:autoReconnect: true)`. So the plan's invariant is achievable. But the +plan should state explicitly that `issueConnect` carries **none** of `connect`'s current side +effects — today's body mutates `reconnectEnabled` (:1083–:1085), calls `persistReconnectIntent()` +(:1087), and clears `intentionalDisconnects` (:1088). If `performReconnect` routed through an +un-stripped extraction, every ladder attempt of a *work-driven* link would persist reconnect +intent, violating D-4's hold-driven-only persistence rule. `issueConnect` should be: optimistic +`.connecting` + options + `centralManager.connect`, nothing else. + +## 4. Problems absent from BOTH the export and the plan + +### 4.1 Double-release detection is impossible with the declared bookkeeping + +Both documents promise "releasing an unknown or already-released token is a no-op" but declare +only `workCount: [String: Int]`. A refcount cannot recognize an already-released token: +`max(0, current - 1)` only protects at zero. Acquire two leases, release token A twice → +`workCount` hits 0 while lease B is live → idle grace tears down a link that still has work. The +`WorkLeaseToken.leaseID: UUID` exists precisely to prevent this but nothing stores it. Correction: +track `activeLeases: [String: Set]` (derive `workCount` as `activeLeases[id]?.count ?? 0`, +or keep both in lockstep); `releaseWorkLease` no-ops unless it removes the token's UUID. The +`doubleReleaseIsNoOp` test as named would pass under the broken refcount if only one lease exists — +write it with **two** leases held. + +### 4.2 Lease holders have no failure signal + +`acquireWorkLease` returns a token after issuing (not completing) the connect. If the connect +fails and the ladder exhausts, the lease holder learns nothing — the only signal is the +connection-state stream. Acceptable for an internal Phase-2 primitive whose consumers are tests, +but the plan should say so explicitly, and note for Phase 3 that the command queue will need an +*await-linked* primitive (or per-command failure delivery) layered on the lease — otherwise +`ensureLinked`'s fire-and-forget shape gets baked into the queue design by accident. + +### 4.3 `cancelPeripheralConnection` callback guarantees are load-bearing and unverified + +Three plan paths assume a cancel produces a terminal delegate callback: `fireIdle`, the +untrusted-Tier-0 branch (§2.2), and idle-grace-during-`.connecting` (the "last lease released +while `.connecting`" row). CoreBluetooth's documented behavior for cancelling a *pending* connect +or an OS-reconnecting link does not guarantee `didDisconnect` in all cases; the mock's behavior is +a separate question (the plan's existing unknown). If no callback arrives, `.disconnecting` is +terminal-but-wrong and `intentionalDisconnects` leaks. Broaden the unknowns table entry from +"mock behavior during `isReconnecting`" to "callback guarantees for cancel during pending +connect / OS-reconnect, mock **and** device", and give every optimistic `.disconnecting` a +settlement rule. + +### 4.4 Resumed waiters act on possibly stale state + +`resolvePoweredOnWaiters` resumes continuations from the state handler, but the resumed caller's +code (issuing `scanForPeripherals`, `issueConnect`) runs at some later actor turn — by which time +the radio may have flipped again. D-1 event 5 says the gate is "re-checked cheaply" for the +connect path, but the scan path has no such statement, and D-2's call-site table shows scan +proceeding directly after the wait. Add: every resumed waiter re-checks `centralManager.state` +before acting and re-parks (or throws, per D-radio) on regression. Related: when a restored scan +(`pendingRestoredScanServices`) and an awaited `startScanning` both fire at `.poweredOn`, both +call `scanForPeripherals` and last-writer-wins on the single CB scan — state which filter wins +(app-requested should; say so) as part of resolving the plan's existing `resumeRestoredScan` +unknown. + +## 5. The persisted-intent question (user-requested assessment): reduce to write-side only + +The plan flags the mechanism as "close to inert" and says keep-but-document. The code says it is +worse than inert — it is **self-erasing**, and the read side is provably dead: + +- Restore seeds `reconnectEnabled` from the persisted set (:768–:777). But `armReconnect` now + gates on `wantsReconnect(id)` — derived from `workCount`/`manualConnectHold`, both empty at relaunch — + so a seeded entry can never arm the ladder. The seeded entry is also outside `syncReconnectIntent`'s + derivation, so the first demand change for that id removes it and **clears the persisted record**. +- Independently, D-restore guarantees the restored link idles out; `fireIdle` "runs the existing + intentional-cancel path", which today removes the id from `reconnectEnabled` and calls + `persistReconnectIntent()` (:1122–:1123) — wiping the persisted entry on the very first restore + cycle. The plan's own "load-bearing again in Phase 3" claim cannot survive this: by the time a + Phase-3 restored queue exists, the set is always empty. +- And Phase 3 does not actually need the read: a restored command queue creates work leases, which + drive `wantsReconnect` by themselves; a re-declared hold passes `autoReconnect` explicitly. The + only hypothetical consumer of restored intent was the hold re-application that D-restore + (correctly) forbids. + +**Recommendation — reduce, don't keep or remove:** keep the UserDefaults write path (in +`syncReconnectIntent`, hold-driven only, per §2.5/§3.2 so it stops being wiped by invalidate and +idle teardown), and delete the restore-time read into `reconnectEnabled` (:741–:777's +`persistedIntent` branchs) plus the now-dead `willRestoreSeedingReconnectOnlyForConnectedOrConnecting` +/ `willRestoreDoesNotRearmReconnectWithoutPersistedIntent` test expectations. This honors the user +decision — no manual-connect hold is restored, and the persisted *record* of intent survives for Phase 3 — while +removing read-side code whose only remaining behavior is to be erased. If the read is kept +instead, the plan must add exemptions so seeded entries are neither removed by the first +`syncReconnectIntent` nor wiped by idle teardown — more mechanism for zero Phase-2 behavior. + +## 6. Questions that would materially change the design + +1. **Does a `reconnectDesired: false` hold count as re-issue demand after an unexpected drop or + radio cycle, or only as idle suppression?** (§2.1 — changes `reevaluateLink`'s gate and the + untrusted-Tier-0 branch.) +2. **What terminal state settles a cancel that never produces a delegate callback** (pending + connect, OS-reconnect limbo)? (§2.2/§4.3 — determines whether `.disconnecting` needs a + watchdog or a synchronous settle.) +3. **On `.poweredOff`, invalidate-with-demand-preservation or verify-CB-state-in-`reevaluateLink`?** + (§3.1 — decides whether event 13 gains a fourth trigger or event 5 changes its "already + connected" check; affects which tests are writable.) +4. **Where do `reevaluateLink` errors surface in delegate contexts** — log-and-drop or + `.failed(reason:)` on the stream? (§2.4 — determines whether a stranded work lease is + observable.) +5. **Should `stopScanning` cancel parked scan waiters?** (§2.7 — small, but it is public API + behavior and must be documented either way.) + +None of these block starting commits 1–3 (config, errors, PoweredOn await); questions 1–4 should +be settled before commit 4 (demand substrate) and 6 (gating/restore), where their answers change +code shape. + +## Spot-check answers (requested) + +| Check | Result | +|-------|--------| +| `handleDidDisconnect` redesign loop-safety | **No cancel/reconnect loop** in the specified branches: untrusted-Tier-0 requires `!wantsReconnect` ⇒ `workCount == 0`, so its follow-up intentional disconnect cannot trigger the relink branch; the intentional-relink branch issues at most one connect per disconnect. But the branch is under-specified (§2.2) and the `reconnectDesired:false` hold breaks it (§2.1). | +| `invalidatePeripherals` preserving `workCount`/`manualConnectHold` | **Safe for CB state**: `cbPeripherals.removeAll()` + `clearConnectionStates()` (which nils the registry mirror and emits `.disconnected(.bluetoothUnavailable)`) compose fine with preserved demand, and `refreshPeripherals` re-fills from the retained `discoveredPeripherals`. **Unsafe for persistence** (§3.2) and needs a `notFound` story on relink (§2.4). | +| `startScanning` throwing vs `resumeRestoredScan` deferral | **No conflict** — the deferral is internal and stays non-throwing. Interaction gap is filter precedence + stale-state re-check when both fire at `.poweredOn` (§4.4); the plan's existing unknown should absorb both. | +| `centralManager.connect` exactly once | **Achievable** — single call site today (:1095); restore never connects; `performReconnect` routes through `connect`. Requires the side-effect-free `issueConnect` extraction (§3.3). | +| Persisted intent inert under D-restore? | **Worse — self-erasing, read side dead.** Reduce to write-side only (§5). | + +## Consistency with the four fixed decisions + +- **One PR** — consistent throughout (D-deliv, implementation order, issue map). +- **poweredOff fails fast** — consistent in D-2, D-7, D-8, and the test table; the export's + contrary rows are all overridden. No stragglers found. +- **Internal-only work primitive** — consistent (D-work, D-5, issue map shows no public delta for + #60). +- **Restore = intent only, never a hold** — consistent in D-restore, D-7, DocC rows, and tests; + the follow-through gap is that the restored intent is then erased before it can ever matter + (§5), and event 13/`.poweredOff` handling (§3.1, §3.2) undermines the "demand survives radio + loss" half of the same story. diff --git a/docs/reviews/work-driven-connection-lifecycle-poweredoff-feedback-2026-08-05.md b/docs/reviews/work-driven-connection-lifecycle-poweredoff-feedback-2026-08-05.md new file mode 100644 index 0000000..358335f --- /dev/null +++ b/docs/reviews/work-driven-connection-lifecycle-poweredoff-feedback-2026-08-05.md @@ -0,0 +1,255 @@ +# Feedback for planning agent: roll into work-driven connection lifecycle plan + +**Audience:** main planning agent updating `docs/plans/work-driven-connection-lifecycle-2026-08-02.md` +**Source:** user review of poweredOff behavior summary + confirmed design choices (2026-08-05) +**Do not implement code from this doc.** Apply these amendments into the lifecycle plan only, then re-check consistency of D-0 / D-1 events / D-2 / D-4 / D-7 / tests / DocC / rejected alternatives. + +**Prior critique (still valid where not overridden):** `docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md` + +--- + +## How to apply + +1. Treat each section below as a **normative plan change** unless marked optional. +2. Where this feedback **contradicts** the current plan (especially **D-restore**, restore-time persistence read, connect hold ordering, parked-scan `CancellationError`), **this feedback wins** — it is later user direction. +3. Leave Phase 2 implementation to a later execute pass; this is design-only roll-up. +4. After editing the plan, update the test table, risks, and rejected-alternatives rows so they do not restate superseded decisions. + +--- + +## A — Bluetooth state observation — no plan change + +Keep `ReliaBLEManager.state` / `currentState` as the UI-gating surface, including `.poweredOff`. FR-1.4 dual requirement (observable state + typed operation errors) stands. + +--- + +## B — No live `cbPeripheral` under demand — **keep D-never; no internal scan in Phase 2** + +### Confirmed + +| Path | Phase 2 | Mechanism | +|------|---------|-----------| +| Known id after power cycle | Yes | `refreshPeripherals()` → `retrievePeripherals(withIdentifiers:)` | +| Known id still in CB system cache | Yes | Same retrieve on radio-return / `reevaluateLink` | +| Never-seen / not retrievable | No | Fail-fast `PeripheralError.notFound` (D-never) | +| Library-owned continuous or demand-driven scan | Deferred | FR-8.2 / FR-4 command queue | + +### Plan edits + +- **Keep D-never** as written (fail-fast, no await-for-discovery, no implicit scan). +- Under D-never / D-7, add: recovery after invalidate is **retrieve-only**; no library scan loop. +- **Do not** invent a second “scan-until-found” stack in Phase 2. Keep a single `reevaluateLink` that surfaces `notFound` when there is no live ref — FR-8.2/FR-4 can later add scan policy + call `reevaluateLink` on discovery (additive). +- **Optional Phase 2 polish (document as allowed, implement if cheap):** when discovery upserts an id that already has demand, call `reevaluateLink` (e.g. reason `.discoveredWhileDemanded` or reuse `.radioReturned`). Does not start scans; only links if something else discovered the device. + +--- + +## C — Pending restored-scan filter cleared on invalidate — **don’t box out FR-8.2** + +### Context + +Today `invalidatePeripherals` nils `pendingRestoredScanServices` / options. Warm power-off can drop a deferred restored scan that had not resumed. + +### Plan edits + +- Phase 2 must **not** invent continuous-scan demand or make work-lease / manual-hold imply “keep scanning.” +- **Preferred minimal Phase 2 change:** when invalidating due to `.poweredOff` / `.resetting`, **preserve** `pendingRestoredScanServices` and options (stop nilling them on that path). Full continuous-scan policy stays FR-8.2. +- If preserving is not trivial without inventing scan-demand semantics, leave clear-as-today and add a one-line open item **owned by FR-8.2**: “power-cycle may drop deferred restored scan until continuous-scan demand is defined.” +- DocC / risks: one sentence that true background continuous scan is FR-8.2, not Phase 2 demand/hold. + +--- + +## D — Scan/connect fail-fast on `.poweredOff` — no change to throw policy + +D-radio stands: `.poweredOff` → `PeripheralError.bluetoothPoweredOff` (not hang). Hold registration order is refined in **E**. + +--- + +## E — Manual-connect hold **before** radio wait — **amend plan (user)** + +### Problem with current plan + +`ensure → waitUntilPoweredOn → applyManualConnect` means a failed wait leaves **no hold**, so radio return does nothing. That contradicts `autoReconnect: true` as “connect whenever the peripheral is available.” + +### Normative connect sequence + +``` +Peripheral.connect(autoReconnect:): + 1. ensureCentralManager() + 2. applyManualConnectHoldOnly(id, reconnectDesired: autoReconnect) + // set manualConnectHold, cancel idle, sync persistence (hold map) + 3. try waitUntilPoweredOn() // may throw bluetoothPoweredOff / unsupported / unavailable + 4. try reevaluateLink(id, reason: .explicitConnect) +``` + +| `autoReconnect` | Wait fails | After throw | Radio returns | +|-----------------|------------|-------------|---------------| +| `true` | Hold already set + persisted | Typed error to app | Event 12 re-issues (`wantsReconnect`) | +| `false` | Hold already set (demand true, wantsReconnect false) | Typed error | Radio-return does **not** re-issue (issue gate) | + +### Plan edits + +- Update D-hold, event 3 / call-site table, Peripheral.connect sketch. +- D-7 row: “connect while poweredOff sets hold then throws.” +- Rejected alternative: “hold only after radio wait succeeds.” +- Tests: `connectPoweredOffSetsHoldAndRelinksOnPowerOn`, `connectAutoReconnectFalsePoweredOffDoesNotRelinkOnPowerOn`. +- Public API name remains `autoReconnect` (not `autoConnect`). + +--- + +## F — After radio drop, auto-relink peripherals show `.reconnecting` — **amend plan (user)** + +### Problem + +`clearConnectionStates` → stream `.disconnected(.bluetoothUnavailable)` + handle `connectionState == nil` hides auto-relink intent. With no public demand API, the app cannot tell “will come back” from “gone.” + +### Confirmed public state + +After the drop signal, ids with **`wantsReconnect(id)`** use: + +```swift +.reconnecting(source: .library, attempt: nil, nextRetryAt: nil) +``` + +while radio is down or until `issueConnect` / ladder / success / demand cleared. + +### Normative sequence on `.poweredOff` invalidate (event 13) + +1. For each tracked id: emit `.disconnected(reason: .bluetoothUnavailable)` (link is dead). +2. Clear CB maps; cancel idle + ladder tasks; **preserve** `activeLeases` + `manualConnectHold`. +3. For each id with `wantsReconnect(id)`: `setConnectionState(.reconnecting(source: .library, attempt: nil, nextRetryAt: nil))`. +4. Demand but **not** wantsReconnect (`connect(autoReconnect: false)`): **no** false “will reconnect” signal (settle disconnected / no reconnecting phase). +5. No demand: handle untracked (`nil` after clear); stream had step 1 only. +6. On radio return: event 12 → `issueConnect` → `.connecting` → `.connected` / fail / ladder as today. + +### Plan edits + +- Map conceptual phase `AwaitingRadio` → public `.reconnecting(source: .library, attempt: nil, nextRetryAt: nil)` when `wantsReconnect`. +- Replace bulk “always nil handle” narrative for auto-relink ids; likely replace/split `clearConnectionStates` with per-id policy. +- DocC on `ConnectionState.reconnecting`: nil `attempt` / `nextRetryAt` means “waiting for radio or not yet on backoff ladder,” distinct from an armed ladder step. +- Tests: power-off with hold/lease asserts reconnecting, not permanent nil. + +--- + +## G — Approach B reconnect gating — no plan change + +`wantsReconnect` gates Tier-0 option and Tier-1 `armReconnect`; quiet peripherals never arm. + +--- + +## H — Not re-retrievable after refresh — **fatal for auto recovery; no scan/retry** + +### Confirmed + +- Delegate-context `reevaluateLink` → `.failed(reason: .notFound)` on the connection-state stream. +- **No** library scan and **no** silent retry loop in Phase 2. +- Lease/hold **remain** unless app `disconnect()` / releases work (demand not silently dropped). +- Optional discovery→`reevaluateLink` (B) can recover later if the app scans. +- Phase 3 command queue should treat `.failed(.notFound)` as command failure (separate from lease lifetime). + +### Plan edits + +- D-7 / unknowns: state “no scan-on-notFound.” +- `strandedLeaseSurfacesFailure` asserts demand retained after failure signal. + +--- + +## J — Background restore re-applies manual-connect hold — **reverse prior D-restore (user)** + +### Prior plan (superseded) + +D-restore said restored links always idle; UserDefaults is reconnect intent only, **never** a hold; demand must be re-declared after relaunch. D-4 said **delete the restore-time read**. + +### New normative D-restore + +| Restored situation | Behavior | +|--------------------|----------| +| OS restores peripheral **and** persisted **manual-connect hold** for that id | Rehydrate `manualConnectHold[id]` (with `reconnectDesired`), sync intent, **no idle timer**, `reevaluateLink` if not linked | +| OS restores link, **no** persisted hold | **Idle timer** (battery-first for residual/Tier-0 restored links) | +| Work leases | **Never** survive process death | +| Hold with `reconnectDesired: false` | Survives restore: demand/idle suppressed; no Tier-1 / no radio-return re-issue | + +### Persistence model + +- Persist enough to rehydrate **holds**, not only a reconnect-enabled Set. +- **Recommended encoding:** dictionary `id → reconnectDesired: Bool` (or equivalent) namespaced by `restoreIdentifier`. A Set of true-only ids is **insufficient** for `autoReconnect: false` holds. +- Sole writer remains demand-sync after hold changes (rename in prose to “sync persisted holds” if clearer). +- **Restore-time read returns** in `handleWillRestoreState` — undo “delete the restore-time read.” +- Idle-out of one restored link must not wipe other ids’ persisted holds. +- NFR-3.2: still met for restored links **without** a prior Manual connect hold. Manual `connect` is explicit “keep this link” and should outlive relaunch when restoration is configured. + +### Plan edits + +- Replace D-restore row in D-0. +- Rewrite D-4 (persistence meaning, restore read, tests). +- Remove from rejected alternatives / risks any “never re-apply hold” / “delete restore-time read” language that is now wrong. +- Tests: `restoredManualHoldSurvivesRelaunch`, `restoredLinkWithoutHoldIdlesOut`, `restoredHoldWithAutoReconnectFalseSuppressesIdleButNotTier1`. + +--- + +## K — Parked `startScanning` waiter — **success on stopScanning supersede; `CancellationError` only on Task cancel** + +### Supersedes plan row that always uses `CancellationError` for cancelled waiters + +| Cause | `startScanning` result | +|-------|------------------------| +| `stopScanning()` while parked on transient radio | **Return success** (void), no scan started | +| Superseded by another `startScanning` | Earlier waiter completes **success** without scanning (or coalesced — document) | +| Calling task cancelled (`Task.cancel`) | **`CancellationError`** | +| Radio resolves to `.poweredOff` | **`bluetoothPoweredOff`** (not success) | + +### Plan edits + +- D-2 call-site table for `stopScanning`. +- D-7 rows for waiting scan cancelled vs stopScanning. +- Tests: `stopScanningCompletesParkedScanWaiterSuccessfully`; separate test for Task cancel → `CancellationError`. +- Implementation note: resume reason enum (`.poweredOn` / `.superseded` / `.failed` / cancel path). + +--- + +## L — `shutdown()` must not touch UserDefaults — **confirm and harden** + +### Already true in code comments; plan must not regress + +- `shutdown()` clears **volatile** state only (holds, leases, waiters, task registries, streams). +- **Must not** call `persistReconnectIntent` / hold-sync writers when clearing in-memory holds (empty flush would erase restore intent — breaks **J**). +- Optional explicit clear/reset API for UserDefaults can come later; test hook `testClearPersistedReconnectIntent` may remain. +- Event 14 bullet + risk table + test: shutdown leaves persisted hold map unchanged. + +--- + +## Checklist for the planning agent (edit targets in the lifecycle plan) + +| # | Section | Action | +|---|---------|--------| +| 1 | D-0 **D-restore** | Reverse: rehydrate holds from persistence; idle only without hold; work non-durable | +| 2 | **D-4** | Persist hold map `id → reconnectDesired`; restore-time read **returns**; sole writer demand-sync; invalidate/shutdown do not wipe disk | +| 3 | **D-hold** / connect path / D-2 table | Hold **before** `waitUntilPoweredOn` (E) | +| 4 | D-1 **event 13** | After disconnect emission, `wantsReconnect` → `.reconnecting(library, nil, nil)` (F); still add `.poweredOff` to invalidate triggers; preserve demand | +| 5 | **D-never** / B | Keep fail-fast; retrieve-only recovery; optional discovery→reevaluate; no internal scan | +| 6 | Event 13 / invalidate | Prefer preserve pending restored scan on power-cycle invalidate (C); FR-8.2 owns continuous scan | +| 7 | **D-2** / K | stopScanning supersede → success; Task.cancel → CancellationError | +| 8 | **H** | notFound after refresh terminal for auto path; demand retained | +| 9 | Event 14 / **L** | shutdown never writes UserDefaults | +| 10 | D-7, tests, DocC, risks | Align all tables with above | +| 11 | **D-8 Rejected alternatives** | Strike superseded rows (delete restore read; never re-apply hold; hold only after wait); add rejections as needed (hang on poweredOff stays rejected; internal scan-on-demand stays rejected for Phase 2) | + +--- + +## Explicitly out of scope for this roll-up + +- Implementing Phase 2 code. +- FR-8.2 continuous / background scan policy design. +- FR-4 command queue / await-linked work. +- Public work-lease API. + +--- + +## Consistency checks after the plan edit + +- [ ] No remaining “delete restore-time read” or “restoration never re-applies a manual-connect hold.” +- [ ] No remaining “hold applied only after wait succeeds.” +- [ ] No remaining “parked scan waiter always throws CancellationError” without distinguishing stopScanning vs Task.cancel. +- [ ] Event 13 + F do not leave auto-relink ids stuck at handle `nil` with no reconnecting signal. +- [ ] Persistence encoding supports `reconnectDesired: false` holds across restore (dictionary, not true-only Set). +- [ ] shutdown / invalidate paths never empty-flush UserDefaults. +- [ ] D-never still blocks Phase 2 internal scan; FR-8.2 door left open. diff --git a/docs/test-plans/work-driven-connection-lifecycle-hw-2026-08-09.md b/docs/test-plans/work-driven-connection-lifecycle-hw-2026-08-09.md new file mode 100644 index 0000000..c55cb3d --- /dev/null +++ b/docs/test-plans/work-driven-connection-lifecycle-hw-2026-08-09.md @@ -0,0 +1,817 @@ +# Manual Hardware Test Plan — Work-Driven Connection Lifecycle + +| Field | Value | +|-------|--------| +| **Branch** | `51-work-driven-connection-lifecycle` | +| **Tracking** | [#51](https://github.com/Five3Apps/ReliaBLE/issues/51) (#57–#60) | +| **Plan** | `docs/plans/work-driven-connection-lifecycle-2026-08-02.md` | +| **App under test** | **ReliaBLE Demo** (Xcode → physical devices) | +| **Library** | ReliaBLE (local package dependency) | +| **Date** | 2026-08-09 (Faraday box notes added same day) | +| **Scope** | Feature validation of Phase 2 lifecycle + regression of scan/connect/restore/background | +| **Audience** | Manual execution on real hardware (2–3 Apple devices) + Faraday box for RF drops | + +--- + +## 1. Purpose + +Validate that the Demo + library on real radios behave according to the work-driven connection model: + +1. **#57 — PoweredOn await** — scan/connect fail fast on terminal radio states; do not silently no-op. +2. **#58 — Idle + Manual connect hold** — Manual `connect` holds the link (no idle teardown); `disconnect` clears the hold and tears down intentionally. +3. **#59 — Reconnect gating** — Tier-0 (OS) and Tier-1 (library ladder) only run while demand wants reconnect; quiet links stay quiet. +4. **#60 — Work-driven auto-connect** — primarily covered by unit tests (internal work leases). On device, validate the public half: Manual hold drives a standing link, idle is suppressed while held, and restore rehydrates holds. + +Also re-check **regressions**: discovery, multi-device advertising, background modes, authorization, logging, and multi-central behavior. + +--- + +## 2. What you can (and cannot) see in the Demo + +### 2.1 Public surfaces exercised by the Demo + +| Surface | Where in Demo | +|---------|----------------| +| `startScanning` (now `async throws`) | Central tab → **Start Scanning**; errors as red `scanError` text | +| `stopScanning` | Central tab → **Stop Scanning** | +| Bluetooth `state` stream | Top of Central: `ReliaBLE state: …` | +| Discovery streams | **Devices** / **Discoveries** lists | +| `Peripheral.connect(autoReconnect:)` | Device detail → **Connect** + **Auto Reconnect** toggle | +| `Peripheral.disconnect()` | Device detail → **Disconnect** | +| Connection-state stream | List caption + detail `Connection: …` | +| `idleDisconnectInterval` | Settings → **Connection Lifecycle** (takes effect **next launch**) | +| `ReconnectPolicy` | Settings → **Reconnect Policy** (next launch) | +| State restoration | App always uses `restoreIdentifier = com.five3apps.relia-ble-demo.central` | +| Peripheral role | **Peripheral** tab (raw `CBPeripheralManager`, not ReliaBLE) | + +### 2.2 Not available in Demo UI (unit-test only) + +| Behavior | Why | +|----------|-----| +| `acquireWorkLease` / `releaseWorkLease` | Internal / `@testable` only until the command queue (Phase 3) | +| Idle teardown after “work finished” | Idle fires only when **demand is zero**. Demo connect always sets a **manual-connect hold**, which suppresses idle | +| Double-release / refcount lease edge cases | Test hooks only | + +**Implication for idle testing on HW:** you primarily prove that a **manual hold keeps the link up** past several idle intervals, and that **intentional disconnect does not arm reconnect**. True “queue drained → idle cancel” is trusted via automated tests unless you build a temporary debug hook. + +### 2.3 Connection captions (expected strings) + +From Demo `ConnectionState.description`: + +| State | UI text | Color (approx.) | +|-------|---------|-----------------| +| `.connecting` | `Connecting` | orange | +| `.connected` | `Connected` | green | +| `.disconnecting` | `Disconnecting` | orange | +| `.disconnected(nil)` | `Disconnected` | secondary | +| `.disconnected(reason)` | `Disconnected (…)` | orange | +| `.failed(reason)` | `Failed (…)` | red | +| `.reconnecting(.system, …)` | `System reconnecting…` | yellow | +| `.reconnecting(.library, attempt, …)` | **`Waiting for Bluetooth…`** when `attempt == nil` (radio await); `Reconnecting (attempt N)` when ladder armed (`attempt >= 1`) | yellow | +| Ladder step | Same + optional `Next retry in: Xs` countdown | yellow | + +**Radio-await projection (important):** after Bluetooth power-off **while linked or connecting** with Auto Reconnect demand, expect roughly: + +1. `Disconnected (bluetoothUnavailable)` (or similar reason text) +2. then **`Waiting for Bluetooth…`** **without** a retry countdown (library waiting for radio, not ladder) + +The same **Waiting for Bluetooth…** caption is projected when **Connect** (Auto Reconnect ON) is tapped while the radio is already off (hold-before-wait), even though the connect call fails fast with `bluetoothPoweredOff`. + +When Auto Reconnect was **off** and the device was still linked, expect settle at `Disconnected (bluetoothUnavailable)` **without** a lasting “waiting for Bluetooth” claim. + +When the peripheral was **already** cleanly `Disconnected` / `Failed` before power-off (e.g. intentional Disconnect, then BT off), the library does **not** rewrite the caption to `bluetoothUnavailable` — stay on the prior terminal text (Demo stream cache) or clear to untracked. + +--- + +## 3. Hardware and environment + +### 3.1 Recommended device roster + +Assign stable labels and stick to them for the whole session: + +| Label | Device | Primary role(s) | +|-------|--------|------------------| +| **C1** | iPhone A | Primary **Central** (ReliaBLE under test) | +| **P1** | iPhone B | **Peripheral** advertiser (Demo Peripheral tab) | +| **C2** | iPad | Second **Central** (multi-central / concurrent regression) | + +Optional swaps: use iPad as P1 or C1 if convenient. Prefer two physical centrals for multi-central regression. + +### 3.2 Software + +- Xcode open on `ReliaBLE.xcworkspace` (or Demo project that consumes the local package). +- Branch: `51-work-driven-connection-lifecycle` (or the PR branch that contains these commits). +- Deploy **Debug** builds from Xcode to each device (Run to device). +- iOS versions: note in the results log (Tier-0 OS auto-reconnect requires **iOS 17+**). +- Keep devices unlocked, screen on for time-sensitive connection captions, or use Console (below). + +### 3.3 Default Demo UUIDs / names + +| Item | Default | +|------|---------| +| Demo service UUID | `12345678-90AB-CDEF-1234-567890ABCDEF` | +| Peripheral local name | `ReliaBLE Demo` (change per device if running two advertisers) | +| Central service filter field | Pre-filled with the same UUID (required for meaningful background scan) | +| Idle default | `5.0` s (Settings → change + **force quit & relaunch** to apply) | +| Restore ID | `com.five3apps.relia-ble-demo.central` (hard-coded in app) | + +### 3.4 Faraday box (preferred for RF drop / reconnect) + +You have a **Faraday box without USB passthrough**. That does **not** change which behaviors to test; it **does** change how drop cases are run and how evidence is collected. + +#### Preferred geometry: isolate the **peripheral**, observe the **central** + +| Role | Where | Why | +|------|--------|-----| +| **P1** (advertiser) | **Inside** the sealed box for the outage window | Drops the ACL/RF path cleanly and repeatably | +| **C1** (ReliaBLE under test) | **Outside** the box | You can watch connection captions live; Xcode/Console may stay attached to C1 | +| **C2** | Outside (multi-central cases) | Same as C1 | + +Do **not** put C1 in the box for primary F3/F10 runs unless you are specifically testing “central RF isolation.” Without USB passthrough you lose the debug cable, and opaque boxes prevent watching the central UI during the critical transition. + +#### Standard drop / restore procedure + +1. Outside the box: P1 **Start Advertising**, C1 scan → connect to the desired Auto Reconnect setting → confirm **Connected**. +2. Keep C1 screen awake on P1’s device detail (connection caption visible). Optional: leave Xcode/Console attached to **C1 only**. +3. Place **P1** in the Faraday box, close/seal fully. Do not rely on a USB cable to P1. +4. On C1, note time-to-leave-Connected and intermediate captions (`System reconnecting…`, library reconnect, disconnected reason, etc.). +5. For recovery cases: open box / remove P1 (still advertising if the app was not killed) → note time-to-**Connected** on C1 without tapping Connect. +6. For “stay dead” cases (Auto Reconnect OFF): leave P1 out and advertising ≥ 30–60 s → must **not** auto-connect. + +#### Constraints the box imposes (non-blocking) + +| Constraint | Impact | Mitigation | +|------------|--------|------------| +| No USB while sealed | Cannot keep Xcode debugger on the **device inside** the box | Put only P1 inside; instrument C1 outside | +| Opaque enclosure | Cannot watch UI on the boxed device mid-outage | Observe C1; only glance at P1 after unsealing if needed | +| Wireless debug / Console to boxed device | Often fails or is flaky inside RF shield | Prefer Console/Xcode on C1; treat P1 as a dumb advertiser for drop suites | +| App may suspend on locked P1 | Advertising can stop if P1 sleeps aggressively | Before boxing: keep P1 unlocked, screen on, Low Power Mode off; Guided Access optional; confirm **Advertising** still true | +| Charge | Long sealed runs drain battery | Start drop suites with P1 ≥ ~50% battery | +| Incomplete seal / lid ajar | Partial isolation → flaky “still connected” | If Connected never drops after ~30–60 s, reseal and retry once before PARTIAL | + +#### What still does **not** use the Faraday box + +Leave these as Control Center / Settings / app lifecycle tests (box adds nothing): + +- F1 radio gating (central BT off) +- F2 hold / idle suppression +- F4 **central** Bluetooth power cycle +- F5 force quit / restore +- F6–F9 multi-central, filters, settings + +#### When to put the **central** in the box (optional, rare) + +Only if you want a secondary check that C1 loses the peer when *its* RF is blocked. Then: + +1. Pre-deploy Demo to C1 from Xcode; disconnect the cable. +2. Launch Demo from the home screen (standalone). +3. Connect to P1 (P1 outside, advertising). +4. Seal C1 in the box; you will **not** see live UI — use a wall-clock timer. +5. Unseal and read the connection caption (may already show reconnecting/connected/disconnected). + +This is inferior to P1-in-box for pass/fail of intermediate states; treat as optional stress, not the ship bar. + +### 3.5 Console logging + +Demo enables logging by default (`OSLogWriter`, subsystem `com.five3apps.relia-ble-demo`, category `BLE`). Optional: + +1. Mac: Console.app → select the **outside** central (C1) → filter `relia-ble` or `BLE`. +2. Xcode: keep the debug session on **C1** for Faraday drop suites; do not depend on a cable to P1. +3. Settings → **Enable Logging** if you turned it off. +4. Wireless logging to a device **inside** the sealed box is unreliable — do not require it for PASS/FAIL. + +Useful log themes (category `connection`): + +| Theme | Level | When you should see it | +|-------|-------|------------------------| +| Manual connect / Manual disconnect | info | Explicit Demo Connect / Disconnect | +| Live connection dropped (bluetoothUnavailable) — radio invalidated | **warn** | BT off (or other radio death) while **Connected** (F4.1) | +| In-progress connection dropped (bluetoothUnavailable) | **warn** | BT off while Connecting / Reconnecting / Disconnecting | +| Awaiting radio return for reconnect | info | Demand still wants reconnect after radio death | +| Radio returned — reissuing connect | info | BT back on; library re-issues connect for demanded id | +| Peripheral disconnected unexpectedly (system reconnecting) | **warn** | Tier-0 path: physical drop while OS auto-reconnect is active (`isReconnecting == true`); may append mapped CB error | +| System auto-reconnect in progress | info | Follows the warn above when demand still wants the link (iOS 17+) | +| Library reconnect ladder armed / scheduled / firing | info | Tier-1 ladder after hard unexpected disconnect (F3.3, Faraday) | +| Library reconnect ladder exhausted | info | maxAttempts reached | +| Idle timer armed / Idle disconnect | info | Demand hits zero (rare via pure Manual path) | + +### 3.6 Pass / fail conventions + +| Result | Meaning | +|--------|---------| +| **PASS** | Observed behavior matches Expected | +| **FAIL** | Deviates; capture device, OS, steps, UI/console evidence | +| **BLOCKED** | Could not run (no advertiser, permission stuck, OS limitation, box isolation failure after retry) | +| **N/A** | Not applicable to this hardware (e.g. Tier-0 on iOS < 17) | +| **PARTIAL** | Behavior close but timing/UI ambiguity; note details | + +Prefer the Faraday box over walking-away for RF drops. If Connected never drops with P1 sealed, reseal once; then mark **BLOCKED** (isolation) rather than library FAIL. Crowded 2.4 GHz is less relevant for sealed-box runs but still applies to multi-central discovery suites. + +--- + +## 4. Session setup checklist + +Complete once at the start of the session (and after any Settings policy change that needs relaunch). + +### 4.1 Clean slate (recommended first pass) + +On each Central (**C1**, **C2**): + +1. Install/run Demo from Xcode. +2. Settings → note Idle Disconnect (start with **5.0 s** unless a case asks otherwise). +3. Settings → Reconnect Policy defaults are fine for first pass (`maxAttempts 5`, `initialDelay 1.0`, `maxDelay 30`, `jitter 0.2`). +4. Central tab → if prompted, **Authorize Bluetooth** (Allow). +5. System Settings → Bluetooth **On**. +6. Optional: Central → **Clear All** to wipe SwiftData devices/discoveries between major suites. + +On Peripheral (**P1**): + +1. Open **Peripheral** tab. +2. Confirm name (e.g. `ReliaBLE Demo P1`) and service UUID = default above. +3. **Start Advertising** → Status shows **Advertising** / Powered On. + +### 4.2 Baseline discovery smoke (must pass before features) + +| Step | Action | Expected | +|------|--------|----------| +| 1 | C1 Central ready → leave service filter as default UUID → **Start Scanning** | `ReliaBLE state: scanning` (or equivalent); no red error | +| 2 | P1 advertising | C1 **Devices** shows P1 within ~10–30 s; **Discoveries** accumulates RSSI events | +| 3 | C1 **Stop Scanning** | State leaves scanning; discoveries stop growing | +| 4 | Open device detail on P1 | Shows ID, last seen; Connection unknown/disconnected; **Connect** enabled | + +Record: **PASS / FAIL** baseline. If discovery fails, do not proceed to connection suites until advertising UUID match and BT authorization are fixed. + +### 4.3 Optional: faster idle-related observation + +For cases that wait multiple idle multiples (e.g. “still connected after 3× interval”): + +1. Settings → Idle Disconnect = **2.0 s** (or **1.0 s**). +2. Force quit Demo on that central → relaunch from Xcode or home screen. +3. Note the value in the results log. **Revert to 5.0 s** before restore/battery-oriented cases if you care about production-like timing. + +--- + +## 5. Feature tests + +### Suite F1 — Radio gating / PoweredOn await (#57) + +**Goal:** Terminal Bluetooth states fail scan/connect with typed errors; no silent no-op. Ready radio works. + +#### F1.1 Scan while Bluetooth off + +| | | +|--|--| +| **Devices** | C1 | +| **Steps** | 1. System Settings → Bluetooth **Off** (or Control Center long-press → toggle). 2. Return to Demo Central (state should reflect powered off). 3. If UI still shows Start Scanning, tap it. | +| **Expected** | Red scan error mentioning `bluetoothPoweredOff` (or `PeripheralError.bluetoothPoweredOff`). **No** discoveries. Manager state reflects powered off. | +| **Regression note** | Pre-change bug was silent return; any “nothing happened, no error” is **FAIL**. | + +#### F1.2 Scan recovers after power on + +| | | +|--|--| +| **Devices** | C1 + P1 advertising | +| **Steps** | 1. From F1.1, turn Bluetooth **On**. 2. Wait until Central shows ready (not unauthorized). 3. **Start Scanning**. | +| **Expected** | Scanning starts; P1 appears; no residual error (or error clears on new attempt). | + +#### F1.3 Connect while Bluetooth off (hold-before-wait) + +| | | +|--|--| +| **Devices** | C1 + P1 (P1 must have been discovered **before** BT off, so a device row/handle exists) | +| **Steps** | 1. With BT on, scan and ensure P1 is in Devices. 2. Stop scan optional. 3. Turn BT **Off**. 4. Open P1 detail → Auto Reconnect **ON** → **Connect**. | +| **Expected** | Connection does **not** succeed (no stuck permanent “Connecting” forever). Caption moves to **Waiting for Bluetooth…** (AwaitingRadio `.reconnecting(.library, nil, nil)`); button becomes **Disconnect** so the hold can be cleared while BT is still off. A red `Connect Failed: bluetoothPoweredOff` caption may flash briefly then clear when the stream becomes active. Intent survives the throw when Auto Reconnect is on (F1.4 relink). | +| **Important** | Hold is registered **before** the radio wait. Auto Reconnect **OFF** (F1.5): hold still set, but **no** Waiting-for-Bluetooth projection — stay Disconnected + red fail-fast caption may remain until the next tap. | + +#### F1.4 Auto Reconnect ON: relink when radio returns + +| | | +|--|--| +| **Devices** | C1 + P1 advertising | +| **Steps** | 1. Complete F1.3 (Connect with Auto Reconnect ON while BT off). 2. Turn BT **On**. 3. Leave UI on P1 detail; wait up to ~30–60 s. P1 should still be advertising. | +| **Expected** | Link eventually reaches **Connected** without needing another Connect tap (radio-return re-issue). May pass through Connecting / Reconnecting. | +| **FAIL if** | Stays permanently disconnected with no attempt after radio is clearly ready and P1 is still discoverable. | + +#### F1.5 Auto Reconnect OFF: no radio-return re-issue + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | 1. Ensure disconnected (Disconnect if needed). 2. BT **Off**. 3. Auto Reconnect **OFF** → **Connect**. 4. BT **On**. Wait ≥ 30 s. | +| **Expected** | **Does not** auto-connect. Stays disconnected (or non-connected). Idle remains suppressed while hold exists, but **no** re-issue. | +| **Cleanup** | **Disconnect** (or Connect then Disconnect) to clear residual hold if the UI still thinks a session is active. | + +#### F1.6 Unauthorized / restricted (if practical) + +| | | +|--|--| +| **Devices** | C1 | +| **Steps** | System Settings → Demo app → Bluetooth → **Don't Allow** / disable, or reset location & privacy if needed. Relaunch Demo. Try authorize / scan. | +| **Expected** | State shows unauthorized; scan fails with unavailability-style error (`bluetoothUnavailable`), not hang. Re-enable permission afterward. | +| **Note** | Optional if privacy reset is too disruptive; mark N/A. | + +#### F1.7 Stop scanning cleans up + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Start Scanning → confirm discoveries → Stop Scanning → wait 15 s watching Discoveries timestamps. | +| **Expected** | Scanning stops; no continuous new discoveries; Start Scanning available again in ready state. | + +**Suite F1 result:** ___ / PASS FAIL + +--- + +### Suite F2 — Manual connect hold & idle suppression (#58) + +**Goal:** Manual connect establishes a durable hold; hold suppresses idle teardown; disconnect is intentional and quiet. + +#### F2.1 Happy-path connect / disconnect + +| | | +|--|--| +| **Devices** | C1 + P1 advertising | +| **Steps** | 1. Scan → open P1. 2. Auto Reconnect **ON**. 3. **Connect**. 4. Observe Connecting → Connected. 5. **Disconnect**. | +| **Expected** | Connected (green). Disconnect → Disconnecting (brief) → **Disconnected** with **no** reason (or reason-less caption). **No** System/Library reconnect after intentional disconnect. Button returns to Connect. | + +#### F2.2 Hold suppresses idle (critical) + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Prep** | Idle interval known (e.g. 5 s or 2 s after relaunch). | +| **Steps** | 1. Connect Auto Reconnect ON → Connected. 2. Stop scanning optional. 3. Wait **≥ 3 × idle interval** (for 5 s → wait ≥ 15 s; for 2 s → ≥ 6 s). Prefer ≥ 30 s to be obvious. 4. Watch connection caption continuously. | +| **Expected** | Remains **Connected** the entire time. | +| **FAIL if** | Spontaneous Disconnect after ~idle interval (would mean hold not suppressing idle). | + +#### F2.3 Auto Reconnect OFF still holds the live link + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | 1. Disconnect if needed. 2. Auto Reconnect **OFF** → Connect → Connected. 3. Wait ≥ 3 × idle interval. | +| **Expected** | Stays **Connected** (hold present, `reconnectDesired == false` still suppresses idle). | +| **Note** | Differs from F2.2 only for later unexpected-drop behavior (Suite F3). | + +#### F2.4 Intentional disconnect does not arm ladder + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | 1. Connect Auto Reconnect ON. 2. Disconnect. 3. Wait ≥ max(idle, 2× initialDelay) with P1 still advertising. | +| **Expected** | Stays **Disconnected** (clean). No `Reconnecting (attempt N)` with countdown. | +| **FAIL if** | Library ladder starts after manual disconnect. | + +#### F2.5 Toggle disabled while active + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Connect → while Connecting/Connected, try Auto Reconnect toggle. | +| **Expected** | Toggle **disabled** while connection is active (`canEditAutoReconnect`). | + +#### F2.6 Logging smoke (optional) + +| | | +|--|--| +| **Steps** | Console filter on → Connect → Disconnect. | +| **Expected** | Info-level connection logs tagged for the peripheral (Manual connect / disconnect). No crash spam. | + +**Suite F2 result:** ___ + +--- + +### Suite F3 — Reconnect tiers & demand gating (#59) + +**Goal:** Unexpected drops recover only when Auto Reconnect demand wants them; system vs library captions make sense. + +#### F3.1 RF drop with Auto Reconnect ON (Tier-0 / system path) — **Faraday** + +| | | +|--|--| +| **Devices** | C1 outside + P1 (iOS 17+ preferred); Faraday box | +| **Steps** | 1. Outside box: Connect Auto Reconnect **ON** → Connected. Keep C1 on device detail. 2. Place **P1** in Faraday box and seal (§3.4). 3. Observe **C1** caption during outage (note timestamps). 4. Unseal / remove P1 (still advertising). 5. Wait for recovery on C1 — do not tap Connect. | +| **Expected** | During outage: often `System reconnecting…` and/or disconnected/reconnecting captions — **not** permanent silent stuck “Connected”. On return: **Connected** again without tapping Connect. | +| **PARTIAL OK** | Exact system vs library wording depends on CoreBluetooth callbacks; recovery without user Connect is the bar. | +| **Fallback** | If box isolation fails after reseal, walk P1 away / stop advertising — note method in results log. | + +#### F3.2 RF drop with Auto Reconnect OFF — **Faraday** + +| | | +|--|--| +| **Devices** | C1 outside + P1; Faraday box | +| **Steps** | 1. Connect Auto Reconnect **OFF** → Connected. 2. Seal **P1** in box until C1 leaves Connected. 3. Remove P1 (advertising). Wait ≥ 30–60 s on C1. | +| **Expected** | Link does **not** come back on its own. Stays disconnected/failed. No library ladder countdown. | +| **Cleanup** | Disconnect if UI still shows active, then Connect again only if needed. | + +#### F3.3 Peripheral reboot while held (Auto Reconnect ON) + +| | | +|--|--| +| **Devices** | C1 + P1 (no Faraday required) | +| **Steps** | Connected with Auto Reconnect ON → reboot P1 (or force-quit Peripheral advertising app and relaunch + Start Advertising). Wait for recovery. | +| **Expected** | Eventually Connected again (system and/or library). | +| **Note** | Distinct from Faraday RF block: process death on the advertiser vs RF isolation while the app may still be “Advertising” with no path. | + +#### F3.4 Library ladder visibility (optional, timing-sensitive) — **Faraday** + +| | | +|--|--| +| **Devices** | C1 outside + P1; Settings with short `initialDelay` (e.g. 1.0) after relaunch; Faraday box | +| **Steps** | 1. Connect Auto Reconnect ON → Connected. 2. Seal P1 long enough that OS Tier-0 may give up (can be minutes — document wall time). 3. Watch C1 for `Reconnecting (attempt N)` with **Next retry in: Xs** (library ladder). 4. Optionally unseal mid-ladder to confirm recovery. | +| **Expected** | If ladder arms: attempt ≥ 1 and countdown present; eventually Connected or Failed after max attempts. | +| **Note** | Mock gap #40 makes OS give-up hard to unit-test; **this is an on-device observation**. Mark PARTIAL if only system reconnect is seen for the whole sealed window. | + +#### F3.4b Cancel / clear demand *during* an OS reconnect attempt (on-device only) — **Faraday** + +| | | +|--|--| +| **Devices** | C1 outside + P1; Faraday box; short idle interval optional (see §4.3) | +| **Steps** | 1. Get to Connected with Auto Reconnect **ON**. 2. Seal **P1** so the OS starts a Tier-0 reconnect — C1 caption shows system reconnecting (or equivalent). 3. While that reconnect is still in flight, remove demand on C1: **Disconnect** (clearing the manual hold). 4. Unseal P1 (advertising) and wait ≥ 60 s. | +| **Expected** | The link is torn down and does **not** come back: clearing demand cancels trust in the in-flight OS reconnect. Caption settles to a clean disconnected state, never `Disconnecting…` indefinitely. | +| **Note** | CoreBluetoothMock always resolves a Tier-0 attempt (relink or `didFailToConnect`), so the in-flight window cannot be held open in unit tests; the cancel is pinned white-box by `idleTeardownDuringCachedSystemReconnectCancels`. **This is the on-device confirmation.** Faraday helps hold the “OS still trying” window open longer than walking away. | + +#### F3.5 Quiet after clean disconnect (regression of arming) + +| | | +|--|--| +| **Steps** | After any F3 recovery, **Disconnect** intentionally, leave P1 advertising 60 s. | +| **Expected** | No spontaneous reconnect. | + +**Suite F3 result:** ___ + +--- + +### Suite F4 — Radio power cycle while demanded (#59 / event 13) + +**Goal:** Turning Bluetooth off/on on the **central** preserves demand for Auto Reconnect holds and re-establishes the link; projection is honest. + +#### F4.1 Power cycle with Auto Reconnect ON + +| | | +|--|--| +| **Devices** | C1 + P1 advertising throughout | +| **Steps** | 1. Connect Auto Reconnect ON → Connected. 2. Control Center / Settings → Bluetooth **Off** on C1. 3. Observe caption sequence (~5–15 s). 4. Bluetooth **On**. 5. Wait up to 60 s. | +| **Expected** | Off: leaves Connected; expect disconnected with unavailability reason, then preferably **Waiting for Bluetooth…** (radio wait — no countdown). On: returns to **Connected** without user Connect. | +| **FAIL if** | After BT on, remains disconnected forever while P1 advertises and hold was Auto Reconnect ON. | +| **FAIL if** | Stays showing Connected while BT is off (stale cache). | + +#### F4.2 Power cycle with Auto Reconnect OFF + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Connect Auto Reconnect OFF → Connected → BT Off → observe → BT On → wait 30–60 s. | +| **Expected** | Does **not** auto re-link. Prefer: no sustained “Reconnecting” claiming recovery. Hold still suppressed idle while present, but issue gate refuses radio-return connect. | +| **Cleanup** | **Disconnect** to drop hold. | + +#### F4.3 Disconnect during radio outage + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | 1. Connect Auto Reconnect ON. 2. BT Off (demand/reconnecting projection — caption often **Waiting for Bluetooth…**). 3. Tap **Disconnect**. 4. (Optional) While BT still off, confirm UI settled and **Connect** is available again. 5. BT On. Wait 30 s. | +| **Expected** | Disconnect succeeds (no hard error UI required). **Immediately** (while BT still off): caption settles to clean **Disconnected** (no reason), button returns to **Connect** — hold cleared and radio-await reconnecting projection removed. A subsequent **Connect** while BT is still off is allowed (registers a new hold; Auto Reconnect ON → **Waiting for Bluetooth…** again). After BT on with no new Connect: **no** auto-connect. | +| **FAIL if** | Link comes back after outage disconnect (hold not cleared). UI stays on **Waiting for Bluetooth…** / **Disconnect** until BT is turned back on. | + +**HW finding (2026-08-11):** Pre-fix, F4.3 link behavior was correct (no reconnect after BT on), but the UI stuck on radio-await reconnecting + **Disconnect** until radio returned — library cleared the hold without publishing `.disconnected` when there was no live `CBPeripheral`. Fixed in `applyManualDisconnect` (settle when `cbPeripherals[id] == nil`). Re-run this case after rebuild. + +**Suite F4 result:** ___ + +--- + +### Suite F5 — State restoration & durable holds (#59 D-restore) + +**Goal:** Manual-connect holds survive process death when `restoreIdentifier` is set; restored standing sessions reappear. + +**Prep:** C1 has `UIBackgroundModes` = `bluetooth-central` (already in Demo Info.plist). Use **non-empty service filter** (default UUID) for any background scan expectations. Keep Auto Reconnect **ON** for durability cases. + +#### F5.1 Background briefly (connection retained by OS/app) + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Connect Auto Reconnect ON → Connected → home button / app switcher leave Demo in background 30–60 s → return. | +| **Expected** | Still Connected (or briefly reconnecting then Connected). App does not crash. | + +#### F5.2 Force quit + relaunch with Auto Reconnect ON (durable hold) + +| | | +|--|--| +| **Devices** | C1 + P1 keep advertising | +| **Steps** | 1. Connect Auto Reconnect ON → Connected. 2. App switcher → **force quit** Demo. 3. Wait ~5–10 s. 4. Relaunch Demo from Xcode or icon. 5. Open Central → device list / detail for P1. Wait up to 60 s. | +| **Expected** | Connection state for P1 returns to **Connected** (or reconnecting then Connected) **without** tapping Connect. Restored hold rehydrated. | +| **Notes** | First launch after upgrade may drop old-format persistence once; re-run Connect → force quit → relaunch. If OS does not restore peripherals quickly, still expect library re-issue once central is ready and peripheral is known. | +| **FAIL if** | Always idle-disconnects within ~idle interval after relaunch despite Auto Reconnect ON connect before kill. | + +#### F5.3 Force quit after intentional Disconnect + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Connect → Disconnect (clean) → force quit → relaunch → wait 30 s. | +| **Expected** | Does **not** reconnect on its own (hold cleared before kill). | + +#### F5.4 Force quit with Auto Reconnect OFF hold + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Connect Auto Reconnect OFF → Connected → force quit → relaunch → wait ≥ 3 × idle interval. | +| **Expected** | Prefer: link restored or idle-suppressed standing behavior per hold map (`reconnectDesired: false` — suppress idle, no tiers). Exact OS restore of the ACL link varies; **must not** start library ladder / system auto-reconnect storm. If link is up, it should **not** idle-drop solely because process restarted. If OS did not restore the ACL, library should **not** re-issue connect. | +| **Document** | What you actually see (Connected vs Disconnected) — valuable for restore matrix validation. | + +#### F5.5 Background scan with service filter (regression + restore adjacent) + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | Service filter = default UUID → Start Scanning → background Demo 30–60 s → foreground. Optionally force quit mid-scan and relaunch. | +| **Expected** | No crash. With filter set, scanning can continue in background per OS; discoveries may lag. Without filter, background discovery is not guaranteed (by design caption in UI). | + +**Suite F5 result:** ___ + +--- + +### Suite F6 — Multi-device / multi-central regression + +**Goal:** ReliaBLE central stacks remain independent; second central does not break the first. + +#### F6.1 Two centrals discover one peripheral + +| | | +|--|--| +| **Devices** | C1, C2, P1 advertising | +| **Steps** | Both centrals Start Scanning with same service UUID. | +| **Expected** | Both list P1. Independent device IDs/lists (per-install SwiftData). | + +#### F6.2 Two centrals connect concurrently + +| | | +|--|--| +| **Devices** | C1, C2, P1 | +| **Steps** | C1 Connect Auto Reconnect ON; C2 Connect Auto Reconnect ON (if peripheral allows multiple centrals — Demo peripheral may support one or more depending on stack). | +| **Expected** | At least one Connected. If second fails, note OS/peripheral limit (**PARTIAL**, not necessarily library bug). Neither central should crash or wedge scanning. | +| **Note** | Many simple peripherals accept only one central; treat dual-connect success as bonus. | + +#### F6.3 Cross-traffic isolation + +| | | +|--|--| +| **Devices** | C1 connected; C2 scanning only | +| **Steps** | C1 Disconnect / power-cycle BT; watch C2. | +| **Expected** | C2 discoveries continue; no cross-process crash. | + +#### F6.4 Two advertisers, one central + +| | | +|--|--| +| **Devices** | C1; P1 and C2-as-peripheral (or second phone as P2) with **distinct names**, same service UUID | +| **Steps** | C1 scan → both appear → connect to one → other still listed. | +| **Expected** | Connection state only for the connected id; second remains disconnected. Disconnect first; connect second. | + +**Suite F6 result:** ___ + +--- + +### Suite F7 — Discovery & scan filter regression + +#### F7.1 Empty service filter (foreground) + +| | | +|--|--| +| **Steps** | Clear service UUID field on C1 → Start Scanning. | +| **Expected** | Scan starts; may see broader set of BLE devices. P1 still appears if advertising. | + +#### F7.2 Wrong service filter + +| | | +|--|--| +| **Steps** | Set filter to a random UUID not used by P1 → Start Scanning. | +| **Expected** | P1 **not** listed (or not as matching service). Switch back to correct UUID → appears. | + +#### F7.3 Stop / start scan thrash + +| | | +|--|--| +| **Steps** | Rapidly Start → Stop → Start 5 times. | +| **Expected** | No crash; final state matches last action; no permanent red error. | + +#### F7.4 Clear All data + +| | | +|--|--| +| **Steps** | With devices present → Clear All → rescan. | +| **Expected** | Lists empty then refill from live scan. Connection holds are **library** state — clearing SwiftData rows does not by itself call `disconnect()`; if a link was up, behavior may still show connection until disconnect. Prefer Disconnect before Clear All for clean slate. | + +**Suite F7 result:** ___ + +--- + +### Suite F8 — Authorization & first-run regression + +#### F8.1 Fresh install authorize path (optional wipe) + +| | | +|--|--| +| **Steps** | Delete Demo from C1 → Run from Xcode → Central → Authorize if shown → Allow. | +| **Expected** | Reaches ready; scan works. | + +#### F8.2 Peripheral tab permission + +| | | +|--|--| +| **Steps** | On a device that has never advertised: Peripheral → Start Advertising. | +| **Expected** | Permission prompt if needed; reaches Advertising when powered on. | + +**Suite F8 result:** ___ + +--- + +### Suite F9 — Settings knobs regression + +#### F9.1 Idle interval applies after relaunch + +| | | +|--|--| +| **Steps** | Set Idle to 10 s → force quit → relaunch → F2.2 style hold test still stays connected past 30 s. | +| **Expected** | Hold still suppresses idle (interval change must not drop held links). | + +#### F9.2 Reconnect policy applies after relaunch + +| | | +|--|--| +| **Steps** | Set maxAttempts = 2, initialDelay = 1 → relaunch → provoke hard unexpected disconnect if possible; observe limited retries. | +| **Expected** | If library ladder is visible, attempts cap near 2 then settle failed/disconnected. If only system reconnect runs, mark PARTIAL. | + +#### F9.3 Logging toggle + +| | | +|--|--| +| **Steps** | Settings → disable logging → Connect → enable logging → Disconnect. | +| **Expected** | No crash; console volume changes appropriately. | + +**Suite F9 result:** ___ + +--- + +### Suite F10 — Stress / edge (timeboxed) + +Run only if time remains; each is optional. + +| ID | Scenario | Expected | +|----|----------|----------| +| F10.1 | Connect while P1 **not** advertising | Connecting then failed/disconnected; no infinite spinner without state change | +| F10.2 | Airplane Mode on C1 mid-connection | Leaves connected cleanly; recovers when mode off + BT on (similar to F4) | +| F10.3 | Lock screen 2+ minutes while connected | Still connected or recovers on unlock | +| F10.4 | Low power mode on C1 | Connect/scan still function | +| F10.5 | **Faraday:** seal P1 until ladder/system gives up, then unseal | Either auto-recovers if demand remains, or stays failed after budget — document which + sealed duration | +| F10.6 | Cancel connect quickly (Connect then Disconnect within 1 s) | Settles disconnected; no stuck Connecting; no reconnect storm | +| F10.7 | **Faraday optional:** C1 inside box, P1 outside (standalone C1, no USB) | After unseal, caption is reconnecting/connected/disconnected consistently — intermediate states may be missed | + +**Suite F10 result:** ___ + +--- + +## 6. Behaviors to treat as known limitations (not automatic FAIL) + +| Topic | Guidance | +|-------|----------| +| **Work-lease auto-connect** | Not Demo-exposed; covered by unit tests. Do not FAIL HW plan for missing lease UI. | +| **Idle teardown of work-only links** | Not Demo-exposed; prove hold **suppresses** idle instead. | +| **Tier-0 give-up timing** | OS-defined; document observed seconds. | +| **Dual central to one peripheral** | Peripheral may reject second link. | +| **Background ads truncated** | iOS may omit local name; rely on service UUID filter. | +| **Demo `try?` on connect** | Thrown `bluetoothPoweredOff` may lack an alert; use F1.4 relink and scanError for scan path. | +| **`Waiting for Bluetooth…`** | Means radio-await (`attempt == nil`), not ladder step 0. Ladder steps show `Reconnecting (attempt N)` with `N >= 1`. | +| **Upgrade persistence shape** | One relaunch after upgrading from pre-hold-map builds may lose standing holds — re-connect once. | + +--- + +## 7. Suggested execution order (half-day pass) + +| Phase | Suites | Est. time | Devices | +|-------|--------|-----------|---------| +| Setup + baseline | §4 | 15 min | All | +| Radio gating | F1 | 25 min | C1, P1 | +| Manual hold / idle suppress | F2 | 20 min | C1, P1 | +| Reconnect (Faraday: P1 in box) | F3 | 30–45 min | C1 outside, P1 in box | +| Central BT power cycle | F4 | 20 min | C1, P1 | +| Restore / force quit | F5 | 25 min | C1, P1 | +| Multi-central | F6 | 20 min | C1, C2, P1 | +| Scan/auth/settings | F7–F9 | 20 min | C1, P1 | +| Stress (optional) | F10 | 20 min | C1, P1 | + +**Minimum ship bar (feature):** F1.1, F1.2, F1.4, F2.1, F2.2, F2.4, F3.1, F3.2, F4.1, F5.2, F5.3, F6.1, baseline discovery. + +**Minimum regression bar:** F1.7, F7.2, F7.3, F8.1 or existing-auth OK, F5.1, F6.3. + +--- + +## 8. Results log template + +Copy per session: + +```text +Date: +Branch / commit: +Xcode: +C1: device model / iOS: +C2: device model / iOS: +P1: device model / iOS: +Faraday box used (Y/N); isolation method for drops (P1-in-box / walk-away / stop advertising): +Idle interval used: +Reconnect policy overrides: + +Baseline discovery: PASS/FAIL — notes: + +F1.1: F1.2: F1.3: F1.4: F1.5: F1.6: F1.7: +F2.1: F2.2: F2.3: F2.4: F2.5: F2.6: +F3.1: F3.2: F3.3: F3.4: F3.5: +F4.1: F4.2: F4.3: +F5.1: F5.2: F5.3: F5.4: F5.5: +F6.1: F6.2: F6.3: F6.4: +F7.1: F7.2: F7.3: F7.4: +F8.1: F8.2: +F9.1: F9.2: F9.3: +F10.*: + +Blockers / FAIL details: +Screenshots / Console excerpts: + +Overall: PASS / FAIL / PASS WITH NOTES +``` + +--- + +## 9. Mapping to plan acceptance + +| Plan / issue | HW coverage | +|--------------|-------------| +| #57 PoweredOn await | F1 | +| #58 Idle + Manual hold | F2 (hold suppress + intentional disconnect); idle **teardown** via leases = unit tests | +| #59 Reconnect gating | F3, F4 | +| #60 Work-driven auto-connect | Unit tests + hold-driven standing session as public proxy | +| D-hold (hold before wait) | F1.3–F1.5 | +| D-restore durable holds | F5 | +| D-1 event 13 radio drop projection | F4.1 | +| FR-9.2 logging | F2.6 | +| Mock gaps #40 / #42 | F3.4, F5.2 (on-device only) | +| Multi-device regression | F6–F8 | + +--- + +## 10. Quick operator cheat sheet + +**P1 advertising** + +1. Peripheral tab → name + UUID `12345678-90AB-CDEF-1234-567890ABCDEF` → Start Advertising. + +**C1 connect** + +1. Central → filter UUID same → Start Scanning → Devices → P1 → Auto Reconnect as required → Connect. + +**Prove hold** + +1. Stay Connected > 3× idle interval without touching UI. + +**Prove intentional quiet** + +1. Disconnect → wait → must not reconnect. + +**Prove Auto Reconnect (Faraday)** + +1. C1 Connected (Auto Reconnect ON), screen on device detail. +2. Seal **P1** in Faraday box → C1 should leave Connected / show system or library reconnecting. +3. Unseal P1 → Connected without Connect tap. + +**Prove no reconnect when Auto Reconnect OFF (Faraday)** + +1. Connect with toggle OFF → seal P1 → unseal → must **not** auto-connect within 30–60 s. + +**Prove BT power cycle** (no box) + +1. Connected + Auto Reconnect ON → BT off on **C1** → reconnecting/disconnected → BT on → Connected. + +**Prove restore** (no box) + +1. Connected + Auto Reconnect ON → force quit → relaunch → Connected without Connect tap. + +**Clear stuck demand** + +1. Open device → Disconnect (even if already disconnected looking) before starting the next contradictory case. + +**Faraday reminder** + +1. Instrument **C1 outside**; only **P1** goes in the box (no USB passthrough). Keep P1 unlocked/advertising before sealing. + +--- + +## 11. References + +- `docs/plans/work-driven-connection-lifecycle-2026-08-02.md` +- `docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md` +- `docs/reviews/work-driven-connection-lifecycle-poweredoff-feedback-2026-08-05.md` +- `docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md` (#40, #42) +- Demo: `CentralView.swift`, `CentralViewModel.swift`, `SettingsView.swift`, `ReliaBLE_DemoApp.swift`, `PeripheralView.swift` +- Library: `Peripheral.connect` / `disconnect`, `ReliaBLEConfig.idleDisconnectInterval`, `ConnectionState.reconnecting` docs