From f282e0bd2df66871a4d1cf2d7dde36d8e45fb5e2 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 11:35:00 -0600 Subject: [PATCH 01/31] Added planning docs --- ...-driven-connection-lifecycle-2026-08-02.md | 556 ++++++++++++++++++ ...tion-lifecycle-plan-critique-2026-08-02.md | 308 ++++++++++ ...ifecycle-poweredoff-feedback-2026-08-05.md | 255 ++++++++ 3 files changed, 1119 insertions(+) create mode 100644 docs/plans/work-driven-connection-lifecycle-2026-08-02.md create mode 100644 docs/reviews/work-driven-connection-lifecycle-plan-critique-2026-08-02.md create mode 100644 docs/reviews/work-driven-connection-lifecycle-poweredoff-feedback-2026-08-05.md 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..89ba1f6 --- /dev/null +++ b/docs/plans/work-driven-connection-lifecycle-2026-08-02.md @@ -0,0 +1,556 @@ +# 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)** | **Manual-connect holds are durable; work leases are not.** Persistence stores a **hold map** (`id → reconnectDesired`), not a reconnect-enabled set, and `handleWillRestoreState` **reads it back**. Four cases: (1) OS restores a peripheral **and** a hold is persisted for that id → rehydrate `manualConnectHold[id]` with its `reconnectDesired`, 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. | A manual `connect` is an explicit "keep this link" instruction; when the app has configured state restoration, that instruction should outlive relaunch, otherwise restoration cannot deliver the standing-session behavior it exists for. NFR-3.2 is still honored where it matters — restored links the app never explicitly asked for (residual or Tier-0 links) 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 +} + +/// 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; for every id with demand that is not linked, `reevaluateLink(id:reason: .radioReturned)`; for restored links with no demand, `beginIdleGrace(id:)` (D-restore). + +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. **`syncReconnectIntent` is the sole persistence writer** — no other call site writes to disk. (Prose may call this "sync persisted holds" where that reads more clearly.) + +**Persistence model (D-restore).** What is persisted is a **hold map**, not a reconnect-enabled set: + +- **Encoding:** a dictionary `id → reconnectDesired: Bool`, namespaced by `restoreIdentifier`. 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." The existing array-of-ids encoding therefore changes shape; since a stale old-format value is simply not decodable as the new map, treat a decode failure as "no persisted holds" and move on. No migration shim. +- **Meaning:** "the app made a manual `connect` for this id and has not disconnected it," together with the `autoReconnect` value it asked for. This is durable demand, not a hint. +- **The restore-time read stays.** `handleWillRestoreState` (near `:768`–`:777`) reads the map back and rehydrates `manualConnectHold`. This reverses the earlier plan's "delete the restore-time read" — that reasoning depended on holds never being restored, which D-restore now requires. +- **Only three call sites touch disk, and two of them are removals:** `syncReconnectIntent` writes; `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. +- 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) | +| 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 map `id → reconnectDesired` 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 goes from an array of reconnect-enabled ids to a **hold map** (`id → reconnectDesired`), written only by `syncReconnectIntent` and read back at restore to rehydrate manual-connect holds (D-4). A value written by an older build will not decode as the new map; treat a decode failure as "no persisted holds". Pre-release, so no migration shim — the practical effect is that one relaunch after upgrading loses standing holds. +- **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**) | +| `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. +- 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. From 2294e2b4d11ce043ce1b56c7170b15ed474a4daa Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 11:52:06 -0600 Subject: [PATCH 02/31] Config and error additions for work-driven lifecycle Add idleDisconnectInterval to ReliaBLEConfig (global, FR-1.5) and the bluetoothPoweredOff / bluetoothUnsupported error cases documenting the wait-vs-fail radio policy (D-radio). No behavior change. --- Sources/ReliaBLE/Models/PeripheralError.swift | 20 +++++++++++++++++++ Sources/ReliaBLE/ReliaBLEConfig.swift | 12 +++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) 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() { - + } } From ec3c5540747c83d7c9ebf8690591f08f69d333e9 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 11:59:12 -0600 Subject: [PATCH 03/31] PoweredOn await infrastructure Add waitUntilPoweredOn() mirroring the authorization-continuation pattern: park continuations on transient radio states (.resetting/.unknown), fail fast with typed errors for terminal states, and resolve all waiters from handleCentralManagerStateUpdate. Wire idleDisconnectInterval through BluetoothActor.init with finite/>=0 validation. Add test hooks. No app path calls waitUntilPoweredOn yet. --- Sources/ReliaBLE/BluetoothActor.swift | 114 +++++++++++++++++++++++++ Sources/ReliaBLE/ReliaBLEManager.swift | 1 + 2 files changed, 115 insertions(+) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index bdd07e3..821c72a 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -171,6 +171,12 @@ 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] = [:] + /// The current Bluetooth state. var currentBluetoothState: BluetoothState = .unknown @@ -205,6 +211,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? @@ -228,11 +237,17 @@ 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 } @@ -267,6 +282,12 @@ actor BluetoothActor { continuation.resume(throwing: CancellationError()) } + let pendingPoweredOn = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pendingPoweredOn.values { + continuation.resume(throwing: PeripheralError.bluetoothUnavailable) + } + centralManager = nil delegateShim = nil cbPeripherals.removeAll() @@ -610,6 +631,83 @@ 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`` + func waitUntilPoweredOn() 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() + @unknown default: + throw PeripheralError.bluetoothUnavailable + } + } + + /// Parks a continuation for ``waitUntilPoweredOn()`` until the radio becomes usable. + private func suspendForPoweredOn() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + let id = UUID() + poweredOnContinuations[id] = continuation + } + } + + /// Resolves all 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() { + guard !poweredOnContinuations.isEmpty else { return } + guard let centralManager else { return } + let result: Result + switch centralManager.state { + case .poweredOn: + result = .success(()) + case .poweredOff: + result = .failure(PeripheralError.bluetoothPoweredOff) + case .unsupported: + result = .failure(PeripheralError.bluetoothUnsupported) + case .unauthorized: + result = .failure(PeripheralError.bluetoothUnavailable) + case .resetting, .unknown: + return + @unknown default: + result = .failure(PeripheralError.bluetoothUnavailable) + } + let pending = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pending.values { + continuation.resume(with: result) + } + } + + /// 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) { @@ -857,6 +955,7 @@ actor BluetoothActor { updateState() resolvePendingAuthorization() + resolvePoweredOnWaiters() } func handlePeripheralDiscovered( @@ -1332,6 +1431,21 @@ actor BluetoothActor { 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: injects a disconnect event with the specified `isReconnecting` flag, /// routing through the same `handleDidDisconnect(_:)` path as a real delegate callback. /// diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 14081c9..862419b 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 ) From 2bb34e13ee21273eac2e3015f1d85efe7758fb5e Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 12:42:51 -0600 Subject: [PATCH 04/31] Await a usable radio for scan and connect (#57) --- Sources/ReliaBLE/BluetoothActor.swift | 217 ++++++++++-- .../Documentation.docc/GettingStarted.md | 4 +- .../Documentation.docc/Topics/Concurrency.md | 7 +- Sources/ReliaBLE/Models/Peripheral.swift | 15 + Sources/ReliaBLE/ReliaBLEManager.swift | 19 +- .../ReliaBLETests/ReliaBLEManagerTests.swift | 310 +++++++++++++++--- 6 files changed, 496 insertions(+), 76 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 821c72a..e7a7d8e 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -177,6 +177,21 @@ actor BluetoothActor { /// the next central state update. private var poweredOnContinuations: [UUID: CheckedContinuation] = [:] + /// Continuation for a parked ``startScanning()`` call awaiting a usable radio. There is at most one + /// pending scan at a time, so a single slot suffices. + private var scanWaiterContinuation: CheckedContinuation? + + /// Service filter requested by the currently parked scan waiter, or `nil` if none is parked. + private var scanWaiterServices: [CBUUID]? + + /// 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 @@ -288,6 +303,12 @@ actor BluetoothActor { continuation.resume(throwing: PeripheralError.bluetoothUnavailable) } + if let continuation = scanWaiterContinuation { + scanWaiterContinuation = nil + scanWaiterServices = nil + continuation.resume(throwing: PeripheralError.bluetoothUnavailable) + } + centralManager = nil delegateShim = nil cbPeripherals.removeAll() @@ -642,7 +663,11 @@ actor BluetoothActor { /// - `.unauthorized` → ``PeripheralError/bluetoothUnavailable`` /// - `.resetting` / `.unknown` → parks a continuation until the next state update /// - Task cancellation → ``CancellationError`` - func waitUntilPoweredOn() async throws { + /// + /// - 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 { @@ -655,50 +680,76 @@ actor BluetoothActor { case .unauthorized: throw PeripheralError.bluetoothUnavailable case .resetting, .unknown: - try await suspendForPoweredOn() + try await suspendForPoweredOn(waiterID: waiterID) @unknown default: throw PeripheralError.bluetoothUnavailable } } - /// Parks a continuation for ``waitUntilPoweredOn()`` until the radio becomes usable. - private func suspendForPoweredOn() async throws { + /// 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 } - let id = UUID() - poweredOnContinuations[id] = continuation + poweredOnContinuations[waiterID] = continuation } } - /// Resolves all pending ``waitUntilPoweredOn()`` calls based on the current radio state. + /// 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() { - guard !poweredOnContinuations.isEmpty else { return } + let hasScanWaiter = scanWaiterContinuation != nil + guard !poweredOnContinuations.isEmpty || hasScanWaiter else { return } guard let centralManager else { return } - let result: Result + switch centralManager.state { case .poweredOn: - result = .success(()) + // A parked scan waiter gets its scan issued and completes successfully on power-on. + if let continuation = scanWaiterContinuation { + scanWaiterContinuation = nil + let services = scanWaiterServices + scanWaiterServices = nil + beginScan(services: services) + continuation.resume(returning: ()) + } + if !poweredOnContinuations.isEmpty { + let pending = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pending.values { + continuation.resume(returning: ()) + } + } case .poweredOff: - result = .failure(PeripheralError.bluetoothPoweredOff) + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothPoweredOff) case .unsupported: - result = .failure(PeripheralError.bluetoothUnsupported) + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothUnsupported) case .unauthorized: - result = .failure(PeripheralError.bluetoothUnavailable) + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothUnavailable) case .resetting, .unknown: return @unknown default: - result = .failure(PeripheralError.bluetoothUnavailable) + failPoweredOnAndScanWaiters(with: PeripheralError.bluetoothUnavailable) } - let pending = poweredOnContinuations - poweredOnContinuations.removeAll() - for continuation in pending.values { - continuation.resume(with: result) + } + + /// Fails every parked ``waitUntilPoweredOn()`` continuation and any parked scan waiter with a + /// terminal radio-state error. + private func failPoweredOnAndScanWaiters(with error: PeripheralError) { + if let continuation = scanWaiterContinuation { + scanWaiterContinuation = nil + scanWaiterServices = nil + continuation.resume(throwing: error) + } + if !poweredOnContinuations.isEmpty { + let pending = poweredOnContinuations + poweredOnContinuations.removeAll() + for continuation in pending.values { + continuation.resume(throwing: error) + } } } @@ -710,21 +761,120 @@ actor BluetoothActor { // 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) 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)") - return + 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) + @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]?) async throws { + // A later startScanning supersedes a parked one: resolve the previous waiter successfully. + if let previous = scanWaiterContinuation { + scanWaiterContinuation = nil + scanWaiterServices = nil + previous.resume(returning: ()) + } + + // An app-requested scan supersedes any deferred restored scan (D-2 filter precedence). + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + + scanWaiterServices = services + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + scanWaiterContinuation = continuation + } + + // Resumed. Re-read the radio because it may have flipped since the waiter was resolved. + guard let centralManager else { throw PeripheralError.bluetoothUnavailable } + switch centralManager.state { + case .poweredOn: + // If ``resolvePoweredOnWaiters()`` already issued the scan, nothing left to do. If this + // waiter was resolved by ``stopScanning()``, ``scanWaiterServices`` is nil and we must + // not start a scan. Otherwise issue it now (e.g. after a transient re-park above). + if scanWaiterServices != nil, centralManager.isScanning == false { + let services = scanWaiterServices + scanWaiterServices = nil + beginScan(services: services) + } + case .resetting, .unknown: + // The radio regressed to a transient state while we held the continuation; re-park + // only while a scan is still requested. If ``stopScanning()`` resolved this waiter, it + // cleared ``scanWaiterServices`` — return successfully, no scan, rather than re-parking. + if scanWaiterServices != nil { + try await parkScanWaiter(services: scanWaiterServices) + } + case .poweredOff: + throw PeripheralError.bluetoothPoweredOff + case .unsupported: + throw PeripheralError.bluetoothUnsupported + case .unauthorized: + throw PeripheralError.bluetoothUnavailable + @unknown default: + throw PeripheralError.bluetoothUnavailable + } + } + + /// Cancels a parked ``startScanning(services:)`` waiter, invoked from a + /// `withTaskCancellationHandler` onCancel. No-op when no waiter is parked. + func cancelScanWaiter() { + guard let continuation = scanWaiterContinuation else { return } + scanWaiterContinuation = nil + scanWaiterServices = nil + 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)], @@ -732,6 +882,7 @@ actor BluetoothActor { ) } + guard let centralManager else { return } centralManager.scanForPeripherals(withServices: services, options: nil) if centralManager.isScanning { @@ -742,7 +893,20 @@ 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 continuation = scanWaiterContinuation { + scanWaiterContinuation = nil + scanWaiterServices = nil + continuation.resume(returning: ()) + } + guard !isShutdown else { log?.warn(tags: [.category(.scanning)], "Attempted to stop scan after shutdown") return @@ -1446,6 +1610,11 @@ actor BluetoothActor { poweredOnContinuations.count } + /// Test-only hook: whether a ``startScanning(services:)`` waiter is currently parked. + func testPendingScanWaiterCount() -> Int { + scanWaiterContinuation == nil ? 0 : 1 + } + /// Test-only hook: injects a disconnect event with the specified `isReconnecting` flag, /// routing through the same `handleDidDisconnect(_:)` path as a real delegate callback. /// diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index d94320f..368761f 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -90,7 +90,7 @@ 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 +109,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)) diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index 06bcd32..774bd05 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -61,13 +61,18 @@ All mutating actions are `async` and hop onto the Bluetooth actor for you: - ``Peripheral/connect(autoReconnect:)`` - ``Peripheral/disconnect()`` +> Note: ``ReliaBLEManager/startScanning(services:)`` now **throws**. A usable radio is awaited +> before scanning begins: a transient state (`.resetting` / `.unknown`) is awaited, while a +> terminal state (powered off or unsupported) fails fast with a typed ``PeripheralError``. +> Cancelling the calling task while the scan is parked on a transient state unblocks the pending +> radio wait with a `CancellationError`. + 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/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 8bafcb2..1d49000 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,6 +172,15 @@ public final class Peripheral: Sendable, Identifiable, Hashable { guard let manager = state.withLock({ $0.manager }) else { throw PeripheralError.bluetoothUnavailable } await manager.bluetooth.ensureCentralManager() + + // Await a usable radio (cancellable), then forward to the actor connect path. + let waiterID = UUID() + try await withTaskCancellationHandler { + try await manager.bluetooth.waitUntilPoweredOn(waiterID: waiterID) + } onCancel: { + Task { await manager.bluetooth.cancelPoweredOnContinuation(waiterID) } + } + try await manager.bluetooth.connect(id: id, autoReconnect: autoReconnect) } diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 862419b..a4e555f 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -176,14 +176,23 @@ 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) + + try await withTaskCancellationHandler { + try await bluetooth.startScanning(services: services) + } onCancel: { + Task { await bluetooth.cancelScanWaiter() } + } } /// Stops scanning for peripheral devices. diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 4ff1060..e534944 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 @@ -294,29 +294,163 @@ 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() #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.waitForState("Ready", on: manager) } + @Test func startScanningFailsWhenUnsupported() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.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. + CBMCentralManagerMock.simulateInitialState(.poweredOn) + #expect(await Mock.waitForState("Ready", on: manager)) + } + + @Test func startScanningAwaitsTransientState() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.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 } + + CBMCentralManagerMock.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) + + CBMCentralManagerMock.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 } + + CBMCentralManagerMock.simulatePowerOff() + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await scanTask.value + } + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + #expect(await manager.bluetooth.testIsScanning() == false) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @Test func stopScanningCompletesParkedScanWaiterSuccessfully() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.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) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @Test func supersededScanWaiterCompletesWithoutScanning() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.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 } + + CBMCentralManagerMock.simulatePowerOn() + _ = try await second.value + + #expect(await Mock.waitForState("Scanning", on: manager)) + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + } + + @Test func startScanningCancellationUnblocksWaiter() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.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() + await #expect(throws: CancellationError.self) { + try await scanTask.value + } + + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + #expect(await manager.bluetooth.testIsScanning() == false) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + // MARK: - Discovery @Test func scanningDeliversDiscoveryEventsAndPeripherals() async throws { @@ -326,7 +460,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 +512,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 +543,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, @@ -430,7 +564,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 +590,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 +609,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 +630,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 +653,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 +668,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 + + CBMCentralManagerMock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await handle.connect() + } + + CBMCentralManagerMock.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. + CBMCentralManagerMock.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 + } + + CBMCentralManagerMock.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 + + CBMCentralManagerMock.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. + CBMCentralManagerMock.simulatePowerOff() + await #expect(throws: PeripheralError.bluetoothPoweredOff) { + try await connectTask.value + } + + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + CBMCentralManagerMock.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 +780,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 +820,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 +924,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, @@ -732,7 +954,7 @@ struct ReliaBLEManagerTests { var changes = manager.connectionStateChanges.makeAsyncIterator() #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) - await manager.startScanning() + try await manager.startScanning() let discovered = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -771,7 +993,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, @@ -813,7 +1035,7 @@ struct ReliaBLEManagerTests { // 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, @@ -858,7 +1080,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, @@ -920,7 +1142,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -998,7 +1220,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1051,7 +1273,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1108,7 +1330,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1160,7 +1382,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1224,7 +1446,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1283,7 +1505,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1333,7 +1555,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1371,7 +1593,7 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1449,7 +1671,7 @@ struct ReliaBLEManagerTests { // Force an actor hop so registration completes before we connect. _ = await manager.currentConnectionStates - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, @@ -1575,7 +1797,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager1) await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager1.startScanning() + try await manager1.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, @@ -1670,7 +1892,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager1) await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager1.startScanning() + try await manager1.startScanning() let connectionPeripheral = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, @@ -1716,7 +1938,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager1) await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager1.startScanning() + try await manager1.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, @@ -1775,7 +1997,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager1) await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager1.startScanning() + try await manager1.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, @@ -1833,7 +2055,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.connectionTestPeripheralID, on: manager, @@ -1867,7 +2089,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.connectionTestPeripheralID, on: manager, @@ -1949,7 +2171,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, @@ -1978,7 +2200,7 @@ struct ReliaBLEManagerTests { #expect(handle.cbIdentifier == nil) // First discover so live refs and prior metadata exist. - await manager.startScanning() + try await manager.startScanning() let snap = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, @@ -2029,7 +2251,7 @@ struct ReliaBLEManagerTests { #expect(await managerB.bluetooth.testRestoreIdentifier() == restoreB) // A discovers while B is idle — B's discovered list must stay empty. - await managerA.startScanning() + try await managerA.startScanning() let discoveredOnA = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: managerA, @@ -2042,7 +2264,7 @@ struct ReliaBLEManagerTests { await managerA.stopScanning() // B discovers independently into its own maps. - await managerB.startScanning() + try await managerB.startScanning() let discoveredOnB = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: managerB, @@ -2053,7 +2275,7 @@ struct ReliaBLEManagerTests { await managerB.stopScanning() // Connect only on A; B must not observe connection state for that peripheral. - await managerA.startScanning() + try await managerA.startScanning() let connectableA = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: managerA, @@ -2132,7 +2354,7 @@ struct ReliaBLEManagerTests { #expect(set.count == 1, "id-only equality means same-id handles from different managers count as one in a Set") // Discover only on A. - await managerA.startScanning() + try await managerA.startScanning() _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: managerA, From 0c4e1cf55740c4c167e0ab29e3917b57e2ea0e58 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 17:31:39 -0600 Subject: [PATCH 05/31] Demand substrate: work leases, manual-connect holds, ensureLinked Introduce the derived demand signal (work leases + manual-connect holds) that drives the connection lifecycle, and extract the single ensure-linked path: - Add actor-isolated activeLeases (Set) + manualConnectHold - extract issueConnect as the ONLY centralManager.connect call site - syncReconnectIntent keeps reconnectEnabled in step with wantsReconnect - reevaluateLink(id:reason:) as the single ensure-linked entry point - applyManualConnectHold / applyManualDisconnect (settling rule) - acquireWorkLease / releaseWorkLease (+ test hooks) - Peripheral.connect: hold-first, then wait, then reevaluateLink(.explicitConnect); disconnect routes to applyManualDisconnect; add lease wrappers - handleDidConnect adds syncReconnectIntent Step 4 of work-driven connection lifecycle (#51). --- Sources/ReliaBLE/BluetoothActor.swift | 250 ++++++++++++++---- Sources/ReliaBLE/Models/Peripheral.swift | 41 ++- .../ReliaBLETests/ReliaBLEManagerTests.swift | 173 +++++++++++- 3 files changed, 405 insertions(+), 59 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index e7a7d8e..f61178a 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -130,6 +130,35 @@ 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:)``. +struct ManualConnectHold: Sendable { + var reconnectDesired: Bool +} + +/// 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 +} + // MARK: - BluetoothActor /// Actor that serializes all CoreBluetooth interactions for a single ``ReliaBLEManager`` stack. @@ -236,6 +265,11 @@ 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] = [:] + // MARK: - Initialization /// Bridge to the owning manager's handle registry. @@ -321,6 +355,8 @@ actor BluetoothActor { reconnectEnabled.removeAll() intentionalDisconnects.removeAll() reconnectAttempts.removeAll() + activeLeases.removeAll() + manualConnectHold.removeAll() pendingRestoredScanServices = nil pendingRestoredScanOptions = nil } @@ -1315,80 +1351,187 @@ actor BluetoothActor { log?.debug("Refreshed \(retrieved.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. + // 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 + } + + /// The only place in the codebase that may call `centralManager.connect(_:options:)`. /// - /// - 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 - } - guard let centralManager else { - log?.warn(tags: [.peripheral(id)], "Attempted to connect without a central manager") - throw PeripheralError.bluetoothUnavailable - } - + /// 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 { 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 } centralManager.connect(cbPeripheral, options: options) } - - /// Initiates a disconnection from the live peripheral backing the given snapshot `id`. + + /// Keeps ``reconnectEnabled`` in step with the derived demand signal, and is the place hold-driven + /// persistence is written from. /// - /// Optimistically broadcasts `.disconnecting` before cancelling the connection. The actual - /// `.disconnected` callback arrives later via the delegate pipeline. + /// Inserts ``id`` when ``wantsReconnect(id)`` is true, removes it otherwise. + // TODO(step 6): reshape this into the persisted hold map (id → reconnectDesired) and make this the + // sole persistence writer. This step keeps the existing set-shaped persistence shape/format. + private func syncReconnectIntent(id: String) { + if wantsReconnect(id: id) { + reconnectEnabled.insert(id) + } else { + reconnectEnabled.remove(id) + } + persistReconnectIntent() + } + + /// The single ensure-linked entry point (D-1 event 5). /// - /// - 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") + /// 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 { + 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 { 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 { + 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. + func applyManualConnectHold(id: String, reconnectDesired: Bool) { + manualConnectHold[id] = ManualConnectHold(reconnectDesired: reconnectDesired) + intentionalDisconnects.remove(id) + // TODO(step 5): cancel any idle timer for id here. + syncReconnectIntent(id: id) + } + + /// 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`. + func applyManualDisconnect(id: String) { + manualConnectHold.removeValue(forKey: id) + syncReconnectIntent(id: id) + + // Cancel the Tier-1 ladder. taskRegistry.cancel(id) reconnectAttempts[id] = nil - - setConnectionState(.disconnecting, for: id) - centralManager.cancelPeripheralConnection(cbPeripheral) + + guard let cbPeripheral = cbPeripherals[id] else { return } + + if cbPeripheral.state == .connected { + intentionalDisconnects.insert(id) + setConnectionState(.disconnecting, for: id) + centralManager?.cancelPeripheralConnection(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. + setConnectionState(.disconnected(reason: nil), for: id) + } + } + + /// 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 { + guard cbPeripherals[id] != nil else { throw PeripheralError.notFound } + + 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. + func releaseWorkLease(_ token: WorkLeaseToken) async { + guard activeLeases[token.id]?.remove(token.leaseID) != nil else { + log?.debug(tags: [.peripheral(token.id)], "releaseWorkLease: unknown or already-released token — no-op") + return + } + syncReconnectIntent(id: token.id) + // TODO(step 5): begin idle grace if demand(id) is now false. + } + + /// 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 } /// The single write path for per-peripheral connection state. @@ -1445,6 +1588,7 @@ actor BluetoothActor { } clearReconnectState(for: id) + syncReconnectIntent(id: id) log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral connected") setConnectionState(.connected, for: id) @@ -1575,7 +1719,7 @@ actor BluetoothActor { } 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) diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 1d49000..5f87388 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -173,7 +173,11 @@ public final class Peripheral: Sendable, Identifiable, Hashable { await manager.bluetooth.ensureCentralManager() - // Await a usable radio (cancellable), then forward to the actor connect path. + // 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 (D-hold). + await manager.bluetooth.applyManualConnectHold(id: id, reconnectDesired: autoReconnect) + + // Await a usable radio (cancellable), then ensure the link. let waiterID = UUID() try await withTaskCancellationHandler { try await manager.bluetooth.waitUntilPoweredOn(waiterID: waiterID) @@ -181,18 +185,45 @@ public final class Peripheral: Sendable, Identifiable, Hashable { Task { await manager.bluetooth.cancelPoweredOnContinuation(waiterID) } } - try await manager.bluetooth.connect(id: id, autoReconnect: autoReconnect) + try await manager.bluetooth.reevaluateLink(id: id, reason: .explicitConnect) } /// 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`. 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/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index e534944..1bdc5f2 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1920,7 +1920,10 @@ struct ReliaBLEManagerTests { #expect(await pollUntil(timeout: 3.0) { await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected }) - #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + // reconnectEnabled is now synced from demand (wantsReconnect). A restored link with no + // rehydrated manual-connect hold (hold rehydration is step 6) has no demand, so the id is + // not re-armed — the restore-time seed is corrected by handleDidConnect's syncReconnectIntent. + #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) await manager2.bluetooth.testClearPersistedReconnectIntent() await Mock.tearDown(manager2) @@ -2440,6 +2443,174 @@ struct ReliaBLEManagerTests { #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 { + 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() + + CBMCentralManagerMock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + + 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)) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + // step 6: the radio-return sweep (D-1 event 12) relinks the held id; not implemented until then. + } + + @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 manager.bluetooth.testHasManualConnectHold(for: handle.id)) + + // A radio reset invalidates live references (clears cbPeripherals) but preserves the hold. + CBMCentralManagerMock.simulateInitialState(.resetting) + _ = await Mock.waitForState("Resetting", on: manager) + #expect(!(await manager.bluetooth.testContainsCBPeripheral(handle.id))) + #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) + + // Dropping the hold during a radio outage must succeed, not throw .notFound. + try await handle.disconnect() + #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + } + + @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: - Logging Test Support From 39df14b4e036735f882cec68e8903ba6cd1c6d60 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 17:57:39 -0600 Subject: [PATCH 06/31] Idle disconnect: grace window and teardown (#58) --- Sources/ReliaBLE/BluetoothActor.swift | 123 ++++++- .../ReliaBLETests/ReliaBLEManagerTests.swift | 309 +++++++++++++++++- 2 files changed, 424 insertions(+), 8 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index f61178a..24bdebd 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -176,6 +176,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 @@ -270,6 +274,11 @@ actor BluetoothActor { private var activeLeases: [String: Set] = [:] private var manualConnectHold: [String: ManualConnectHold] = [:] + /// 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] = [:] + // MARK: - Initialization /// Bridge to the owning manager's handle registry. @@ -303,6 +312,7 @@ actor BluetoothActor { deinit { eventPipeline.finish() taskRegistry.cancelAll() + idleTaskRegistry.cancelAll() } /// Terminal teardown for tests/harness. Clears volatile state only — does **not** touch @@ -313,6 +323,7 @@ actor BluetoothActor { eventPipeline.finish() taskRegistry.cancelAll() + idleTaskRegistry.cancelAll() delegateEventTask?.cancel() delegateEventTask = nil @@ -357,6 +368,7 @@ actor BluetoothActor { reconnectAttempts.removeAll() activeLeases.removeAll() manualConnectHold.removeAll() + idleGeneration.removeAll() pendingRestoredScanServices = nil pendingRestoredScanOptions = nil } @@ -1469,7 +1481,10 @@ actor BluetoothActor { func applyManualConnectHold(id: String, reconnectDesired: Bool) { manualConnectHold[id] = ManualConnectHold(reconnectDesired: reconnectDesired) intentionalDisconnects.remove(id) - // TODO(step 5): cancel any idle timer for id here. + // 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) + log?.info(tags: [.peripheral(id), .category(.connection)], "Manual connect") syncReconnectIntent(id: id) } @@ -1481,8 +1496,12 @@ actor BluetoothActor { manualConnectHold.removeValue(forKey: id) syncReconnectIntent(id: id) - // Cancel the Tier-1 ladder. + log?.info(tags: [.peripheral(id), .category(.connection)], "Manual disconnect") + + // 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 { return } @@ -1507,6 +1526,10 @@ actor BluetoothActor { func acquireWorkLease(id: String) async throws -> WorkLeaseToken { 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) @@ -1521,7 +1544,81 @@ actor BluetoothActor { return } syncReconnectIntent(id: token.id) - // TODO(step 5): begin idle grace if demand(id) is now false. + if !demand(id: token.id) { + beginIdleGrace(id: token.id) + } + } + + /// 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 { + setConnectionState(.disconnected(reason: nil), for: id) + return + } + + intentionalDisconnects.insert(id) + setConnectionState(.disconnecting, for: id) + centralManager?.cancelPeripheralConnection(cbPeripheral) } /// Test-only hook: number of live work leases for `id`. @@ -1588,10 +1685,17 @@ actor BluetoothActor { } clearReconnectState(for: id) - syncReconnectIntent(id: id) 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) { @@ -1773,6 +1877,17 @@ 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: runs the same peripheral invalidation as a Bluetooth reset/unauthorized path. func testInvalidatePeripherals() { invalidatePeripherals() diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 1bdc5f2..f397441 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1920,10 +1920,12 @@ struct ReliaBLEManagerTests { #expect(await pollUntil(timeout: 3.0) { await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected }) - // reconnectEnabled is now synced from demand (wantsReconnect). A restored link with no - // rehydrated manual-connect hold (hold rehydration is step 6) has no demand, so the id is - // not re-armed — the restore-time seed is corrected by handleDidConnect's syncReconnectIntent. - #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) + // reconnectEnabled is synced from demand (wantsReconnect). A restored link with no rehydrated + // manual-connect hold (hold rehydration is step 6) has no demand. In step 5, handleDidConnect's + // event-8 branch routes a no-demand landing to beginIdleGrace (not syncReconnectIntent), so the + // stale restore-time seed is not yet corrected — step 6's hold rehydration + persistence reshape + // removes it. Assert the current step-5 state: the stale seed remains. + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) await manager2.bluetooth.testClearPersistedReconnectIntent() await Mock.tearDown(manager2) @@ -2611,6 +2613,305 @@ struct ReliaBLEManagerTests { } #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, ending at .disconnected(reason: nil). + 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.last == .disconnected(reason: nil)) + // step 6: assert work re-drives the link back to .connected + } + + @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) + } + + @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: - Logging Test Support From a33db1e8c6c3047a4b4ac3bad9933d0ac80d11b4 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 18:29:26 -0600 Subject: [PATCH 07/31] Reconnect gating, radio-drop projection, and durable holds (#59, #60) --- Sources/ReliaBLE/BluetoothActor.swift | 397 +++++++++--- Sources/ReliaBLE/Models/ConnectionState.swift | 15 +- .../ReliaBLETests/ReliaBLEManagerTests.swift | 602 +++++++++++++++++- 3 files changed, 897 insertions(+), 117 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 24bdebd..7b01fab 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -279,6 +279,13 @@ actor BluetoothActor { /// on wake. A stale generation means the timer was superseded and must not fire (D-1 event 6/7). private var idleGeneration: [String: UInt64] = [:] + /// 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. @@ -369,8 +376,8 @@ actor BluetoothActor { activeLeases.removeAll() manualConnectHold.removeAll() idleGeneration.removeAll() + reconnectGeneration.removeAll() pendingRestoredScanServices = nil - pendingRestoredScanOptions = nil } // MARK: - Event Streams @@ -1025,12 +1032,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** + /// (D-restore), 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 @@ -1048,8 +1054,10 @@ actor BluetoothActor { let now = Date() var didMutatePeripherals = false - // Reconnect intent persisted across launches; only ids in this set are re-armed below. - let persistedIntent = persistedReconnectIntent() + // Manual-connect holds persisted across launches (D-restore). A value written by an older + // build (array-of-ids) does not decode as the new map; a decode failure is treated as "no + // persisted holds". Work leases are never persisted and never rehydrated (case 3). + let persistedHolds = persistedHoldMap() for cbPeripheral in restoredPeripherals { // Restored peripherals arrive with no delegate; re-associate into actor-owned maps using the same @@ -1070,20 +1078,27 @@ actor BluetoothActor { 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). + // D-restore: a persisted manual-connect hold rehydrates, suppressing idle and re-arming + // reconnect intent for this link. Reconnect intent is now derived from demand, so there is + // no separate disconnected/connecting seeding of ``reconnectEnabled``. + let rehydratedHold = persistedHolds[resolvedId] + if let rehydratedHold { + manualConnectHold[resolvedId] = ManualConnectHold(reconnectDesired: rehydratedHold) + syncReconnectIntent(id: resolvedId) + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Rehydrated manual-connect hold (reconnectDesired=\(rehydratedHold))" + ) + } + + // 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: @@ -1095,6 +1110,28 @@ actor BluetoothActor { if let connectionState { setConnectionState(connectionState, for: resolvedId) } + + // Link-retention policy per D-restore: + // - Case 1 / case 4 (persisted hold present): **no** idle timer; ensure the link if it is + // not already establishing. A hold with `reconnectDesired: false` suppresses idle but + // arms neither tier and is not re-issued (``reevaluateLink``'s issue gate refuses + // `radioReturned` without `wantsReconnect`). + // - Case 2 (no persisted hold): the idle timer starts — restored links the app never + // explicitly asked for still idle out (NFR-3.2). + if rehydratedHold != nil { + 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 (D-restore case 2). + // A disconnected restored peripheral has nothing to tear down and must stay untracked. + beginIdleGrace(id: resolvedId) + } } if didMutatePeripherals { @@ -1155,11 +1192,19 @@ actor BluetoothActor { 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)") @@ -1170,6 +1215,40 @@ actor BluetoothActor { 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`` + /// is the terminal outcome an app must observe (the lease or hold is retained, no scan, no retry + /// loop) — publish it via ``setConnectionState`` rather than dropping it. + 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) + 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( _ cbPeripheral: CBPeripheral, advertisementData: [String: Any], @@ -1200,6 +1279,32 @@ actor BluetoothActor { // Stash the live reference under the resolved id. Never escapes the actor. 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. Narrowed to the terminal `.failed` state 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. + if demand(id: resolvedId) { + // 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). `.reconnecting` + // ids are already being driven (ladder or AwaitingRadio projection), so they are skipped. + switch connectionStates[resolvedId] { + case .connected, .connecting, .disconnecting, .reconnecting(_, _, _): + break + default: + 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 @@ -1302,23 +1407,68 @@ 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) Every tracked id emits a terminal `.disconnected` — the link really is dead. + for id in trackedIds { + 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. + for id in untrackedIds { + registry.applyConnectionState(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. + for id in reconnectingIds { + syncReconnectIntent(id: id) + 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. + for id in suppressedIds { + 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") } // 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. @@ -1326,24 +1476,6 @@ 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 } @@ -1404,19 +1536,56 @@ actor BluetoothActor { centralManager.connect(cbPeripheral, options: options) } - /// Keeps ``reconnectEnabled`` in step with the derived demand signal, and is the place hold-driven - /// persistence is written from. + /// 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`. /// - /// Inserts ``id`` when ``wantsReconnect(id)`` is true, removes it otherwise. - // TODO(step 6): reshape this into the persisted hold map (id → reconnectDesired) and make this the - // sole persistence writer. This step keeps the existing set-shaped persistence shape/format. - private func syncReconnectIntent(id: String) { - if wantsReconnect(id: id) { + /// 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). + /// + /// Persistence is **hold-driven only**: `persistHold` is `true` only from the two manual-connect + /// entry points (``applyManualConnectHold(id:reconnectDesired:)`` and + /// ``applyManualDisconnect(id:)``), never from work-lease or event-driven demand changes. The + /// persisted artifact is the hold map (`id → reconnectDesired`), not the reconnect-enabled set, + /// and is namespaced by ``restoreIdentifier``. Nothing is written when there is no restore + /// identifier (no relaunch path could consume it) — D-4 / D-restore. + 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 } - persistReconnectIntent() + if persistHold, restoreIdentifier != nil { + persistHoldMap() + } + } + + /// Writes the persisted hold map (`id → reconnectDesired`) for the current ``restoreIdentifier``. + /// + /// Only ``syncReconnectIntent`` (with `persistHold: true`) and the restore-time read-back 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 (D-1 event 13/14). + private func persistHoldMap() { + guard let key = reconnectIntentDefaultsKey else { return } + let holds = manualConnectHold.mapValues { $0.reconnectDesired } + UserDefaults.standard.set(holds, forKey: key) + } + + /// Reads the persisted hold map (`id → reconnectDesired`) back at restore time. + /// + /// A value written by an older build (array-of-ids) does not decode as the new `[String: Bool]` + /// map — treat a decode failure as "no persisted holds" and move on. No migration shim (pre-release). + private func persistedHoldMap() -> [String: Bool] { + guard let key = reconnectIntentDefaultsKey, + let stored = UserDefaults.standard.dictionary(forKey: key) as? [String: Bool] else { return [:] } + return stored } /// The single ensure-linked entry point (D-1 event 5). @@ -1485,7 +1654,7 @@ actor BluetoothActor { idleGeneration[id] = (idleGeneration[id] ?? 0) + 1 idleTaskRegistry.cancel(id) log?.info(tags: [.peripheral(id), .category(.connection)], "Manual connect") - syncReconnectIntent(id: id) + syncReconnectIntent(id: id, persistHold: true) } /// Clears a manual-connect hold and tears down the link per the settling rule (D-1 event 4). @@ -1494,7 +1663,7 @@ actor BluetoothActor { /// dropping a hold during a radio outage must not throw `.notFound`. func applyManualDisconnect(id: String) { manualConnectHold.removeValue(forKey: id) - syncReconnectIntent(id: id) + syncReconnectIntent(id: id, persistHold: true) log?.info(tags: [.peripheral(id), .category(.connection)], "Manual disconnect") @@ -1703,39 +1872,68 @@ 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) { + 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] { + centralManager?.cancelPeripheralConnection(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) { @@ -1743,27 +1941,33 @@ 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 { 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. + // 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") - + return } @@ -1772,8 +1976,12 @@ actor BluetoothActor { } 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"). @@ -1808,13 +2016,14 @@ 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 @@ -1834,6 +2043,11 @@ actor BluetoothActor { 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) } @@ -1893,6 +2107,22 @@ actor BluetoothActor { 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) @@ -1991,9 +2221,10 @@ 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: removes any persisted reconnect intent for the current restore identifier. diff --git a/Sources/ReliaBLE/Models/ConnectionState.swift b/Sources/ReliaBLE/Models/ConnectionState.swift index 4ee18b8..c58c70d 100644 --- a/Sources/ReliaBLE/Models/ConnectionState.swift +++ b/Sources/ReliaBLE/Models/ConnectionState.swift @@ -34,10 +34,13 @@ 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 the moment a demanded link's radio drops, 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 +49,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/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index f397441..3ead75e 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1810,7 +1810,7 @@ struct ReliaBLEManagerTests { _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } - #expect(await manager1.bluetooth.testPersistedReconnectIntent().contains(handle.id)) + #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. @@ -1877,8 +1877,10 @@ struct ReliaBLEManagerTests { } @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 + // 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(()) @@ -1905,6 +1907,8 @@ struct ReliaBLEManagerTests { _ = 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() @@ -1920,18 +1924,21 @@ struct ReliaBLEManagerTests { #expect(await pollUntil(timeout: 3.0) { await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected }) - // reconnectEnabled is synced from demand (wantsReconnect). A restored link with no rehydrated - // manual-connect hold (hold rehydration is step 6) has no demand. In step 5, handleDidConnect's - // event-8 branch routes a no-demand landing to beginIdleGrace (not syncReconnectIntent), so the - // stale restore-time seed is not yet corrected — step 6's hold rehydration + persistence reshape - // removes it. Assert the current step-5 state: the stale seed remains. + // 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 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(()) @@ -1952,11 +1959,14 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager1.stopScanning() - try await handle.connect(autoReconnect: false) + try await handle.connect() _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[handle.id] == .connected } - #expect(!(await manager1.bluetooth.testPersistedReconnectIntent().contains(handle.id))) + // 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() @@ -1971,7 +1981,9 @@ struct ReliaBLEManagerTests { #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) @@ -1990,7 +2002,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) } - @Test func invalidatePeripheralsClearsRestoredStateAndIntent() async throws { + @Test func invalidatePreservesHoldsProjectsReconnectingAndKeepsDeferredScan() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) @@ -2039,21 +2051,30 @@ struct ReliaBLEManagerTests { #expect(await manager2.bluetooth.testPendingRestoredScanServices() == [scanUUID]) 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) + // 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 invalidatePeripheralsClearsHandleConnectionStateButKeepsMetadata() async throws { + @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 connection - // state must NOT — a handle stuck reporting `.connected` after the library tore the connection down is - // not stale, it is false. + // 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(()) } @@ -2077,8 +2098,9 @@ struct ReliaBLEManagerTests { await manager.bluetooth.testInvalidatePeripherals() - #expect(handle.connectionState == nil) - #expect(await manager.currentConnectionStates[handle.id] == nil) + #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) } @@ -2132,8 +2154,10 @@ struct ReliaBLEManagerTests { watchdog.cancel() #expect(terminal?.state == .disconnected(reason: .bluetoothUnavailable)) - // The event describes the transition; the handle reports "no longer tracked". - #expect(handle.connectionState == nil) + // 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 willRestoreDefersScanUntilPoweredOn() async throws { @@ -2517,12 +2541,18 @@ struct ReliaBLEManagerTests { } @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. + 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.testPeripheralID, + id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) @@ -2541,7 +2571,14 @@ struct ReliaBLEManagerTests { CBMCentralManagerMock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) - // step 6: the radio-return sweep (D-1 event 12) relinks the held id; not implemented until then. + + // 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 manualDisconnectDuringRadioOutageSucceeds() async throws { @@ -2704,12 +2741,18 @@ struct ReliaBLEManagerTests { #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) #expect(await manager.bluetooth.testWorkCount(for: handle.id) == 1) - // The link cancels once: exactly one .disconnecting, ending at .disconnected(reason: nil). + // 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.last == .disconnected(reason: nil)) - // step 6: assert work re-drives the link back to .connected + #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 { @@ -2912,6 +2955,501 @@ struct ReliaBLEManagerTests { 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 }) + + CBMCentralManagerMock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + CBMCentralManagerMock.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 } + + CBMCentralManagerMock.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) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + try? await handle.disconnect() + } + + @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 } + + CBMCentralManagerMock.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) }) + + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + try? await handle.disconnect() + } + + @Test func noReconnectHoldIsNotResurrectedByRadioCycle() 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(autoReconnect: false) + _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } + + CBMCentralManagerMock.simulatePowerOff() + _ = await Mock.waitForState("Powered Off", on: manager) + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + + // wantsReconnect false → reason .radioReturned does not satisfy the issue gate. + #expect(await manager.currentConnectionStates[handle.id] != .connected) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) + try? await handle.disconnect() + } + + @Test func strandedLeaseSurfacesFailure() 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 }) + + // 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 token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + 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. + Mock.connectionTestSpec.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() + + #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) + + await handle.releaseWorkLease(token) + } + + @Test func shutdownLeavesPersistedHoldsIntact() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + 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 restoreId = "com.five3apps.relia-ble.tests.invalidate-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) + + await manager.bluetooth.testInvalidatePeripherals() + + #expect(await manager.bluetooth.testPersistedManualConnectHolds()[handle.id] == true) + await manager.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager) + } + + @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") + CBMCentralManagerMock.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]) + + CBMCentralManagerMock.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 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() + + 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 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 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) + }) + + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test func restoredManualHoldSurvivesRelaunch() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } + + 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() + + 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) + + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) + + // 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 manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test func restoredHoldWithAutoReconnectFalseSuppressesIdleButNotTier1() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } + + 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() + + 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) + + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) + + // 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) + + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + @Test func workLeasesDoNotSurviveRelaunch() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } + + 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() + + 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) + + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() + Mock.installStateRestoration(restoreIdentifier: restoreId, peripherals: [Mock.connectionTestSpec], scanServices: nil) + + // 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) + }) + + _ = token + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } } // MARK: - Logging Test Support @@ -3065,13 +3603,15 @@ enum Mock { 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) @@ -3102,6 +3642,7 @@ enum Mock { loggingEnabled: Bool = false, reconnectPolicy: ReconnectPolicy? = nil, restoreIdentifier: String? = nil, + idleDisconnectInterval: TimeInterval? = nil, tearDownPrevious: Bool = true ) async -> ReliaBLEManager { await SimulationConfig.shared.ensureConfigured() @@ -3113,6 +3654,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 { From 353347b4142b3c22b98fb19caaf43942dc135460 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 18:36:56 -0600 Subject: [PATCH 08/31] DocC and PRD updates for the work-driven connection lifecycle --- PRD.md | 14 +++++----- .../Documentation.docc/GettingStarted.md | 26 ++++++++++++++++--- .../Documentation.docc/Topics/Background.md | 17 ++++++++++++ .../Documentation.docc/Topics/Concurrency.md | 20 ++++++++++---- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/PRD.md b/PRD.md index 43123a9..c2171b4 100644 --- a/PRD.md +++ b/PRD.md @@ -75,8 +75,8 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - 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. +- ✅ 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: @@ -258,11 +258,11 @@ 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. +- ✅ 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. ### Non-Functional Requirements diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 368761f..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,9 +86,8 @@ 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: @@ -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 774bd05..e8dd0a1 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -61,11 +61,21 @@ All mutating actions are `async` and hop onto the Bluetooth actor for you: - ``Peripheral/connect(autoReconnect:)`` - ``Peripheral/disconnect()`` -> Note: ``ReliaBLEManager/startScanning(services:)`` now **throws**. A usable radio is awaited -> before scanning begins: a transient state (`.resetting` / `.unknown`) is awaited, while a -> terminal state (powered off or unsupported) fails fast with a typed ``PeripheralError``. -> Cancelling the calling task while the scan is parked on a transient state unblocks the pending -> radio wait with a `CancellationError`. +> 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``: From a2b2fc6c83f65ed7bb4d3f82ad4beb9db1bbf89c Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 18:41:58 -0600 Subject: [PATCH 09/31] Demo: adopt async throws scanning and idle interval --- .../ReliaBLE Demo/Central/CentralView.swift | 7 +++++++ .../ReliaBLE Demo/Central/CentralViewModel.swift | 12 +++++++++++- .../ReliaBLE Demo/ReliaBLE_DemoApp.swift | 2 ++ .../ReliaBLE Demo/Settings/SettingsView.swift | 14 ++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index f96a8db..0248bed 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -109,6 +109,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() diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index 0174f79..df4b31a 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? @@ -65,7 +66,16 @@ import ReliaBLE 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/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") } From 235b9bf4ff0599d781ab5c5ced31781bb553b6bd Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 19:29:34 -0600 Subject: [PATCH 10/31] Fix teardown cancellation, reconnect gating, and scan waiter supersession --- Sources/ReliaBLE/BluetoothActor.swift | 215 ++++++++++++------ .../ReliaBLETests/ReliaBLEManagerTests.swift | 189 +++++++++++++++ 2 files changed, 339 insertions(+), 65 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 7b01fab..fda1e81 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -159,6 +159,26 @@ enum LinkReason: Sendable { 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. @@ -210,12 +230,15 @@ actor BluetoothActor { /// the next central state update. private var poweredOnContinuations: [UUID: CheckedContinuation] = [:] - /// Continuation for a parked ``startScanning()`` call awaiting a usable radio. There is at most one - /// pending scan at a time, so a single slot suffices. - private var scanWaiterContinuation: 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? - /// Service filter requested by the currently parked scan waiter, or `nil` if none is parked. - private var scanWaiterServices: [CBUUID]? + /// 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]? /// 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 @@ -355,10 +378,9 @@ actor BluetoothActor { continuation.resume(throwing: PeripheralError.bluetoothUnavailable) } - if let continuation = scanWaiterContinuation { - scanWaiterContinuation = nil - scanWaiterServices = nil - continuation.resume(throwing: PeripheralError.bluetoothUnavailable) + if let waiter = scanWaiter { + scanWaiter = nil + waiter.continuation.resume(throwing: PeripheralError.bluetoothUnavailable) } centralManager = nil @@ -757,19 +779,18 @@ actor BluetoothActor { /// 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 = scanWaiterContinuation != nil + 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. - if let continuation = scanWaiterContinuation { - scanWaiterContinuation = nil - let services = scanWaiterServices - scanWaiterServices = nil - beginScan(services: services) - continuation.resume(returning: ()) + // 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 @@ -794,10 +815,9 @@ actor BluetoothActor { /// Fails every parked ``waitUntilPoweredOn()`` continuation and any parked scan waiter with a /// terminal radio-state error. private func failPoweredOnAndScanWaiters(with error: PeripheralError) { - if let continuation = scanWaiterContinuation { - scanWaiterContinuation = nil - scanWaiterServices = nil - continuation.resume(throwing: error) + if let waiter = scanWaiter { + scanWaiter = nil + waiter.continuation.resume(throwing: error) } if !poweredOnContinuations.isEmpty { let pending = poweredOnContinuations @@ -870,62 +890,47 @@ actor BluetoothActor { /// resumed continuation runs on a later actor turn and the radio may have flipped again. private func parkScanWaiter(services: sending [CBUUID]?) async throws { // A later startScanning supersedes a parked one: resolve the previous waiter successfully. - if let previous = scanWaiterContinuation { - scanWaiterContinuation = nil - scanWaiterServices = nil - previous.resume(returning: ()) + // 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 - scanWaiterServices = services - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let reason: ScanWaiterResumeReason = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in guard !Task.isCancelled else { continuation.resume(throwing: CancellationError()) return } - scanWaiterContinuation = continuation + scanWaiter = ScanWaiter(id: UUID(), services: services, continuation: continuation) } - // Resumed. Re-read the radio because it may have flipped since the waiter was resolved. - guard let centralManager else { throw PeripheralError.bluetoothUnavailable } - switch centralManager.state { + // 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: - // If ``resolvePoweredOnWaiters()`` already issued the scan, nothing left to do. If this - // waiter was resolved by ``stopScanning()``, ``scanWaiterServices`` is nil and we must - // not start a scan. Otherwise issue it now (e.g. after a transient re-park above). - if scanWaiterServices != nil, centralManager.isScanning == false { - let services = scanWaiterServices - scanWaiterServices = nil - beginScan(services: services) - } - case .resetting, .unknown: - // The radio regressed to a transient state while we held the continuation; re-park - // only while a scan is still requested. If ``stopScanning()`` resolved this waiter, it - // cleared ``scanWaiterServices`` — return successfully, no scan, rather than re-parking. - if scanWaiterServices != nil { - try await parkScanWaiter(services: scanWaiterServices) - } - case .poweredOff: - throw PeripheralError.bluetoothPoweredOff - case .unsupported: - throw PeripheralError.bluetoothUnsupported - case .unauthorized: - throw PeripheralError.bluetoothUnavailable - @unknown default: - throw PeripheralError.bluetoothUnavailable + // 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. No-op when no waiter is parked. func cancelScanWaiter() { - guard let continuation = scanWaiterContinuation else { return } - scanWaiterContinuation = nil - scanWaiterServices = nil - continuation.resume(throwing: CancellationError()) + guard let waiter = scanWaiter else { return } + scanWaiter = nil + waiter.continuation.resume(throwing: CancellationError()) } /// Issues `scanForPeripherals` for the given filter and broadcasts the resulting state. @@ -938,6 +943,7 @@ actor BluetoothActor { } guard let centralManager else { return } + lastScanServices = services centralManager.scanForPeripherals(withServices: services, options: nil) if centralManager.isScanning { @@ -956,10 +962,9 @@ actor BluetoothActor { /// not an error (D-2). func stopScanning() { // Complete any parked scan waiter successfully before tearing down the scan. - if let continuation = scanWaiterContinuation { - scanWaiterContinuation = nil - scanWaiterServices = nil - continuation.resume(returning: ()) + if let waiter = scanWaiter { + scanWaiter = nil + waiter.continuation.resume(returning: .stopped) } guard !isShutdown else { @@ -1513,6 +1518,13 @@ actor BluetoothActor { 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 + } + /// 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 @@ -1683,8 +1695,15 @@ actor BluetoothActor { // 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. + // `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`. setConnectionState(.disconnected(reason: nil), for: id) + if cbPeripheral.state == .connecting || cachedSystemReconnect(id: id) { + centralManager?.cancelPeripheralConnection(cbPeripheral) + } } } @@ -1781,7 +1800,15 @@ actor BluetoothActor { // 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`. setConnectionState(.disconnected(reason: nil), for: id) + if let live = cbPeripherals[id], live.state == .connecting || cachedSystemReconnect(id: id) { + centralManager?.cancelPeripheralConnection(live) + } return } @@ -2030,7 +2057,52 @@ actor BluetoothActor { 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: + 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: _): + return + default: + break + } + do { try issueConnect(id: id, enableAutoReconnect: wantsReconnect(id: id)) } catch { @@ -2040,6 +2112,14 @@ actor BluetoothActor { 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)], "Reconnect attempt gated: \(error)") + setConnectionState(.failed(reason: error), for: id) + } private func clearReconnectState(for id: String) { taskRegistry.cancel(id) @@ -2074,7 +2154,12 @@ actor BluetoothActor { /// Test-only hook: whether a ``startScanning(services:)`` waiter is currently parked. func testPendingScanWaiterCount() -> Int { - scanWaiterContinuation == nil ? 0 : 1 + 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, diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 3ead75e..4c70bac 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -427,6 +427,38 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) } + @Test func supersededScanWaiterDoesNotStealNewerRequest() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.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) + + CBMCentralManagerMock.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 startScanningCancellationUnblocksWaiter() async throws { let manager = await Mock.makeManager() await Mock.ensureReady(manager) @@ -2864,6 +2896,99 @@ struct ReliaBLEManagerTests { #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 1: idle teardown while a Tier-0 / system reconnect is in progress (.reconnecting(.system)) + // must settle AND cancel, so the OS cannot relink a link nobody wants (FR-1.2 / D-tier). + @Test func idleTeardownDuringSystemReconnectCancels() 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() + + // A work lease links the peripheral with the Tier-0 (auto-reconnect) option active. + let token = try await handle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[handle.id] == .connected + }) + + // OS-delivered Tier-0 reconnect: cached state moves to .reconnecting(.system). + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: true) + #expect(await pollUntil(timeout: 3.0) { + if case .reconnecting(.system, _, _) = await manager.currentConnectionStates[handle.id] { + return true + } + return false + }) + + // Demand drops mid-system-reconnect: idle teardown must settle AND cancel the OS's pending work. + 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) + + // No later .connected may arrive from the suppressed Tier-0 reconnect. First flush the events + // already observed (the buffer includes the initial .connecting/.connected from lease acquisition + // and the .reconnecting(.system) above), THEN drain a fresh window and assert nothing links again. + _ = await drainConnectionStateChanges(from: changes, withinNanoseconds: 400_000_000) + 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)) + } + @Test func zeroIdleIntervalTearsDownImmediately() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } @@ -3120,6 +3245,70 @@ struct ReliaBLEManagerTests { try? await handle.disconnect() } + // Defect 2: a reconnect ladder step must not issue a connect against a dead radio. A ladder retry + // that wakes only reads `centralManager.state`; the synchronous flip on `simulatePowerOff` is + // observed before the deferred delegate invalidation cancels the ladder, so without the radio gate + // a redundant connect could be issued. We assert the ladder never reaches `.connecting` on a dead + // radio and instead surfaces `.bluetoothPoweredOff` after the gate. + @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. + CBMCentralManagerMock.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. + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + await handle.releaseWorkLease(token) + } + @Test func radioDropWithoutWantsReconnectShowsNoReconnecting() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } From 0efbcdf5e8e3ae4537ad7e868cf2442c15977329 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 19:40:56 -0600 Subject: [PATCH 11/31] Make scan waiter cancellation waiter-specific --- Sources/ReliaBLE/BluetoothActor.swift | 19 +++++----- Sources/ReliaBLE/ReliaBLEManager.swift | 5 +-- .../ReliaBLETests/ReliaBLEManagerTests.swift | 35 +++++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index fda1e81..dccff3b 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -847,7 +847,7 @@ actor BluetoothActor { /// 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) async throws { + func startScanning(services: sending [CBUUID]? = nil, waiterID: UUID) async throws { guard !isShutdown else { log?.warn(tags: [.category(.scanning)], "Attempted to start scan after shutdown") throw PeripheralError.bluetoothUnavailable @@ -872,7 +872,7 @@ actor BluetoothActor { case .unauthorized: throw PeripheralError.bluetoothUnavailable case .resetting, .unknown: - try await parkScanWaiter(services: services) + try await parkScanWaiter(services: services, waiterID: waiterID) @unknown default: throw PeripheralError.bluetoothUnavailable } @@ -888,7 +888,7 @@ actor BluetoothActor { /// /// 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]?) async throws { + 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. @@ -906,7 +906,7 @@ actor BluetoothActor { continuation.resume(throwing: CancellationError()) return } - scanWaiter = ScanWaiter(id: UUID(), services: services, continuation: continuation) + scanWaiter = ScanWaiter(id: waiterID, services: services, continuation: continuation) } // Resumed. Decide from OUR OWN resume reason and identity — never a global slot field. @@ -926,11 +926,14 @@ actor BluetoothActor { } /// Cancels a parked ``startScanning(services:)`` waiter, invoked from a - /// `withTaskCancellationHandler` onCancel. No-op when no waiter is parked. - func cancelScanWaiter() { - guard let waiter = scanWaiter else { return } + /// `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()) + waiter?.continuation.resume(throwing: CancellationError()) } /// Issues `scanForPeripherals` for the given filter and broadcasts the resulting state. diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index a4e555f..cb63e69 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -188,10 +188,11 @@ public final class ReliaBLEManager: Sendable { public func startScanning(services: sending [CBUUID]? = nil) async throws { await bluetooth.ensureCentralManager() + let waiterID = UUID() try await withTaskCancellationHandler { - try await bluetooth.startScanning(services: services) + try await bluetooth.startScanning(services: services, waiterID: waiterID) } onCancel: { - Task { await bluetooth.cancelScanWaiter() } + Task { await bluetooth.cancelScanWaiter(waiterID) } } } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 4c70bac..725a728 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -459,6 +459,41 @@ struct ReliaBLEManagerTests { await manager.stopScanning() } + @Test func cancellingSupersededScanWaiterDoesNotCancelNewerRequest() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + CBMCentralManagerMock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let scanUUID = CBUUID(string: "180D") + + // Waiter A 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 } + + // Waiter B (with a NON-nil filter) supersedes A, which completes successfully without + // scanning. B now owns the single-slot scan waiter. + let second = Task { try await manager.startScanning(services: [scanUUID]) } + _ = try await first.value // superseded — success, no scan, no error + _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } + + // Cancelling A's task must NOT steal B's parked waiter — cancellation is waiter-specific. + first.cancel() + _ = try await first.value // still completes; superseded return already delivered + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 1) + + // Power on: B still owns the request and starts scanning with B's filter, and did not throw. + CBMCentralManagerMock.simulatePowerOn() + _ = try await second.value + + #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) From 8441988877ba049c8935223da01de8dd3f1a7f0f Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 19:52:25 -0600 Subject: [PATCH 12/31] Make the scan-cancel regression test actually falsifiable --- .../ReliaBLETests/ReliaBLEManagerTests.swift | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 725a728..74f7e57 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -459,7 +459,7 @@ struct ReliaBLEManagerTests { await manager.stopScanning() } - @Test func cancellingSupersededScanWaiterDoesNotCancelNewerRequest() async throws { + @Test func cancelScanWaiterIgnoresNonMatchingWaiterID() async throws { let manager = await Mock.makeManager() await Mock.ensureReady(manager) @@ -468,24 +468,20 @@ struct ReliaBLEManagerTests { let scanUUID = CBUUID(string: "180D") - // Waiter A parks with a NIL filter while the radio is transient. - let first = Task { try await manager.startScanning(services: nil) } + // 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 } - // Waiter B (with a NON-nil filter) supersedes A, which completes successfully without - // scanning. B now owns the single-slot scan waiter. - let second = Task { try await manager.startScanning(services: [scanUUID]) } - _ = try await first.value // superseded — success, no scan, no error - _ = 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()) - // Cancelling A's task must NOT steal B's parked waiter — cancellation is waiter-specific. - first.cancel() - _ = try await first.value // still completes; superseded return already delivered #expect(await manager.bluetooth.testPendingScanWaiterCount() == 1) - // Power on: B still owns the request and starts scanning with B's filter, and did not throw. + // Power on: the waiter resolves and starts scanning with its filter. CBMCentralManagerMock.simulatePowerOn() - _ = try await second.value + _ = try await scanTask.value // Must NOT throw #expect(await Mock.waitForState("Scanning", on: manager)) #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) From 4ad6972a47f277c35024528b82f938aebe56306f Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 21:21:44 -0600 Subject: [PATCH 13/31] Make idle-cancel and ladder-radio-gate tests falsifiable --- Sources/ReliaBLE/BluetoothActor.swift | 57 +++++++++- .../ReliaBLETests/ReliaBLEManagerTests.swift | 101 ++++++++++++++++-- 2 files changed, 144 insertions(+), 14 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index dccff3b..de04cb4 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -292,6 +292,11 @@ actor BluetoothActor { private var intentionalDisconnects: Set = [] private var reconnectAttempts: [String: Int] = [:] + /// 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] = [:] + /// 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] = [:] @@ -1551,6 +1556,17 @@ actor BluetoothActor { centralManager.connect(cbPeripheral, options: options) } + /// 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`. /// @@ -1693,7 +1709,7 @@ actor BluetoothActor { if cbPeripheral.state == .connected { intentionalDisconnects.insert(id) setConnectionState(.disconnecting, for: id) - centralManager?.cancelPeripheralConnection(cbPeripheral) + 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 @@ -1705,7 +1721,7 @@ actor BluetoothActor { // It is fire-and-forget and deliberately NOT inserted into `intentionalDisconnects`. setConnectionState(.disconnected(reason: nil), for: id) if cbPeripheral.state == .connecting || cachedSystemReconnect(id: id) { - centralManager?.cancelPeripheralConnection(cbPeripheral) + issueCancel(cbPeripheral) } } } @@ -1810,14 +1826,14 @@ actor BluetoothActor { // is no `.disconnecting` to settle) and without publishing `.disconnecting`. setConnectionState(.disconnected(reason: nil), for: id) if let live = cbPeripherals[id], live.state == .connecting || cachedSystemReconnect(id: id) { - centralManager?.cancelPeripheralConnection(live) + issueCancel(live) } return } intentionalDisconnects.insert(id) setConnectionState(.disconnecting, for: id) - centralManager?.cancelPeripheralConnection(cbPeripheral) + issueCancel(cbPeripheral) } /// Test-only hook: number of live work leases for `id`. @@ -1946,7 +1962,7 @@ actor BluetoothActor { "Tier-0 reconnect suppressed (no demand wants it)") setConnectionState(.disconnected(reason: nil), for: id) if let cbPeripheral = cbPeripherals[id] { - centralManager?.cancelPeripheralConnection(cbPeripheral) + issueCancel(cbPeripheral) } } return @@ -2259,11 +2275,42 @@ 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: 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) { + 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) + } + /// Test-only hook: number of registered `connectionStateChanges` subscribers. /// /// Stream registration is asynchronous — the factory schedules `register(...)` on a detached diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 74f7e57..3e01e30 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -2975,7 +2975,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.bluetooth.setIdleDisconnectInterval(0.1) + await manager.bluetooth.setIdleDisconnectInterval(0.01) let changes = manager.connectionStateChanges @@ -2994,21 +2994,36 @@ struct ReliaBLEManagerTests { await manager.currentConnectionStates[handle.id] == .connected }) - // OS-delivered Tier-0 reconnect: cached state moves to .reconnecting(.system). - await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: true) + // OS-delivered Tier-0 reconnect: a REAL drop of the (auto-reconnect) connected peripheral, so + // the live `CBPeripheral` is no longer connected and the cached state moves to + // `.reconnecting(.system)`. Unlike `testInjectDisconnect` (which bypasses the mock and leaves + // it connected), this drives the actual limbo the idle-torn guard protects: a physically + // dropped link the OS is still trying to relink. + Mock.connectionTestSpec.simulateDisconnection() #expect(await pollUntil(timeout: 3.0) { if case .reconnecting(.system, _, _) = await manager.currentConnectionStates[handle.id] { return true } return false }) + // Confirm we are genuinely in the non-connected limbo, not still `.connected` in the mock. + #expect(await manager.bluetooth.testContainsCBPeripheral(handle.id)) + #expect((await manager.bluetooth.testCBPeripheralState(for: handle.id)) != .connected, + "Scenario must drive the non-connected Tier-0 limbo") - // Demand drops mid-system-reconnect: idle teardown must settle AND cancel the OS's pending work. + // Demand drops mid-system-reconnect: idle teardown must settle AND issue exactly one cancel to + // stop the OS's pending reconnect work. The mock cannot complete the OS-reconnect on its own, so + // we pin the cancel CALL itself rather than a downstream side effect the mock cannot produce — a + // "no later .connected" assertion would pass trivially whether or not the cancel fires. 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 the Tier-0 reconnect") + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(handle.id) == false, + "Idle teardown of a non-connected Tier-0 limbo must not mark the disconnect intentional") // No later .connected may arrive from the suppressed Tier-0 reconnect. First flush the events // already observed (the buffer includes the initial .connecting/.connected from lease acquisition @@ -3276,11 +3291,12 @@ struct ReliaBLEManagerTests { try? await handle.disconnect() } - // Defect 2: a reconnect ladder step must not issue a connect against a dead radio. A ladder retry - // that wakes only reads `centralManager.state`; the synchronous flip on `simulatePowerOff` is - // observed before the deferred delegate invalidation cancels the ladder, so without the radio gate - // a redundant connect could be issued. We assert the ladder never reaches `.connecting` on a dead - // radio and instead surfaces `.bluetoothPoweredOff` after the gate. + // 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(()) } @@ -3340,6 +3356,73 @@ struct ReliaBLEManagerTests { 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 + }) + + // 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. + CBMCentralManagerMock.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(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") + + // Cleanup: restore the radio and tear down the lease. + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + await handle.releaseWorkLease(token) + } + @Test func radioDropWithoutWantsReconnectShowsNoReconnecting() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } From bbff3d10208711ae61e5c0f0ddc036bba11f2bad Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 22:55:43 -0600 Subject: [PATCH 14/31] Evaluate teardown cancel predicate before settling state --- Sources/ReliaBLE/BluetoothActor.swift | 31 ++++- .../ReliaBLETests/ReliaBLEManagerTests.swift | 131 +++++++++++++++++- 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index de04cb4..e847b4a 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -1719,8 +1719,15 @@ actor BluetoothActor { // `.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 cbPeripheral.state == .connecting || cachedSystemReconnect(id: id) { + + if shouldCancelPendingWork { issueCancel(cbPeripheral) } } @@ -1824,8 +1831,16 @@ actor BluetoothActor { // 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 let live = cbPeripherals[id], live.state == .connecting || cachedSystemReconnect(id: id) { + + if shouldCancelPendingWork, let live { issueCancel(live) } return @@ -2280,6 +2295,18 @@ actor BluetoothActor { 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 diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 3e01e30..3733ca8 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -503,8 +503,17 @@ struct ReliaBLEManagerTests { // Cancelling the awaiting task unblocks the parked waiter with a `CancellationError` // and leaves no continuation behind. scanTask.cancel() - await #expect(throws: CancellationError.self) { - try await scanTask.value + // 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) @@ -3035,6 +3044,99 @@ struct ReliaBLEManagerTests { #expect(await manager.currentConnectionStates[handle.id] == .disconnected(reason: nil)) } + @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") + } + // 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") + + // 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) + } + @Test func zeroIdleIntervalTearsDownImmediately() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } @@ -4117,6 +4219,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. From 12275a112e35b1664dcb2057d2a2978e627f8a2c Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 8 Aug 2026 23:03:47 -0600 Subject: [PATCH 15/31] Pin the manual-disconnect cached Tier-0 cancel path --- .../ReliaBLETests/ReliaBLEManagerTests.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 3733ca8..1aca3d5 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -2976,6 +2976,53 @@ struct ReliaBLEManagerTests { #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") + } + // Defect 1: idle teardown while a Tier-0 / system reconnect is in progress (.reconnecting(.system)) // must settle AND cancel, so the OS cannot relink a link nobody wants (FR-1.2 / D-tier). @Test func idleTeardownDuringSystemReconnectCancels() async throws { From 1fecc2580649bff5cd7d91423a7e3ec538dc1062 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 09:48:39 -0600 Subject: [PATCH 16/31] Serialize CoreBluetoothMock simulation on the main actor --- .../ReliaBLETests/ReliaBLEManagerTests.swift | 214 ++++++++++-------- 1 file changed, 125 insertions(+), 89 deletions(-) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 1aca3d5..8694a98 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -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)) @@ -305,7 +305,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() #expect(await Mock.waitForState("Powered Off", on: manager)) await #expect(throws: PeripheralError.bluetoothPoweredOff) { @@ -316,7 +316,7 @@ struct ReliaBLEManagerTests { #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) } @@ -324,7 +324,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unsupported) + await Mock.simulateInitialState(.unsupported) #expect(await Mock.waitForState("Unsupported", on: manager)) await #expect(throws: PeripheralError.bluetoothUnsupported) { @@ -334,7 +334,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testIsScanning() == false) // Restore power so later tests start from a known-good state. - CBMCentralManagerMock.simulateInitialState(.poweredOn) + await Mock.simulateInitialState(.poweredOn) #expect(await Mock.waitForState("Ready", on: manager)) } @@ -342,7 +342,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let scanTask = Task { try await manager.startScanning() } @@ -350,7 +350,7 @@ struct ReliaBLEManagerTests { // Wait until the scan waiter is parked on the transient state. _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = try await scanTask.value // The radio reaching `.poweredOn` resolved the waiter and started the scan. @@ -362,13 +362,13 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + 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 } - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() await #expect(throws: PeripheralError.bluetoothPoweredOff) { try await scanTask.value } @@ -376,7 +376,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) #expect(await manager.bluetooth.testIsScanning() == false) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -384,7 +384,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let scanTask = Task { try await manager.startScanning() } @@ -397,7 +397,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) #expect(await manager.bluetooth.testIsScanning() == false) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -405,7 +405,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let first = Task { try await manager.startScanning(services: [CBUUID(string: "180D")]) } @@ -420,7 +420,7 @@ struct ReliaBLEManagerTests { // The newer request now owns the single-slot scan waiter. _ = await pollUntil(timeout: 2.0) { await manager.bluetooth.testPendingScanWaiterCount() == 1 } - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = try await second.value #expect(await Mock.waitForState("Scanning", on: manager)) @@ -431,7 +431,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let scanUUID = CBUUID(string: "180D") @@ -448,7 +448,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingScanWaiterCount() == 1) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = try await second.value #expect(await Mock.waitForState("Scanning", on: manager)) @@ -463,7 +463,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let scanUUID = CBUUID(string: "180D") @@ -480,7 +480,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingScanWaiterCount() == 1) // Power on: the waiter resolves and starts scanning with its filter. - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = try await scanTask.value // Must NOT throw #expect(await Mock.waitForState("Scanning", on: manager)) @@ -494,7 +494,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let scanTask = Task { try await manager.startScanning() } @@ -519,7 +519,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) #expect(await manager.bluetooth.testIsScanning() == false) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -625,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)) } @@ -753,14 +753,14 @@ struct ReliaBLEManagerTests { await manager.stopScanning() let handle = try #require(discovered).peripheral - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) await #expect(throws: PeripheralError.bluetoothPoweredOff) { try await handle.connect() } - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -780,7 +780,7 @@ struct ReliaBLEManagerTests { // 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. - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let connectTask = Task { try await handle.connect() } @@ -788,7 +788,7 @@ struct ReliaBLEManagerTests { await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 } - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() try await connectTask.value // The parked radio wait was resolved; no continuation is left behind. @@ -808,7 +808,7 @@ struct ReliaBLEManagerTests { await manager.stopScanning() let handle = try #require(discovered).peripheral - CBMCentralManagerMock.simulateInitialState(.unknown) + await Mock.simulateInitialState(.unknown) _ = await Mock.waitForState("Unknown", on: manager) let connectTask = Task { try await handle.connect() } @@ -817,14 +817,14 @@ struct ReliaBLEManagerTests { } // The transient resolves to a terminal `.poweredOff`, failing the waiter with a typed error. - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() await #expect(throws: PeripheralError.bluetoothPoweredOff) { try await connectTask.value } #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -1054,7 +1054,7 @@ struct ReliaBLEManagerTests { @Test func connectionStateChangesEmitsConnectFailureSequence() async throws { // Pre-condition: no stale connection state from a preceding lifecycle test. - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } @@ -1076,7 +1076,7 @@ struct ReliaBLEManagerTests { // Force a clean disconnection on the spec to reset any lingering // `virtualConnections` / `isConnected` state left by a preceding test. - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) // Configure failure only after discovery — a failed connectionResult can @@ -1170,7 +1170,7 @@ struct ReliaBLEManagerTests { #expect(c2?.state == .connected) // Simulate an unexpected disconnect with the OS auto-reconnect option active. - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() // Tier 0: OS sends isReconnecting=true → library emits .system with nil metadata. let c3 = await changes.next() @@ -1224,7 +1224,7 @@ struct ReliaBLEManagerTests { await manager.stopScanning() // Force a clean disconnection to reset any lingering mock state. - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) try await handle.connect() @@ -1355,7 +1355,7 @@ struct ReliaBLEManagerTests { await manager.stopScanning() // Force a clean disconnection to reset any lingering mock state. - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) try await handle.connect() @@ -1752,7 +1752,7 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) try await handle.connect() @@ -1819,7 +1819,7 @@ struct ReliaBLEManagerTests { @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) + await Mock.simulateAuthorization(.notDetermined) let manager = await Mock.makeManager(restoreIdentifier: nil) try? await Task.sleep(nanoseconds: 200_000_000) @@ -1834,8 +1834,8 @@ struct ReliaBLEManagerTests { // 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() + await Mock.simulateAuthorization(.allowedAlways) + await Mock.simulatePowerOn() let manager = await Mock.makeManager(restoreIdentifier: restoreId) #expect(await manager.bluetooth.testRestoreIdentifier() == restoreId) @@ -1857,7 +1857,7 @@ struct ReliaBLEManagerTests { #expect(!(await noRestore.bluetooth.testDelegateIsRestoringShim())) } - @Test func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { + @Test @MainActor func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) @@ -1948,7 +1948,7 @@ struct ReliaBLEManagerTests { await Mock.tearDown(manager2) } - @Test func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { + @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 @@ -2006,7 +2006,7 @@ struct ReliaBLEManagerTests { await Mock.tearDown(manager2) } - @Test func willRestoreDoesNotRearmReconnectWithoutPersistedIntent() async throws { + @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 @@ -2074,7 +2074,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) } - @Test func invalidatePreservesHoldsProjectsReconnectingAndKeepsDeferredScan() async throws { + @Test @MainActor func invalidatePreservesHoldsProjectsReconnectingAndKeepsDeferredScan() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) @@ -2239,7 +2239,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() #expect(await Mock.waitForState("Powered Off", on: manager)) let scanUUID = CBUUID(string: "180D") @@ -2248,7 +2248,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) #expect(!(await manager.bluetooth.testIsScanning())) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() let becameScanning = await Mock.waitForState("Scanning", on: manager) if !becameScanning { #expect(await Mock.waitForState("Ready", on: manager)) @@ -2401,7 +2401,7 @@ struct ReliaBLEManagerTests { } @Test func authorizeCancellationDoesNotAffectOtherManager() async throws { - CBMCentralManagerMock.simulateAuthorization(.notDetermined) + await Mock.simulateAuthorization(.notDetermined) let managerA = await Mock.makeManager(tearDownPrevious: true) let managerB = await Mock.makeManager(tearDownPrevious: false) @@ -2423,9 +2423,9 @@ struct ReliaBLEManagerTests { // 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.simulateAuthorization(.allowedAlways) + await Mock.simulatePowerOff() + await Mock.simulatePowerOn() try await taskB.value #expect(await managerB.bluetooth.hasCentralManager) @@ -2631,7 +2631,7 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) await #expect(throws: PeripheralError.bluetoothPoweredOff) { @@ -2641,7 +2641,7 @@ struct ReliaBLEManagerTests { // The hold is registered BEFORE the radio wait, so a thrown connect still leaves durable demand. #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) - CBMCentralManagerMock.simulatePowerOn() + 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 @@ -2673,7 +2673,7 @@ struct ReliaBLEManagerTests { #expect(await manager.bluetooth.testHasManualConnectHold(for: handle.id)) // A radio reset invalidates live references (clears cbPeripherals) but preserves the hold. - CBMCentralManagerMock.simulateInitialState(.resetting) + 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)) @@ -2682,7 +2682,7 @@ struct ReliaBLEManagerTests { try await handle.disconnect() #expect(!(await manager.bluetooth.testHasManualConnectHold(for: handle.id))) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) } @@ -3055,7 +3055,7 @@ struct ReliaBLEManagerTests { // `.reconnecting(.system)`. Unlike `testInjectDisconnect` (which bypasses the mock and leaves // it connected), this drives the actual limbo the idle-torn guard protects: a physically // dropped link the OS is still trying to relink. - Mock.connectionTestSpec.simulateDisconnection() + await Mock.simulateDisconnection() #expect(await pollUntil(timeout: 3.0) { if case .reconnecting(.system, _, _) = await manager.currentConnectionStates[handle.id] { return true @@ -3388,9 +3388,9 @@ struct ReliaBLEManagerTests { let token = try await handle.acquireWorkLease() #expect(await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected }) - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) // Demand survived the radio cycle; the sweep re-links without re-acquiring. @@ -3419,7 +3419,7 @@ struct ReliaBLEManagerTests { try await handle.connect() _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) // The stream shows .disconnected(.bluetoothUnavailable) THEN .reconnecting(.library, nil, nil), @@ -3435,7 +3435,7 @@ struct ReliaBLEManagerTests { } #expect(handle.connectionState != nil) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) try? await handle.disconnect() } @@ -3485,7 +3485,7 @@ struct ReliaBLEManagerTests { // 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. - CBMCentralManagerMock.simulatePowerOff() + 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. @@ -3500,7 +3500,7 @@ struct ReliaBLEManagerTests { #expect(!issuedConnecting, "Ladder must not issue a connect against a dead radio") // Cleanup: restore the radio and tear down the lease. - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) await handle.releaseWorkLease(token) } @@ -3548,7 +3548,7 @@ struct ReliaBLEManagerTests { // Flip the radio off and let the delegate-driven invalidation settle deterministically, so the // ladder bookkeeping is stationary before we drive the step ourselves. - CBMCentralManagerMock.simulatePowerOff() + 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. @@ -3567,7 +3567,7 @@ struct ReliaBLEManagerTests { "Ladder step must not issue a connect against a dead radio") // Cleanup: restore the radio and tear down the lease. - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) await handle.releaseWorkLease(token) } @@ -3589,7 +3589,7 @@ struct ReliaBLEManagerTests { try await handle.connect(autoReconnect: false) _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) @@ -3597,7 +3597,7 @@ struct ReliaBLEManagerTests { #expect(!hasLibrary) #expect(events.contains { $0.state == .disconnected(reason: .bluetoothUnavailable) }) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) try? await handle.disconnect() } @@ -3617,9 +3617,9 @@ struct ReliaBLEManagerTests { try await handle.connect(autoReconnect: false) _ = await pollUntil(timeout: 3.0) { await manager.currentConnectionStates[handle.id] == .connected } - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) - CBMCentralManagerMock.simulatePowerOn() + await Mock.simulatePowerOn() _ = await Mock.waitForState("Ready", on: manager) // wantsReconnect false → reason .radioReturned does not satisfy the issue gate. @@ -3680,7 +3680,7 @@ struct ReliaBLEManagerTests { // 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. - Mock.connectionTestSpec.simulateDisconnection() + 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) @@ -3746,7 +3746,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) let scanUUID = CBUUID(string: "180D") - CBMCentralManagerMock.simulatePowerOff() + await Mock.simulatePowerOff() _ = await Mock.waitForState("Powered Off", on: manager) await manager.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) @@ -3755,14 +3755,14 @@ struct ReliaBLEManagerTests { await manager.bluetooth.testInvalidatePeripherals() #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) - CBMCentralManagerMock.simulatePowerOn() + 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 func restoredLinkWithoutHoldIdlesOut() async throws { + @Test @MainActor func restoredLinkWithoutHoldIdlesOut() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } @@ -3797,7 +3797,7 @@ struct ReliaBLEManagerTests { await Mock.tearDown(manager2) } - @Test func restoredManualHoldSurvivesRelaunch() async throws { + @Test @MainActor func restoredManualHoldSurvivesRelaunch() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } @@ -3831,7 +3831,7 @@ struct ReliaBLEManagerTests { await Mock.tearDown(manager2) } - @Test func restoredHoldWithAutoReconnectFalseSuppressesIdleButNotTier1() async throws { + @Test @MainActor func restoredHoldWithAutoReconnectFalseSuppressesIdleButNotTier1() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } @@ -3865,7 +3865,7 @@ struct ReliaBLEManagerTests { await Mock.tearDown(manager2) } - @Test func workLeasesDoNotSurviveRelaunch() async throws { + @Test @MainActor func workLeasesDoNotSurviveRelaunch() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()); Mock.clearStateRestoration() } @@ -4009,7 +4009,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() } @@ -4023,7 +4023,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 @@ -4041,10 +4041,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. @@ -4052,7 +4088,7 @@ 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, @@ -4090,14 +4126,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) @@ -4130,7 +4166,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. @@ -4239,14 +4275,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) From d0ac073bfa6e8b42b68fc3d09745a790d8787a1f Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 11:17:33 -0600 Subject: [PATCH 17/31] Make shared test-harness state thread-safe --- .../ReliaBLETests/ReliaBLEManagerTests.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 8694a98..8df61bb 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -3912,7 +3912,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 { From 7164d693e4582912cfd7602196fdecc5aff141ae Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 13:26:58 -0600 Subject: [PATCH 18/31] Added manual test plan --- ...iven-connection-lifecycle-hw-2026-08-09.md | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 docs/test-plans/work-driven-connection-lifecycle-hw-2026-08-09.md 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..7acd4e7 --- /dev/null +++ b/docs/test-plans/work-driven-connection-lifecycle-hw-2026-08-09.md @@ -0,0 +1,718 @@ +# 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 | +| **Scope** | Feature validation of Phase 2 lifecycle + regression of scan/connect/restore/background | +| **Audience** | Manual execution on real hardware (2–3 Apple devices) | + +--- + +## 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, …)` | `Reconnecting (attempt N)` — **N may be 0** when waiting for radio (`attempt == nil` displays as 0) | yellow | +| Ladder step | Same + optional `Next retry in: Xs` countdown | yellow | + +**Radio-await projection (important):** after Bluetooth power-off with Auto Reconnect demand, expect roughly: + +1. `Disconnected (bluetoothUnavailable)` (or similar reason text) +2. then `Reconnecting (attempt 0)` **without** a retry countdown (library waiting for radio, not ladder) + +When Auto Reconnect was **off**, expect settle at disconnected **without** a lasting “reconnecting” claim. + +--- + +## 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 Console logging + +Demo enables logging by default (`OSLogWriter`, subsystem `com.five3apps.relia-ble-demo`, category `BLE`). Optional: + +1. Mac: Console.app → select the device → filter `relia-ble` or `BLE`. +2. Xcode: Devices and Simulators → Open Console, or keep the debug session attached. +3. Settings → **Enable Logging** if you turned it off. + +Useful log themes (info): Manual connect, Manual disconnect, idle timer armed / idle disconnect (latter mainly if demand hits zero — rare via pure Manual path). + +### 3.5 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) | +| **N/A** | Not applicable to this hardware (e.g. Tier-0 on iOS < 17) | +| **PARTIAL** | Behavior close but timing/UI ambiguity; note details | + +Mark flaky RF issues (range, crowded 2.4 GHz) as **BLOCKED** or **PARTIAL** only after one retry closer / quieter environment. + +--- + +## 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. Prefer: visible failed/disconnected path and/or no stuck “Connecting” forever. *(Demo uses `try?` on connect, so a thrown `bluetoothPoweredOff` may not show an alert — watch for not stuck connecting and for later relink behavior in F1.4.)* | +| **Important** | Hold is registered **before** the radio wait. Intent survives the throw when Auto Reconnect is on. | + +#### 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 Range drop with Auto Reconnect ON (Tier-0 / system path) + +| | | +|--|--| +| **Devices** | C1 + P1 (iOS 17+) | +| **Steps** | 1. Connect Auto Reconnect **ON** → Connected. 2. Move P1 far away / put in another room / enclose in RF-hostile bag, **or** force-stop advertising on P1 then restart later. 3. Observe C1 caption during outage. 4. Bring P1 back in range / advertising. | +| **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. | + +#### F3.2 Range drop with Auto Reconnect OFF + +| | | +|--|--| +| **Devices** | C1 + P1 | +| **Steps** | 1. Connect Auto Reconnect **OFF** → Connected. 2. Take P1 out of range / stop advertising until C1 leaves Connected. 3. Restore P1 advertising/range. Wait ≥ 30–60 s. | +| **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 | +| **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). | + +#### F3.4 Library ladder visibility (optional, timing-sensitive) + +| | | +|--|--| +| **Devices** | C1 + P1; Settings with short `initialDelay` (e.g. 1.0) after relaunch | +| **Steps** | Provoke an unexpected disconnect where OS is **not** actively system-reconnecting (hard: OS often claims Tier-0). If you observe `Reconnecting (attempt N)` with **Next retry in: Xs**, note it. | +| **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. | + +#### 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 **Reconnecting (attempt 0)** (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). 3. Tap **Disconnect** (button may still show as active while reconnecting). 4. BT On. Wait 30 s. | +| **Expected** | Disconnect succeeds (no hard error UI required). After BT on, **no** auto-connect (hold cleared). | +| **FAIL if** | Link comes back after outage disconnect (hold not cleared). | + +**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 | Walk out of range until ladder/system gives up, then return | Either auto-recovers if demand remains, or stays failed after budget — document which | +| F10.6 | Cancel connect quickly (Connect then Disconnect within 1 s) | Settles disconnected; no stuck Connecting; no reconnect storm | + +**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. | +| **`Reconnecting (attempt 0)`** | Means radio-await (`attempt == nil`), not ladder step 0. | +| **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 | F3 | 30–45 min | C1, P1 | +| 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: +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** + +1. Walk away / stop advertising / reboot peripheral → return → Connected without Connect tap. + +**Prove BT power cycle** + +1. Connected + Auto Reconnect ON → BT off → reconnecting/disconnected → BT on → Connected. + +**Prove restore** + +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. + +--- + +## 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 From b7e13fa6984e1ab6633a836c5a4e3207fdb2a3c6 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 14:05:41 -0600 Subject: [PATCH 19/31] Cover untrusted-Tier-0, notFound surfacing, and ladder radio gates --- .../ReliaBLETests/ReliaBLEManagerTests.swift | 458 ++++++++++++++++++ 1 file changed, 458 insertions(+) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 8df61bb..0b58006 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -3572,6 +3572,464 @@ struct ReliaBLEManagerTests { await handle.releaseWorkLease(token) } + // 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 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) + #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") + } + // 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") + + try? await handle.disconnect() + } + + // 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(()) } + + 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 manager.bluetooth.shutdown() + await manager.bluetooth.testInvokeLadderStep(for: handle.id) + + // 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) + } + + /// 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() + + await Mock.simulateInitialState(mockState) + _ = await Mock.waitForState(stateDescription, on: manager) + + await manager.bluetooth.testInvokeLadderStep(for: handle.id) + + #expect(await pollUntil(timeout: 3.0) { + 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. + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } + + @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) + } + + // 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(()) } + + 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.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + await manager.bluetooth.testInvokeLadderStep(for: handle.id) + + // 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) + + await Mock.simulateInitialState(.poweredOn) + _ = 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 handle = try #require(snap).peripheral + await manager.stopScanning() + + await Mock.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + // 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.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } + + // 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) + + // A ghost id never discovered has no live `CBPeripheral` while the radio is powered on. + let ghostId = Mock.connectionTestPeripheralID + ".ghost" + await manager.bluetooth.testInvokeLadderStep(for: ghostId) + + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[ghostId] == .failed(reason: .notFound) + }, "Ladder step with no live peripheral must fail via .notFound") + #expect(await manager.currentConnectionStates[ghostId] != .connecting) + } + + // 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() + } + + 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) + + 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.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.simulateInitialState(.unsupported) + await #expect(throws: PeripheralError.bluetoothUnsupported) { + try await connectTask.value + } + + #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 handle = try #require(snap).peripheral + await manager.stopScanning() + + 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.simulateInitialState(.unauthorized) + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await connectTask.value + } + + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } + + // 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) + + 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.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 + } + + 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(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + await Mock.simulateInitialState(.poweredOn) + _ = await Mock.waitForState("Ready", on: manager) + } + + // 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) + + 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.simulateInitialState(.unknown) + _ = await Mock.waitForState("Unknown", on: manager) + + let leaseTask = Task { try await handle.acquireWorkLease() } + _ = await pollUntil(timeout: 2.0) { + await manager.bluetooth.testPendingPoweredOnWaiterCount() == 1 + } + + 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 + } + + #expect(await manager.bluetooth.testPendingPoweredOnWaiterCount() == 0) + + 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 + } + #expect(await manager.bluetooth.testPendingScanWaiterCount() == 0) + + await Mock.simulateInitialState(.poweredOn) + } + + // 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) + + await Mock.simulateInitialState(.unauthorized) + _ = await Mock.waitForState("Unauthorized", on: manager) + + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await manager.startScanning() + } + #expect(await manager.bluetooth.testIsScanning() == false) + + await Mock.simulateInitialState(.poweredOn) + _ = 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) + + await manager.bluetooth.shutdown() + + await #expect(throws: PeripheralError.bluetoothUnavailable) { + try await manager.startScanning() + } + + await Mock.simulateInitialState(.poweredOn) + } + @Test func radioDropWithoutWantsReconnectShowsNoReconnecting() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } From ea2f80fe9f1add9736eca780bad41c165589f83b Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 14:28:50 -0600 Subject: [PATCH 20/31] Make the Tier-0 limbo test deterministic --- Tests/ReliaBLETests/ReliaBLEManagerTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 0b58006..6711c21 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -3054,7 +3054,12 @@ struct ReliaBLEManagerTests { // the live `CBPeripheral` is no longer connected and the cached state moves to // `.reconnecting(.system)`. Unlike `testInjectDisconnect` (which bypasses the mock and leaves // it connected), this drives the actual limbo the idle-torn guard protects: a physically - // dropped link the OS is still trying to relink. + // dropped link the OS is still trying to relink a now-unwanted link. Force the mock's relink + // attempt to fail so the peripheral is deterministically STUCK in the non-connected limbo for the + // assertions below, instead of racing the mock's auto-reconnect (which on a slower CI box re-links + // it back to `.connected` before we can observe the limbo). A failed connection request leaves the + // mock peripheral `.disconnected` (see `CBMPeripheralMock.connect`), never back to `.connected`. + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) await Mock.simulateDisconnection() #expect(await pollUntil(timeout: 3.0) { if case .reconnecting(.system, _, _) = await manager.currentConnectionStates[handle.id] { From 4e8e37ec9fed0e13aecda8408623567c95f2b463 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 16:34:43 -0600 Subject: [PATCH 21/31] Replace the racy Tier-0 limbo test with an on-device check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreBluetoothMock always resolves a Tier-0 reconnect attempt — relinking on success, or reporting didFailToConnect on failure — so the window in which the cached state is .reconnecting(.system) cannot be held open. The end-to-end test raced that resolution and failed intermittently on slower CI machines in both directions: first the relink won (no limbo observed), then, once the relink was forced to fail, didFailToConnect won (state settled .failed, so no cancel). The production guard is unaffected and still pinned deterministically: idleTeardownDuringCachedSystemReconnectCancels covers the cached arm and idleTeardownDuringLiveConnectingCancels the live .connecting arm — both mutation-verified. Record the real-radio behaviour as on-device check F3.4b. Co-Authored-By: Claude Opus 5 --- .../ReliaBLETests/ReliaBLEManagerTests.swift | 83 +++---------------- ...iven-connection-lifecycle-hw-2026-08-09.md | 9 ++ 2 files changed, 19 insertions(+), 73 deletions(-) diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 6711c21..8506b50 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -3023,79 +3023,16 @@ struct ReliaBLEManagerTests { "Manual disconnect of a non-connected cached Tier-0 limbo must not mark the disconnect intentional") } - // Defect 1: idle teardown while a Tier-0 / system reconnect is in progress (.reconnecting(.system)) - // must settle AND cancel, so the OS cannot relink a link nobody wants (FR-1.2 / D-tier). - @Test func idleTeardownDuringSystemReconnectCancels() 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) - - 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() - - // A work lease links the peripheral with the Tier-0 (auto-reconnect) option active. - let token = try await handle.acquireWorkLease() - #expect(await pollUntil(timeout: 3.0) { - await manager.currentConnectionStates[handle.id] == .connected - }) - - // OS-delivered Tier-0 reconnect: a REAL drop of the (auto-reconnect) connected peripheral, so - // the live `CBPeripheral` is no longer connected and the cached state moves to - // `.reconnecting(.system)`. Unlike `testInjectDisconnect` (which bypasses the mock and leaves - // it connected), this drives the actual limbo the idle-torn guard protects: a physically - // dropped link the OS is still trying to relink a now-unwanted link. Force the mock's relink - // attempt to fail so the peripheral is deterministically STUCK in the non-connected limbo for the - // assertions below, instead of racing the mock's auto-reconnect (which on a slower CI box re-links - // it back to `.connected` before we can observe the limbo). A failed connection request leaves the - // mock peripheral `.disconnected` (see `CBMPeripheralMock.connect`), never back to `.connected`. - Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) - await Mock.simulateDisconnection() - #expect(await pollUntil(timeout: 3.0) { - if case .reconnecting(.system, _, _) = await manager.currentConnectionStates[handle.id] { - return true - } - return false - }) - // Confirm we are genuinely in the non-connected limbo, not still `.connected` in the mock. - #expect(await manager.bluetooth.testContainsCBPeripheral(handle.id)) - #expect((await manager.bluetooth.testCBPeripheralState(for: handle.id)) != .connected, - "Scenario must drive the non-connected Tier-0 limbo") - - // Demand drops mid-system-reconnect: idle teardown must settle AND issue exactly one cancel to - // stop the OS's pending reconnect work. The mock cannot complete the OS-reconnect on its own, so - // we pin the cancel CALL itself rather than a downstream side effect the mock cannot produce — a - // "no later .connected" assertion would pass trivially whether or not the cancel fires. - 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 the Tier-0 reconnect") - #expect(await manager.bluetooth.testContainsIntentionalDisconnect(handle.id) == false, - "Idle teardown of a non-connected Tier-0 limbo must not mark the disconnect intentional") - - // No later .connected may arrive from the suppressed Tier-0 reconnect. First flush the events - // already observed (the buffer includes the initial .connecting/.connected from lease acquisition - // and the .reconnecting(.system) above), THEN drain a fresh window and assert nothing links again. - _ = await drainConnectionStateChanges(from: changes, withinNanoseconds: 400_000_000) - 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)) - } - + // 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(()) } 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 index 7acd4e7..8133bff 100644 --- 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 @@ -346,6 +346,15 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i | **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. | +#### F3.4b Idle teardown *during* an OS reconnect attempt (on-device only) + +| | | +|--|--| +| **Devices** | C1 + P1; short idle interval (see §4.3) | +| **Steps** | 1. Get to Connected with Auto Reconnect **ON**. 2. Drop the link so the OS starts a Tier-0 reconnect (take P1 out of range briefly) — caption shows system reconnecting. 3. While that reconnect is still in flight, remove all demand: **Disconnect** (clearing the manual hold) so nothing wants the link. 4. Bring P1 back in range and wait ≥ 60 s. | +| **Expected** | The link is torn down and does **not** come back: idle teardown cancels 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.** | + #### F3.5 Quiet after clean disconnect (regression of arming) | | | From 58bda406213832fd9fad546c27686be8260e9058 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 17:14:01 -0600 Subject: [PATCH 22/31] Updated manual test plan to include faraday box --- ...iven-connection-lifecycle-hw-2026-08-09.md | 133 ++++++++++++++---- 1 file changed, 102 insertions(+), 31 deletions(-) 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 index 8133bff..d1d20f1 100644 --- 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 @@ -7,9 +7,9 @@ | **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 | +| **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) | +| **Audience** | Manual execution on real hardware (2–3 Apple devices) + Faraday box for RF drops | --- @@ -111,27 +111,84 @@ Optional swaps: use iPad as P1 or C1 if convenient. Prefer two physical centrals | 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 Console logging +### 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 device → filter `relia-ble` or `BLE`. -2. Xcode: Devices and Simulators → Open Console, or keep the debug session attached. +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 (info): Manual connect, Manual disconnect, idle timer armed / idle disconnect (latter mainly if demand hits zero — rare via pure Manual path). -### 3.5 Pass / fail conventions +### 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) | +| **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 | -Mark flaky RF issues (range, crowded 2.4 GHz) as **BLOCKED** or **PARTIAL** only after one retry closer / quieter environment. +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. --- @@ -311,21 +368,22 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i **Goal:** Unexpected drops recover only when Auto Reconnect demand wants them; system vs library captions make sense. -#### F3.1 Range drop with Auto Reconnect ON (Tier-0 / system path) +#### F3.1 RF drop with Auto Reconnect ON (Tier-0 / system path) — **Faraday** | | | |--|--| -| **Devices** | C1 + P1 (iOS 17+) | -| **Steps** | 1. Connect Auto Reconnect **ON** → Connected. 2. Move P1 far away / put in another room / enclose in RF-hostile bag, **or** force-stop advertising on P1 then restart later. 3. Observe C1 caption during outage. 4. Bring P1 back in range / advertising. | +| **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 Range drop with Auto Reconnect OFF +#### F3.2 RF drop with Auto Reconnect OFF — **Faraday** | | | |--|--| -| **Devices** | C1 + P1 | -| **Steps** | 1. Connect Auto Reconnect **OFF** → Connected. 2. Take P1 out of range / stop advertising until C1 leaves Connected. 3. Restore P1 advertising/range. Wait ≥ 30–60 s. | +| **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. | @@ -333,27 +391,28 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i | | | |--|--| -| **Devices** | C1 + P1 | +| **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) +#### F3.4 Library ladder visibility (optional, timing-sensitive) — **Faraday** | | | |--|--| -| **Devices** | C1 + P1; Settings with short `initialDelay` (e.g. 1.0) after relaunch | -| **Steps** | Provoke an unexpected disconnect where OS is **not** actively system-reconnecting (hard: OS often claims Tier-0). If you observe `Reconnecting (attempt N)` with **Next retry in: Xs**, note it. | +| **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. | +| **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 Idle teardown *during* an OS reconnect attempt (on-device only) +#### F3.4b Cancel / clear demand *during* an OS reconnect attempt (on-device only) — **Faraday** | | | |--|--| -| **Devices** | C1 + P1; short idle interval (see §4.3) | -| **Steps** | 1. Get to Connected with Auto Reconnect **ON**. 2. Drop the link so the OS starts a Tier-0 reconnect (take P1 out of range briefly) — caption shows system reconnecting. 3. While that reconnect is still in flight, remove all demand: **Disconnect** (clearing the manual hold) so nothing wants the link. 4. Bring P1 back in range and wait ≥ 60 s. | -| **Expected** | The link is torn down and does **not** come back: idle teardown cancels 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.** | +| **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) @@ -587,8 +646,9 @@ Run only if time remains; each is optional. | 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 | Walk out of range until ladder/system gives up, then return | Either auto-recovers if demand remains, or stays failed after budget — document which | +| 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:** ___ @@ -616,7 +676,7 @@ Run only if time remains; each is optional. | Setup + baseline | §4 | 15 min | All | | Radio gating | F1 | 25 min | C1, P1 | | Manual hold / idle suppress | F2 | 20 min | C1, P1 | -| Reconnect | F3 | 30–45 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 | @@ -640,6 +700,7 @@ 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: @@ -699,15 +760,21 @@ Overall: PASS / FAIL / PASS WITH NOTES 1. Disconnect → wait → must not reconnect. -**Prove Auto 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. Walk away / stop advertising / reboot peripheral → return → Connected without Connect tap. +1. Connect with toggle OFF → seal P1 → unseal → must **not** auto-connect within 30–60 s. -**Prove BT power cycle** +**Prove BT power cycle** (no box) -1. Connected + Auto Reconnect ON → BT off → reconnecting/disconnected → BT on → Connected. +1. Connected + Auto Reconnect ON → BT off on **C1** → reconnecting/disconnected → BT on → Connected. -**Prove restore** +**Prove restore** (no box) 1. Connected + Auto Reconnect ON → force quit → relaunch → Connected without Connect tap. @@ -715,6 +782,10 @@ Overall: PASS / FAIL / PASS WITH NOTES 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 From b78175042e476c5a857631a992f59a01350feb4a Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 17:19:36 -0600 Subject: [PATCH 23/31] Updated PRD based on recent work and decisions --- PRD.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/PRD.md b/PRD.md index c2171b4..5e5f894 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,11 +72,11 @@ 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.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. @@ -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. @@ -177,7 +178,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - 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.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 @@ -259,10 +260,15 @@ 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.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. -- ✅ 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.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—is persisted and rehydrated during state restoration, so a restored 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: From db329c57beefd6997ddfab41148481984a92129c Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 9 Aug 2026 17:46:05 -0600 Subject: [PATCH 24/31] Re-organized test-only code in BluetoothActor --- Sources/ReliaBLE/BluetoothActor.swift | 156 +++++++++++++------------- 1 file changed, 79 insertions(+), 77 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index e847b4a..ccd0c27 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -236,10 +236,6 @@ actor BluetoothActor { /// its own when it resumes. private var scanWaiter: ScanWaiter? - /// 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]? - /// 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` @@ -292,11 +288,6 @@ actor BluetoothActor { private var intentionalDisconnects: Set = [] private var reconnectAttempts: [String: Int] = [:] - /// 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] = [:] - /// 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] = [:] @@ -350,63 +341,6 @@ actor BluetoothActor { idleTaskRegistry.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() - 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() - pendingRestoredScanServices = nil - } - // MARK: - Event Streams /// Returns a fresh `AsyncStream` of Bluetooth state changes for a single subscriber. @@ -1851,16 +1785,6 @@ actor BluetoothActor { issueCancel(cbPeripheral) } - /// 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 - } - /// The single write path for per-peripheral connection state. /// /// Records the state, mirrors it onto the interned handle so ``Peripheral/connectionState`` stays in step, and @@ -2166,6 +2090,84 @@ actor BluetoothActor { 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() + 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 @@ -2395,7 +2397,7 @@ actor BluetoothActor { UserDefaults.standard.removeObject(forKey: key) } - } +} // MARK: - BluetoothDelegateShim From 82d9a3dcb12fcca6c48eb4198908e216d685b1b0 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Mon, 10 Aug 2026 18:51:16 -0600 Subject: [PATCH 25/31] Skip terminal rewrite on radio invalidate; surface connect failures Do not re-emit bluetoothUnavailable for peripherals already disconnected or failed when the radio dies, so stream UIs keep a clean intentional disconnect. Demo now shows connect/disconnect throws instead of try?, and manual connect logs a warn when the radio gate or link issue fails. --- .../ReliaBLE Demo/Central/CentralView.swift | 38 ++++++++++++- Sources/ReliaBLE/BluetoothActor.swift | 21 +++++-- Sources/ReliaBLE/Models/Peripheral.swift | 28 +++++++--- .../ReliaBLETests/ReliaBLEManagerTests.swift | 56 +++++++++++++++++-- ...iven-connection-lifecycle-hw-2026-08-09.md | 8 ++- 5 files changed, 128 insertions(+), 23 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index 0248bed..c5f906c 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -258,6 +258,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] @@ -284,12 +287,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") @@ -307,6 +334,13 @@ private struct DeviceDetailView: View { } } .padding() + .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/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index ccd0c27..97d62ef 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -1360,9 +1360,18 @@ actor BluetoothActor { // "this is over": let trackedIds = Array(connectionStates.keys) - // (1) Every tracked id emits a terminal `.disconnected` — the link really is dead. + // (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 { - setConnectionState(.disconnected(reason: .bluetoothUnavailable), for: id) + switch connectionStates[id] { + case .disconnected, .failed: + continue + default: + setConnectionState(.disconnected(reason: .bluetoothUnavailable), for: id) + } } // (2) Drop live references and cancel every in-flight timer/ladder. `activeLeases` and @@ -1382,6 +1391,8 @@ actor BluetoothActor { 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 { registry.applyConnectionState(id: id, state: nil) } @@ -1390,7 +1401,8 @@ actor BluetoothActor { // (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. + // 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) setConnectionState(.reconnecting(source: .library, attempt: nil, nextRetryAt: nil), for: id) @@ -1398,7 +1410,8 @@ actor BluetoothActor { // (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. + // 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 { syncReconnectIntent(id: id) } diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 5f87388..d834b04 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -177,15 +177,27 @@ public final class Peripheral: Sendable, Identifiable, Hashable { // still leaves durable demand behind — the throw is informational, not destructive (D-hold). await manager.bluetooth.applyManualConnectHold(id: id, reconnectDesired: autoReconnect) - // Await a usable radio (cancellable), then ensure the link. - let waiterID = UUID() - try await withTaskCancellationHandler { - try await manager.bluetooth.waitUntilPoweredOn(waiterID: waiterID) - } onCancel: { - Task { await manager.bluetooth.cancelPoweredOnContinuation(waiterID) } - } + // 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: id, reason: .explicitConnect) + try await manager.bluetooth.reevaluateLink(id: id, 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. diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 8506b50..462fcf4 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -2178,10 +2178,10 @@ struct ReliaBLEManagerTests { } @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. + // 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(()) } @@ -2232,6 +2232,50 @@ struct ReliaBLEManagerTests { #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() + + try await handle.connect() + #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 drain 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 + await manager.bluetooth.testInvalidatePeripherals() + + let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 1_500_000_000) + let forId = events.filter { $0.peripheralId == id } + #expect(!forId.contains { $0.state == .disconnected(reason: .bluetoothUnavailable) }) + #expect(!forId.contains { if case .reconnecting = $0.state { return true }; return false }) + // No demand after intentional disconnect → untracked (handle nil); stream stays quiet. + #expect(handle.connectionState == nil) + } + @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 @@ -3614,7 +3658,7 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - await Mock.simulateInitialState(mockState) + Mock.simulateInitialState(mockState) _ = await Mock.waitForState(stateDescription, on: manager) await manager.bluetooth.testInvokeLadderStep(for: handle.id) @@ -3625,7 +3669,7 @@ struct ReliaBLEManagerTests { #expect(await manager.currentConnectionStates[handle.id] != .connecting) // Restore the baseline so the next test starts from a known-good radio. - await Mock.simulateInitialState(.poweredOn) + Mock.simulateInitialState(.poweredOn) _ = await Mock.waitForState("Ready", on: manager) } 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 index d1d20f1..67868b6 100644 --- 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 @@ -70,12 +70,14 @@ From Demo `ConnectionState.description`: | `.reconnecting(.library, attempt, …)` | `Reconnecting (attempt N)` — **N may be 0** when waiting for radio (`attempt == nil` displays as 0) | yellow | | Ladder step | Same + optional `Next retry in: Xs` countdown | yellow | -**Radio-await projection (important):** after Bluetooth power-off with Auto Reconnect demand, expect roughly: +**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 `Reconnecting (attempt 0)` **without** a retry countdown (library waiting for radio, not ladder) -When Auto Reconnect was **off**, expect settle at disconnected **without** a lasting “reconnecting” claim. +When Auto Reconnect was **off** and the device was still linked, expect settle at `Disconnected (bluetoothUnavailable)` **without** a lasting “reconnecting” 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. --- @@ -263,7 +265,7 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i |--|--| | **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. Prefer: visible failed/disconnected path and/or no stuck “Connecting” forever. *(Demo uses `try?` on connect, so a thrown `bluetoothPoweredOff` may not show an alert — watch for not stuck connecting and for later relink behavior in F1.4.)* | +| **Expected** | Connection does **not** succeed. Prefer: visible failed/disconnected path and/or no stuck “Connecting” forever. Demo device detail should show a red error caption for the thrown `bluetoothPoweredOff` (or similar). Watch for not stuck connecting and for later relink behavior in F1.4. | | **Important** | Hold is registered **before** the radio wait. Intent survives the throw when Auto Reconnect is on. | #### F1.4 Auto Reconnect ON: relink when radio returns From eed3de4fd52fa3c0ccc214018c0669a5f8d5a71a Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Mon, 10 Aug 2026 23:20:47 -0600 Subject: [PATCH 26/31] Harden flaky ladder and invalidate unit tests for CI Shorten the already-terminal invalidate observation window and clean up mock connection state so CoreBluetoothMock is less stressed between tests. Restore sticky simulateInitialState pollution with a real power cycle, and assert the missing-peripheral ladder gate from the same actor turn so a later poweredOn idle sweep cannot rewrite .failed. --- Sources/ReliaBLE/BluetoothActor.swift | 8 +++ .../ReliaBLETests/ReliaBLEManagerTests.swift | 70 +++++++++++++++---- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 97d62ef..d47c17a 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -2345,12 +2345,20 @@ actor BluetoothActor { /// 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. diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 462fcf4..1240573 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -2251,7 +2251,8 @@ struct ReliaBLEManagerTests { let handle = try #require(snap).peripheral await manager.stopScanning() - try await handle.connect() + // 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() @@ -2260,20 +2261,40 @@ struct ReliaBLEManagerTests { return false }) - // Subscribe only after the clean disconnect so drain sees invalidate-era events alone. + // 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() - let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 1_500_000_000) - let forId = events.filter { $0.peripheralId == id } - #expect(!forId.contains { $0.state == .disconnected(reason: .bluetoothUnavailable) }) - #expect(!forId.contains { if case .reconnecting = $0.state { return true }; return false }) + // 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 { @@ -3669,7 +3690,11 @@ struct ReliaBLEManagerTests { #expect(await manager.currentConnectionStates[handle.id] != .connecting) // Restore the baseline so the next test starts from a known-good radio. - Mock.simulateInitialState(.poweredOn) + // 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) } @@ -3710,7 +3735,11 @@ struct ReliaBLEManagerTests { #expect(isLibrary, "Ladder step on a transient radio must defer, leaving .reconnecting(.library)") #expect(await manager.currentConnectionStates[handle.id] != .connecting) - await Mock.simulateInitialState(.poweredOn) + // `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) } @@ -3744,7 +3773,9 @@ struct ReliaBLEManagerTests { await manager.bluetooth.releaseWorkLease(token) - await Mock.simulateInitialState(.poweredOn) + await Mock.simulatePowerOff() + await Mock.simulatePowerOn() + await manager.bluetooth.updateState() _ = await Mock.waitForState("Ready", on: manager) } @@ -3757,14 +3788,25 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) + // 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)) + // A ghost id never discovered has no live `CBPeripheral` while the radio is powered on. let ghostId = Mock.connectionTestPeripheralID + ".ghost" - await manager.bluetooth.testInvokeLadderStep(for: ghostId) + let after = await manager.bluetooth.testInvokeLadderStepReturningState(for: ghostId) - #expect(await pollUntil(timeout: 3.0) { - await manager.currentConnectionStates[ghostId] == .failed(reason: .notFound) - }, "Ladder step with no live peripheral must fail via .notFound") - #expect(await manager.currentConnectionStates[ghostId] != .connecting) + // 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) } // Radio regression surfaced through `connect()`: with the radio `.unsupported`, the connect waits for From 708587500a2a74524a84636578a7525c3cbc665e Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 11 Aug 2026 15:48:18 -0600 Subject: [PATCH 27/31] Improved log messages and added note to PRD --- PRD.md | 2 +- Sources/ReliaBLE/BluetoothActor.swift | 77 ++++++++++++++++--- ...iven-connection-lifecycle-hw-2026-08-09.md | 14 +++- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/PRD.md b/PRD.md index 5e5f894..1f7a81d 100644 --- a/PRD.md +++ b/PRD.md @@ -175,7 +175,7 @@ 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). - 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. 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. diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index d47c17a..4a1cd2e 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -1369,7 +1369,17 @@ actor BluetoothActor { switch connectionStates[id] { case .disconnected, .failed: continue - default: + 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) } } @@ -1405,6 +1415,10 @@ actor BluetoothActor { // 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) } @@ -1413,6 +1427,10 @@ actor BluetoothActor { // 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) } @@ -1422,7 +1440,10 @@ actor BluetoothActor { // **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 @@ -1618,6 +1639,12 @@ actor BluetoothActor { } 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)) } } @@ -1957,20 +1984,35 @@ actor BluetoothActor { // 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 { return } + 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 { + 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) { @@ -2000,6 +2042,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 @@ -2054,6 +2101,10 @@ actor BluetoothActor { 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) @@ -2069,17 +2120,25 @@ actor BluetoothActor { // 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 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) } } @@ -2088,7 +2147,7 @@ actor BluetoothActor { /// 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)], "Reconnect attempt gated: \(error)") + log?.warn(tags: [.peripheral(id), .category(.connection)], "Library reconnect attempt gated: \(error)") setConnectionState(.failed(reason: error), for: id) } 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 index 67868b6..a76b41c 100644 --- 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 @@ -178,7 +178,19 @@ Demo enables logging by default (`OSLogWriter`, subsystem `com.five3apps.relia-b 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 (info): Manual connect, Manual disconnect, idle timer armed / idle disconnect (latter mainly if demand hits zero — rare via pure Manual path). +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 | +| System auto-reconnect in progress | info | Tier-0 OS reconnect (iOS 17+) after unexpected drop | +| 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 From 17ccb941d2426c0e798ee2f763245b536883ea43 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 11 Aug 2026 16:26:06 -0600 Subject: [PATCH 28/31] Fix connection state projection for radio-outage hold and disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish AwaitingRadio reconnecting when a reconnect-wanting manual hold is registered while Bluetooth is off, and settle immediately to clean disconnected when disconnect clears demand with no live peripheral. Demo shows "Waiting for Bluetooth…" for nil-attempt radio await so stream-only UIs stay honest during F1.3/F4.3-style outages. --- .../ReliaBLE Demo/Central/CentralView.swift | 9 +- Sources/ReliaBLE/BluetoothActor.swift | 48 ++++++++- Sources/ReliaBLE/Models/ConnectionState.swift | 7 +- Sources/ReliaBLE/Models/Peripheral.swift | 8 +- .../ReliaBLETests/ReliaBLEManagerTests.swift | 97 ++++++++++++++++++- ...iven-connection-lifecycle-hw-2026-08-09.md | 24 +++-- 6 files changed, 172 insertions(+), 21 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index c5f906c..86e7433 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" diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 4a1cd2e..196eb7c 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -1652,6 +1652,14 @@ actor BluetoothActor { /// 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`). func applyManualConnectHold(id: String, reconnectDesired: Bool) { manualConnectHold[id] = ManualConnectHold(reconnectDesired: reconnectDesired) intentionalDisconnects.remove(id) @@ -1660,12 +1668,41 @@ actor BluetoothActor { idleTaskRegistry.cancel(id) log?.info(tags: [.peripheral(id), .category(.connection)], "Manual connect") syncReconnectIntent(id: id, persistHold: true) + + guard reconnectDesired, shouldProjectAwaitingRadio else { return } + 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) + } + } + + /// 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`. + /// 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) { manualConnectHold.removeValue(forKey: id) syncReconnectIntent(id: id, persistHold: true) @@ -1678,7 +1715,14 @@ actor BluetoothActor { idleTaskRegistry.cancel(id) reconnectAttempts[id] = nil - guard let cbPeripheral = cbPeripherals[id] else { return } + 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) diff --git a/Sources/ReliaBLE/Models/ConnectionState.swift b/Sources/ReliaBLE/Models/ConnectionState.swift index c58c70d..27063ea 100644 --- a/Sources/ReliaBLE/Models/ConnectionState.swift +++ b/Sources/ReliaBLE/Models/ConnectionState.swift @@ -35,9 +35,10 @@ public enum ConnectionState: Sendable, Equatable, Hashable { /// A reconnection is in progress. /// /// For ``ReconnectSource/library``, a `nil` `attempt` / `nextRetryAt` means the link is - /// **waiting for the radio** (projected the moment a demanded link's radio drops, 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 + /// **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. diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index d834b04..119125c 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -174,7 +174,9 @@ public final class Peripheral: Sendable, Identifiable, Hashable { await manager.bluetooth.ensureCentralManager() // 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 (D-hold). + // 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. await manager.bluetooth.applyManualConnectHold(id: id, reconnectDesired: autoReconnect) // Await a usable radio (cancellable), then ensure the link. Failures after the hold is @@ -204,7 +206,9 @@ public final class Peripheral: Sendable, Identifiable, Hashable { /// /// 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`. + /// 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 } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 1240573..e3280e2 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -2680,13 +2680,16 @@ struct ReliaBLEManagerTests { @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. + // 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, @@ -2698,6 +2701,9 @@ struct ReliaBLEManagerTests { 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() @@ -2705,6 +2711,14 @@ struct ReliaBLEManagerTests { // 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) @@ -2718,6 +2732,53 @@ struct ReliaBLEManagerTests { }) } + @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(()) } @@ -2735,20 +2796,50 @@ struct ReliaBLEManagerTests { 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. + // 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. + // 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 { 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 index a76b41c..b456410 100644 --- 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 @@ -67,15 +67,17 @@ From Demo `ConnectionState.description`: | `.disconnected(reason)` | `Disconnected (…)` | orange | | `.failed(reason)` | `Failed (…)` | red | | `.reconnecting(.system, …)` | `System reconnecting…` | yellow | -| `.reconnecting(.library, attempt, …)` | `Reconnecting (attempt N)` — **N may be 0** when waiting for radio (`attempt == nil` displays as 0) | 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 `Reconnecting (attempt 0)` **without** a retry countdown (library waiting for radio, not ladder) +2. then **`Waiting for Bluetooth…`** **without** a retry countdown (library waiting for radio, not ladder) -When Auto Reconnect was **off** and the device was still linked, expect settle at `Disconnected (bluetoothUnavailable)` **without** a lasting “reconnecting” claim. +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. @@ -277,8 +279,8 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i |--|--| | **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. Prefer: visible failed/disconnected path and/or no stuck “Connecting” forever. Demo device detail should show a red error caption for the thrown `bluetoothPoweredOff` (or similar). Watch for not stuck connecting and for later relink behavior in F1.4. | -| **Important** | Hold is registered **before** the radio wait. Intent survives the throw when Auto Reconnect is on. | +| **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 @@ -449,7 +451,7 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i |--|--| | **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 **Reconnecting (attempt 0)** (radio wait — no countdown). On: returns to **Connected** without user Connect. | +| **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). | @@ -467,9 +469,11 @@ For cases that wait multiple idle multiples (e.g. “still connected after 3× i | | | |--|--| | **Devices** | C1 + P1 | -| **Steps** | 1. Connect Auto Reconnect ON. 2. BT Off (demand/reconnecting projection). 3. Tap **Disconnect** (button may still show as active while reconnecting). 4. BT On. Wait 30 s. | -| **Expected** | Disconnect succeeds (no hard error UI required). After BT on, **no** auto-connect (hold cleared). | -| **FAIL if** | Link comes back after outage disconnect (hold not cleared). | +| **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:** ___ @@ -678,7 +682,7 @@ Run only if time remains; each is optional. | **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. | -| **`Reconnecting (attempt 0)`** | Means radio-await (`attempt == nil`), not ladder step 0. | +| **`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. | --- From e8a1d03228c5d871a8382f6b2498ed0f77a4789d Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 11 Aug 2026 17:24:31 -0600 Subject: [PATCH 29/31] Improved connection drop logging --- Sources/ReliaBLE/BluetoothActor.swift | 14 ++++++++++++++ ...rk-driven-connection-lifecycle-hw-2026-08-09.md | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 196eb7c..dd19177 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -1972,6 +1972,20 @@ actor BluetoothActor { taskRegistry.cancel(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 { 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 index b456410..c55cb3d 100644 --- 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 @@ -189,7 +189,8 @@ Useful log themes (category `connection`): | 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 | -| System auto-reconnect in progress | info | Tier-0 OS reconnect (iOS 17+) after unexpected drop | +| 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) | From bb7ed2a622a1fed44f92a2db5fbbeb3534bd0400 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 11 Aug 2026 20:27:34 -0600 Subject: [PATCH 30/31] Rehydrate durable holds after force-quit and stabilize identity Load persisted manual-connect holds on cold start even when CoreBluetooth restores no peripherals, key holds by CBPeripheral UUID for name drift, prefer advertisement local name over GAP device name, and merge Demo SwiftData devices by cbUUID so one radio is not listed twice. --- AGENTS.md | 5 +- .../ReliaBLE Demo/Central/CentralView.swift | 11 + .../Central/CentralViewModel.swift | 10 + .../Central/DeviceStoreActor.swift | 27 +- .../ReliaBLE Demo/Central/Models/Device.swift | 7 +- Sources/ReliaBLE/BluetoothActor.swift | 568 +++++++++++++++--- .../ReliaBLETests/ReliaBLEManagerTests.swift | 113 ++++ 7 files changed, 666 insertions(+), 75 deletions(-) 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 86e7433..3157afa 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -204,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) } @@ -341,6 +345,13 @@ 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. diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index df4b31a..f1f7a11 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -60,6 +60,16 @@ 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() } } 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/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index dd19177..aeafb18 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -134,8 +134,23 @@ fileprivate final class TaskRegistry: @unchecked Sendable { /// 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. @@ -292,6 +307,9 @@ actor BluetoothActor { /// 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 @@ -507,7 +525,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. @@ -979,9 +1000,9 @@ 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). - /// Reconnect intent is re-armed only for peripherals with a **persisted manual-connect hold** - /// (D-restore), 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. + /// 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 while preserving demand. /// @@ -993,18 +1014,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 - - // Manual-connect holds persisted across launches (D-restore). A value written by an older - // build (array-of-ids) does not decode as the new map; a decode failure is treated as "no - // persisted holds". Work leases are never persisted and never rehydrated (case 3). - let persistedHolds = persistedHoldMap() + var heldRestoredIds: [String] = [] for cbPeripheral in restoredPeripherals { // Restored peripherals arrive with no delegate; re-associate into actor-owned maps using the same @@ -1022,19 +1052,28 @@ 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 - // D-restore: a persisted manual-connect hold rehydrates, suppressing idle and re-arming - // reconnect intent for this link. Reconnect intent is now derived from demand, so there is - // no separate disconnected/connecting seeding of ``reconnectEnabled``. - let rehydratedHold = persistedHolds[resolvedId] - if let rehydratedHold { - manualConnectHold[resolvedId] = ManualConnectHold(reconnectDesired: rehydratedHold) + 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)], - "Rehydrated manual-connect hold (reconnectDesired=\(rehydratedHold))" + "Restored peripheral matched manual-connect hold (reconnectDesired=\(reconnectDesired))" ) } @@ -1058,14 +1097,10 @@ actor BluetoothActor { setConnectionState(connectionState, for: resolvedId) } - // Link-retention policy per D-restore: - // - Case 1 / case 4 (persisted hold present): **no** idle timer; ensure the link if it is - // not already establishing. A hold with `reconnectDesired: false` suppresses idle but - // arms neither tier and is not re-issued (``reevaluateLink``'s issue gate refuses - // `radioReturned` without `wantsReconnect`). - // - Case 2 (no persisted hold): the idle timer starts — restored links the app never - // explicitly asked for still idle out (NFR-3.2). - if rehydratedHold != nil { + // 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 { @@ -1075,12 +1110,18 @@ actor BluetoothActor { } } } else if connectionState != nil { - // Only *linked* restored peripherals (connected/connecting) idle out (D-restore case 2). - // A disconnected restored peripheral has nothing to tear down and must stay untracked. + // 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) } @@ -1135,7 +1176,20 @@ 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) } @@ -1168,14 +1222,21 @@ actor BluetoothActor { /// 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`` - /// is the terminal outcome an app must observe (the lease or hold is retained, no scan, no retry - /// loop) — publish it via ``setConnectionState`` rather than dropping it. + /// 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) @@ -1215,15 +1276,24 @@ 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) @@ -1233,7 +1303,22 @@ actor BluetoothActor { // device demand is already waiting on. Narrowed to the terminal `.failed` state 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. - if demand(id: resolvedId) { + 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). `.reconnecting` @@ -1242,6 +1327,10 @@ actor BluetoothActor { case .connected, .connecting, .disconnecting, .reconnecting(_, _, _): break default: + log?.info( + tags: [.peripheral(resolvedId), .category(.connection)], + "Discovered while demanded — reissuing connect" + ) do { try reevaluateLink(id: resolvedId, reason: .discoveredWhileDemanded) } catch { @@ -1261,13 +1350,21 @@ 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). + /// + /// **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: `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 `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. /// @@ -1297,26 +1394,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 @@ -1462,7 +1583,11 @@ actor BluetoothActor { 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 } @@ -1473,7 +1598,63 @@ actor BluetoothActor { } } broadcast(discoveredPeripherals, to: peripheralsContinuations) - log?.debug("Refreshed \(retrieved.count) peripherals from CBCentralManager") + log?.debug("Refreshed \(retrieved.count)/\(identifiers.count) peripherals from CBCentralManager") + } + + /// `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 + } + + 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 @@ -1509,6 +1690,10 @@ actor BluetoothActor { /// 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 } guard let centralManager else { @@ -1521,6 +1706,12 @@ actor BluetoothActor { if #available(macOS 14.0, iOS 17.0, *) { 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) } @@ -1543,10 +1734,10 @@ actor BluetoothActor { /// /// Persistence is **hold-driven only**: `persistHold` is `true` only from the two manual-connect /// entry points (``applyManualConnectHold(id:reconnectDesired:)`` and - /// ``applyManualDisconnect(id:)``), never from work-lease or event-driven demand changes. The - /// persisted artifact is the hold map (`id → reconnectDesired`), not the reconnect-enabled set, - /// and is namespaced by ``restoreIdentifier``. Nothing is written when there is no restore - /// identifier (no relaunch path could consume it) — D-4 / D-restore. + /// ``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 { @@ -1566,25 +1757,229 @@ actor BluetoothActor { } } - /// Writes the persisted hold map (`id → reconnectDesired`) for the current ``restoreIdentifier``. + /// Loads every durable hold from disk into ``manualConnectHold`` once per process. /// - /// Only ``syncReconnectIntent`` (with `persistHold: true`) and the restore-time read-back 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 (D-1 event 13/14). + /// 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)" + ) + + let hold = oldEntry.value + manualConnectHold.removeValue(forKey: oldId) + + var moved = hold + moved.cbIdentifier = cbIdentifier + manualConnectHold[resolvedId] = moved + + // Transfer demand-adjacent bookkeeping so ladders / leases / terminal states do not stick + // under the obsolete id. + if let leases = activeLeases.removeValue(forKey: oldId) { + activeLeases[resolvedId, default: []].formUnion(leases) + } + if intentionalDisconnects.remove(oldId) != nil { + intentionalDisconnects.insert(resolvedId) + } + if let cb = cbPeripherals.removeValue(forKey: oldId) { + cbPeripherals[resolvedId] = cb + } + if let state = connectionStates.removeValue(forKey: oldId) { + // Publish under the new id so stream observers follow the rebind. + setConnectionState(state, for: resolvedId) + } + if let attempts = reconnectAttempts.removeValue(forKey: oldId) { + reconnectAttempts[resolvedId] = attempts + } + if let gen = reconnectGeneration.removeValue(forKey: oldId) { + reconnectGeneration[resolvedId] = gen + } + if let gen = idleGeneration.removeValue(forKey: oldId) { + idleGeneration[resolvedId] = gen + } + // Drop the obsolete discovery snapshot so consumers do not see two rows for one radio. + if let oldIdx = discoveredPeripherals.firstIndex(where: { $0.id == oldId }) { + discoveredPeripherals.remove(at: oldIdx) + } + taskRegistry.cancel(oldId) + idleTaskRegistry.cancel(oldId) + + // Drop reconnect-enabled under the old key; re-sync both ids (old clears, new arms). + reconnectEnabled.remove(oldId) + syncReconnectIntent(id: oldId) + syncReconnectIntent(id: resolvedId, persistHold: restoreIdentifier != nil) + return true + } + + /// 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 { return } - let holds = manualConnectHold.mapValues { $0.reconnectDesired } - UserDefaults.standard.set(holds, forKey: key) + 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 the persisted hold map (`id → reconnectDesired`) back at restore time. + /// Reads durable hold records from disk. /// - /// A value written by an older build (array-of-ids) does not decode as the new `[String: Bool]` - /// map — treat a decode failure as "no persisted holds" and move on. No migration shim (pre-release). + /// 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] { - guard let key = reconnectIntentDefaultsKey, - let stored = UserDefaults.standard.dictionary(forKey: key) as? [String: Bool] else { return [:] } - return stored + 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). @@ -1617,6 +2012,17 @@ actor BluetoothActor { } 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 } @@ -1661,12 +2067,20 @@ actor BluetoothActor { /// false (no radio-return re-issue) or when the radio is already `.poweredOn` (``reevaluateLink`` /// / ``issueConnect`` drive `.connecting`). func applyManualConnectHold(id: String, reconnectDesired: Bool) { - manualConnectHold[id] = ManualConnectHold(reconnectDesired: reconnectDesired) + 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) - log?.info(tags: [.peripheral(id), .category(.connection)], "Manual connect") + 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 } @@ -1707,7 +2121,10 @@ actor BluetoothActor { manualConnectHold.removeValue(forKey: id) syncReconnectIntent(id: id, persistHold: true) - log?.info(tags: [.peripheral(id), .category(.connection)], "Manual disconnect") + 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) @@ -2529,6 +2946,19 @@ actor BluetoothActor { 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. func testClearPersistedReconnectIntent() { guard let key = reconnectIntentDefaultsKey else { return } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index e3280e2..4f8dbba 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -4479,6 +4479,119 @@ struct ReliaBLEManagerTests { await manager2.bluetooth.testClearPersistedReconnectIntent() await Mock.tearDown(manager2) } + + /// 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() + } + + 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() + + 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") + + // Full process death simulation: tear down stack, reset mock links, **no** state-restoration fixture. + await Mock.tearDown(manager1, resetMockConnections: true) + Mock.clearStateRestoration() + + let manager2 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager2) + + // Disk → memory without willRestore. + #expect(await manager2.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID)) + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) + + // 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 + }) + + await manager2.stopScanning() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) + } + + /// 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 restoreId = "com.five3apps.relia-ble.tests.hold-id-drift" + 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 } + 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 + ) + + 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) + } } // MARK: - Logging Test Support From e594c068b2fed57a14d7d8e9811869d2a8ccb879 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sun, 16 Aug 2026 22:39:33 -0600 Subject: [PATCH 31/31] Fix identity upgrade stranding demand under an obsolete id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An advertisement that arrives without a local name resolves a peripheral to its cbIdentifier string; the next packet carrying one upgrades the app-facing id. The discovery row was reused, but every other per-id record stayed behind — including the live CBPeripheral, leaving two cbPeripherals keys pointing at one object. The reverse lookup that resolves delegate callbacks then picked arbitrarily, so didConnect could land under an id with no demand: the caller's stream stayed on .connecting while the idle timer tore the link back down seconds later. Identity resolution now migrates every per-id record onto the new id, gated on id drift rather than on a durable hold existing. Alongside that: - Re-drive scheduled work under the new id. Both task registries key their closures by the id captured at arm time, so a moved idle timer or ladder step has to be re-armed rather than only cancelled. - Release work leases by lease UUID. A token minted before a migration names the obsolete id, and a lookup that trusted it leaked demand. - Split the AwaitingRadio projection from a real ladder step. Only the latter reschedules; the former re-drives the link, and discovery now re-issues it once the live reference is bound. - Alias obsolete ids to their replacement so a Peripheral handle vended before an upgrade keeps working, and mirror connection state onto those handles so they cannot disagree with the id that replaced them. Aliases are dropped when a device claims the retired id again. - Harden id(for:) to resolve a duplicate binding in favour of the published id instead of letting dictionary order decide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RqvRXATrnLfBqRbkP7BffS --- PRD.md | 4 +- Sources/ReliaBLE/BluetoothActor.swift | 293 +++++++-- Sources/ReliaBLE/Models/Peripheral.swift | 7 +- .../ReliaBLETests/ReliaBLEManagerTests.swift | 564 ++++++++++++++++++ ...-driven-connection-lifecycle-2026-08-02.md | 27 +- 5 files changed, 844 insertions(+), 51 deletions(-) diff --git a/PRD.md b/PRD.md index 1f7a81d..0d6cecf 100644 --- a/PRD.md +++ b/PRD.md @@ -175,7 +175,7 @@ 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. 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). + - 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. 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. @@ -268,7 +268,7 @@ only where the gate must be honored. - ✅ 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—is persisted and rehydrated during state restoration, so a restored 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. + - 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 diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index aeafb18..2ac0fe6 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -316,6 +316,22 @@ actor BluetoothActor { /// 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 @@ -1300,9 +1316,9 @@ actor BluetoothActor { // 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. Narrowed to the terminal `.failed` state 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. + // 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 { @@ -1321,10 +1337,19 @@ actor BluetoothActor { 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). `.reconnecting` - // ids are already being driven (ladder or AwaitingRadio projection), so they are skipped. + // 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(_, _, _): + case .connected, .connecting, .disconnecting, + .reconnecting(source: .system, attempt: _, nextRetryAt: _): + break + case .reconnecting(source: .library, attempt: .some, nextRetryAt: _): break default: log?.info( @@ -1360,6 +1385,10 @@ actor BluetoothActor { /// 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 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. @@ -1454,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 { @@ -1461,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, @@ -1525,7 +1583,7 @@ actor BluetoothActor { // 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 { - registry.applyConnectionState(id: id, state: nil) + applyToHandles(id: id, state: nil) } connectionStates.removeAll() @@ -1682,6 +1740,21 @@ actor BluetoothActor { 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 @@ -1846,49 +1919,109 @@ actor BluetoothActor { "Hold identity drift: moving hold from id=\(oldId) → id=\(resolvedId) for cbUUID=\(oldUUID) (name-derived id changed; demand preserved)" ) - let hold = oldEntry.value - manualConnectHold.removeValue(forKey: oldId) + migrateIdentity(from: oldId, to: resolvedId, cbIdentifier: cbIdentifier) + return true + } - var moved = hold - moved.cbIdentifier = cbIdentifier - manualConnectHold[resolvedId] = moved + /// 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[resolvedId, default: []].formUnion(leases) + activeLeases[newId, default: []].formUnion(leases) } if intentionalDisconnects.remove(oldId) != nil { - intentionalDisconnects.insert(resolvedId) + intentionalDisconnects.insert(newId) } if let cb = cbPeripherals.removeValue(forKey: oldId) { - cbPeripherals[resolvedId] = cb - } - if let state = connectionStates.removeValue(forKey: oldId) { - // Publish under the new id so stream observers follow the rebind. - setConnectionState(state, for: resolvedId) + 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[resolvedId] = attempts + reconnectAttempts[newId] = attempts } if let gen = reconnectGeneration.removeValue(forKey: oldId) { - reconnectGeneration[resolvedId] = gen + reconnectGeneration[newId] = gen } if let gen = idleGeneration.removeValue(forKey: oldId) { - idleGeneration[resolvedId] = gen + idleGeneration[newId] = gen } - // Drop the obsolete discovery snapshot so consumers do not see two rows for one radio. + 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) - - // Drop reconnect-enabled under the old key; re-sync both ids (old clears, new arms). reconnectEnabled.remove(oldId) - syncReconnectIntent(id: oldId) - syncReconnectIntent(id: resolvedId, persistHold: restoreIdentifier != nil) - return true + 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``. @@ -1988,6 +2121,7 @@ actor BluetoothActor { /// 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 @@ -2066,7 +2200,13 @@ actor BluetoothActor { /// 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`). - func applyManualConnectHold(id: String, reconnectDesired: Bool) { + /// - 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 @@ -2083,7 +2223,7 @@ actor BluetoothActor { ) syncReconnectIntent(id: id, persistHold: true) - guard reconnectDesired, shouldProjectAwaitingRadio else { return } + guard reconnectDesired, shouldProjectAwaitingRadio else { return id } switch connectionStates[id] { case .connected, .connecting, .disconnecting, .reconnecting: // Already linked or in progress — leave the live state alone. @@ -2095,6 +2235,8 @@ actor BluetoothActor { ) 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 @@ -2118,6 +2260,7 @@ actor BluetoothActor { /// 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) @@ -2173,6 +2316,7 @@ actor BluetoothActor { /// 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. @@ -2187,14 +2331,28 @@ actor BluetoothActor { } /// 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 { - guard activeLeases[token.id]?.remove(token.leaseID) != nil else { + 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 } - syncReconnectIntent(id: token.id) - if !demand(id: token.id) { - beginIdleGrace(id: token.id) + 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) } } @@ -2293,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. /// @@ -2314,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 @@ -2328,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) { @@ -2702,6 +2892,10 @@ actor BluetoothActor { 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 } @@ -2770,6 +2964,31 @@ actor BluetoothActor { 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() diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 119125c..9db70bd 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -177,7 +177,10 @@ public final class Peripheral: Sendable, Identifiable, Hashable { // 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. - await manager.bluetooth.applyManualConnectHold(id: id, reconnectDesired: autoReconnect) + // 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 @@ -190,7 +193,7 @@ public final class Peripheral: Sendable, Identifiable, Hashable { Task { await manager.bluetooth.cancelPoweredOnContinuation(waiterID) } } - try await manager.bluetooth.reevaluateLink(id: id, reason: .explicitConnect) + try await manager.bluetooth.reevaluateLink(id: resolvedID, reason: .explicitConnect) } catch is CancellationError { throw CancellationError() } catch { diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 4f8dbba..2aca5d7 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1016,6 +1016,570 @@ struct ReliaBLEManagerTests { #expect(connected?.state == .connected) } + /// 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) + + try await manager.startScanning() + _ = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + + let upgradedID = "ReliaBLE-Renamed-Peripheral" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + + // 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) + + // 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) + + // The payoff: `didConnect` resolves back to the id that carries the demand. + let handle = manager.peripheral(id: upgradedID) + try await handle.connect() + + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) + #expect(await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == nil) + + await Mock.tearDown(manager) + } + + /// 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) + + 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() + + try await interimHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + + let upgradedID = "ReliaBLE-Renamed-Live" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + + // 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) + + // 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) + + await Mock.tearDown(manager) + } + + /// 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) + + 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 token = try await interimHandle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + + // 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) + + let upgradedID = "ReliaBLE-Renamed-Idle" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[upgradedID] == .disconnected(reason: nil) + }) + + await Mock.tearDown(manager) + } + + /// 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() + 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 interimHandle = try #require(snap).peripheral + await manager.stopScanning() + + let token = try await interimHandle.acquireWorkLease() + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + + let upgradedID = "ReliaBLE-Renamed-Lease" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + #expect(await manager.bluetooth.testWorkCount(for: upgradedID) == 1) + + // 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) + }) + + await Mock.tearDown(manager) + } + + /// 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(()) } + + // 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) + + try await manager.startScanning() + _ = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + + // 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 + }) + + // 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() + + // 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) + + Mock.connectionTestDelegate.connectionResult = .success(()) + + // 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() + + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(await manager.bluetooth.testHasManualConnectHold(for: Mock.connectionTestPeripheralID)) + + await manager.stopScanning() + await Mock.tearDown(manager) + } + + /// 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() + 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 staleHandle = try #require(snap).peripheral + await manager.stopScanning() + + // 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 + }) + + let upgradedID = "ReliaBLE-Renamed-RadioDrop" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + #expect(staleHandle.connectionState != nil) + + await staleHandle.releaseWorkLease(token) + #expect(await manager.bluetooth.testWorkCount(for: upgradedID) == 0) + + await manager.bluetooth.testInvalidatePeripherals() + + // 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) + + await Mock.tearDown(manager) + } + + /// 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() + await Mock.ensureReady(manager) + + // 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 + ) + _ = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + + let retiredID = Mock.connectionTestPeripheralID + let upgradedID = "ReliaBLE-Renamed-Reclaim" + await manager.bluetooth.testRediscover(id: retiredID, advertisedLocalName: upgradedID) + + try await manager.peripheral(id: upgradedID).connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) + + // 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) + + // 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 + ) + + // 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) + + // 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) + + await Mock.tearDown(manager) + } + + /// 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(()) + + 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) + + 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() + + try await interimHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + + // 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 + }) + + let upgradedID = "ReliaBLE-Renamed-Ladder" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + + #expect(await pollUntil(timeout: 5.0) { + await manager.currentConnectionStates[upgradedID] == .connected + }) + + await Mock.tearDown(manager) + } + + /// 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(()) + + // 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) + + 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 staleHandle = try #require(snap).peripheral + await manager.stopScanning() + + try await staleHandle.connect(autoReconnect: true) + + // 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 {} + + let upgradedID = "ReliaBLE-Renamed-Mirror" + await manager.bluetooth.testRediscover( + id: Mock.connectionTestPeripheralID, + advertisedLocalName: upgradedID + ) + + // 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) + + // 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)) + + await Mock.tearDown(manager) + } + + /// 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() + await Mock.ensureReady(manager) + + try await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let staleHandle = try #require(snap).peripheral + await manager.stopScanning() + + try await staleHandle.connect(autoReconnect: true) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + + 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)) + + // Disconnect through the obsolete id. + try await staleHandle.disconnect() + + #expect(await manager.bluetooth.testHasManualConnectHold(for: upgradedID) == false) + #expect(await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[upgradedID] == .disconnected(reason: nil) + }) + + await Mock.tearDown(manager) + } + + /// 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 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 interimHandle = try #require(snap).peripheral + await manager.stopScanning() + + // `connect()` returns once the connect is issued — before `didConnect` lands. + try await interimHandle.connect(autoReconnect: true) + + 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 connectionStateChangesEmitsDisconnectSequence() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) diff --git a/docs/plans/work-driven-connection-lifecycle-2026-08-02.md b/docs/plans/work-driven-connection-lifecycle-2026-08-02.md index 89ba1f6..e9933d2 100644 --- a/docs/plans/work-driven-connection-lifecycle-2026-08-02.md +++ b/docs/plans/work-driven-connection-lifecycle-2026-08-02.md @@ -79,7 +79,7 @@ Every open question this plan started with is resolved here. Decisions marked ** | **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)** | **Manual-connect holds are durable; work leases are not.** Persistence stores a **hold map** (`id → reconnectDesired`), not a reconnect-enabled set, and `handleWillRestoreState` **reads it back**. Four cases: (1) OS restores a peripheral **and** a hold is persisted for that id → rehydrate `manualConnectHold[id]` with its `reconnectDesired`, 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. | A manual `connect` is an explicit "keep this link" instruction; when the app has configured state restoration, that instruction should outlive relaunch, otherwise restoration cannot deliver the standing-session behavior it exists for. NFR-3.2 is still honored where it matters — restored links the app never explicitly asked for (residual or Tier-0 links) 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-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. | @@ -103,6 +103,7 @@ All state and mutation is actor-isolated on `BluetoothActor`. Per peripheral id: ```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`. @@ -168,7 +169,7 @@ The issue arm requires `wantsReconnect(id) || reason == .explicitConnect`. Bare - 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; for every id with demand that is not linked, `reevaluateLink(id:reason: .radioReturned)`; for restored links with no demand, `beginIdleGrace(id:)` (D-restore). +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: @@ -289,14 +290,15 @@ The throw is informational, not destructive: the app learns Bluetooth is off *an ### 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. **`syncReconnectIntent` is the sole persistence writer** — no other call site writes to disk. (Prose may call this "sync persisted holds" where that reads more clearly.) +- 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 map**, not a reconnect-enabled set: +**Persistence model (D-restore).** What is persisted is a **hold record list**, not a reconnect-enabled set: -- **Encoding:** a dictionary `id → reconnectDesired: Bool`, namespaced by `restoreIdentifier`. 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." The existing array-of-ids encoding therefore changes shape; since a stale old-format value is simply not decodable as the new map, treat a decode failure as "no persisted holds" and move on. No migration shim. -- **Meaning:** "the app made a manual `connect` for this id and has not disconnected it," together with the `autoReconnect` value it asked for. This is durable demand, not a hint. -- **The restore-time read stays.** `handleWillRestoreState` (near `:768`–`:777`) reads the map back and rehydrates `manualConnectHold`. This reverses the earlier plan's "delete the restore-time read" — that reasoning depended on holds never being restored, which D-restore now requires. -- **Only three call sites touch disk, and two of them are removals:** `syncReconnectIntent` writes; `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. +- **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). @@ -365,6 +367,8 @@ Optional `@testable`-visible wrappers on `Peripheral` so lease tests read natura | `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) | @@ -389,7 +393,7 @@ Optional `@testable`-visible wrappers on `Peripheral` so lease tests read natura | `.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 map `id → reconnectDesired` can | +| 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 | @@ -424,7 +428,7 @@ Optional `@testable`-visible wrappers on `Peripheral` so lease tests read natura ## 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 goes from an array of reconnect-enabled ids to a **hold map** (`id → reconnectDesired`), written only by `syncReconnectIntent` and read back at restore to rehydrate manual-connect holds (D-4). A value written by an older build will not decode as the new map; treat a decode failure as "no persisted holds". Pre-release, so no migration shim — the practical effect is that one relaunch after upgrading loses standing holds. +- **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. @@ -477,6 +481,8 @@ One branch, `51-work-driven-connection-lifecycle`. Each numbered step is a green | `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 | @@ -553,4 +559,5 @@ Demo builds go through XcodeBuildMCP per `Demo/AGENTS.md` — delegate that to a - `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`