diff --git a/AGENTS.md b/AGENTS.md index c12a196..afc1380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,18 +41,19 @@ The package declares **three targets** that share a single source tree to make ` ### Swift Concurrency -The library is built with Swift 6 and **complete concurrency checking**. The `ReliaBLEManager` public API should be callable from `@MainActor`, but the library itself should avoid `@MainActor` and instead use `@BluetoothActor` (a custom actor defined in `BluetoothActor.swift`) to serialize all Bluetooth interactions. This keeps the library thread-safe and allows the integrating app to decide how to bridge to the main thread for UI updates. +The library is built with Swift 6 and **complete concurrency checking**. The `ReliaBLEManager` public API should be callable from `@MainActor`, but the library itself should avoid `@MainActor` and instead serialize all Bluetooth interactions on `BluetoothActor` (a plain `actor` defined in `BluetoothActor.swift`). This keeps the library thread-safe and allows the integrating app to decide how to bridge to the main thread for UI updates. -### Logging +`BluetoothActor` is **not** a `@globalActor` and has no shared singleton. Each `ReliaBLEManager` **owns its own** `BluetoothActor` instance, created synchronously in the manager's `init` (`BluetoothActor(log:reconnectPolicy:restoreIdentifier:)`). There is no `@BluetoothActor` annotation, no `.shared`, and no process-wide state — do not reintroduce any of these. -`LoggingService` wraps Willow's `Logger` with an async execution queue. The service is `Sendable` and passed by reference into both managers. Default writer is an `OSLogWriter` (`subsystem: com.five3apps.relia-ble`, `category: BLE`), configurable via `ReliaBLEConfig`. Logging is **disabled by default** — `config.loggingEnabled` must be set to true. Log calls take a `tags: [LogTag]` array; use `.category(.scanning)`, `.peripheral(id)`, etc. rather than embedding the category in the message. +**One stack per manager.** Each `ReliaBLEManager` is a fully isolated stack: its own actor, `CBCentralManager`, discovered-peripheral snapshots, connection state, and streams. Constructing a second manager yields a second, independent stack — config (logging, `reconnectPolicy`) applies per manager, not first-wins. -### Authorization flow +### Logging -`ReliaBLEManager.init` does **not** instantiate `CBCentralManager` unless the user has already granted `.allowedAlways`. This is deliberate so the integrating app controls when the iOS permission prompt appears — callers invoke `authorizeBluetooth()` when they want the prompt. Preserve this lazy-init behavior when touching `BluetoothActor.setupCentralManager()`. +`LoggingService` wraps Willow's `Logger` with an async execution queue. The service is `Sendable` and passed by reference into both managers. Default writer is an `OSLogWriter` (`subsystem: com.five3apps.relia-ble`, `category: BLE`), configurable via `ReliaBLEConfig`. Logging is **disabled by default** — `config.loggingEnabled` must be set to true. Log calls take a `tags: [LogTag]` array; use `.category(.scanning)`, `.peripheral(id)`, etc. rather than embedding the category in the message. ## 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. - 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. diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index b633832..c8bbd16 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -79,7 +79,9 @@ extension ConnectionState { struct CentralView: View { @Environment(\.modelContext) private var modelContext - @Environment(\.bleManager) private var reliaBLE + /// Optional because SwiftUI evaluates `EnvironmentKey.defaultValue` while wiring keys; + /// App injects the real manager on the root content view. + @Environment(\.bleManager) private var bleManager @Query private var discoveries: [DiscoveryEvent] @Query private var devices: [Device] @@ -88,6 +90,22 @@ struct CentralView: View { @State private var selectedView: String = "Devices" var body: some View { + Group { + if let reliaBLE = bleManager { + centralContent(reliaBLE: reliaBLE) + } else { + // Defensive only: App/previews always inject. Not a product UX state to design around. + ContentUnavailableView( + "Bluetooth Manager Missing", + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text("Inject ReliaBLEManager via .environment(\\.bleManager, …).") + ) + } + } + } + + @ViewBuilder + private func centralContent(reliaBLE: ReliaBLEManager) -> some View { NavigationSplitView { Text("ReliaBLE state: \(viewModel.currentState.description)") @@ -131,7 +149,7 @@ struct CentralView: View { Group { if selectedView == "Devices" { - deviceList + deviceList(reliaBLE: reliaBLE) } else { discoveriesList } @@ -152,28 +170,27 @@ struct CentralView: View { Text("Select a device") } .task { - let manager = reliaBLE let store = await DeviceStoreActor.create(container: modelContext.container) - viewModel.setDependencies(deviceStore: store, reliaBLE: manager) + viewModel.setDependencies(deviceStore: store, reliaBLE: reliaBLE) await withTaskGroup(of: Void.self) { group in group.addTask { - for await state in manager.state { + for await state in reliaBLE.state { await viewModel.updateState(state) } } group.addTask { - for await discoveryEvent in manager.peripheralDiscoveries { + for await discoveryEvent in reliaBLE.peripheralDiscoveries { await store.insertDiscovery(discoveryEvent) } } group.addTask { - for await peripherals in manager.discoveredPeripherals { + for await peripherals in reliaBLE.discoveredPeripherals { await store.syncDevices(peripherals) } } group.addTask { - for await change in manager.connectionStateChanges { + for await change in reliaBLE.connectionStateChanges { await viewModel.updateConnectionState(change) } } @@ -181,7 +198,7 @@ struct CentralView: View { } } - private var deviceList: some View { + private func deviceList(reliaBLE: ReliaBLEManager) -> some View { List { ForEach(devices, id: \.persistentModelID) { device in NavigationLink { @@ -313,4 +330,9 @@ private struct CountdownView: View { for: [Device.self, DiscoveryEvent.self], inMemory: true ) + .environment(\.bleManager, { + var config = ReliaBLEConfig() + config.restoreIdentifier = "com.five3apps.relia-ble-demo.preview" + return ReliaBLEManager(config: config) + }()) } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/ContentView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/ContentView.swift index 0dff5ea..79f8bf5 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/ContentView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/ContentView.swift @@ -23,6 +23,7 @@ // SOFTWARE. import SwiftUI +import ReliaBLE struct ContentView: View { var body: some View { @@ -47,4 +48,9 @@ struct ContentView: View { #Preview { ContentView() + .environment(\.bleManager, { + var config = ReliaBLEConfig() + config.restoreIdentifier = "com.five3apps.relia-ble-demo.preview" + return ReliaBLEManager(config: config) + }()) } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift index 3025419..7bdf154 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift @@ -27,11 +27,20 @@ import SwiftData import ReliaBLE private struct BLEManagerKey: EnvironmentKey { - static let defaultValue: ReliaBLEManager = ReliaBLEManager(config: ReliaBLEConfig()) + /// Must stay cheap and side-effect free. + /// + /// SwiftUI evaluates `defaultValue` while wiring environments (including when *setting* + /// `.environment(\.bleManager, …)` via a writable key path) — not only when a view reads a + /// missing value. So this must never `fatalError` and must never construct a + /// ``ReliaBLEManager`` (that would spin up a second `CBCentralManager` stack, often without + /// the app's `restoreIdentifier`). + /// + /// The real manager is injected once from ``ReliaBLE_DemoApp``. + static let defaultValue: ReliaBLEManager? = nil } extension EnvironmentValues { - var bleManager: ReliaBLEManager { + var bleManager: ReliaBLEManager? { get { self[BLEManagerKey.self] } set { self[BLEManagerKey.self] = newValue } } @@ -77,8 +86,10 @@ struct ReliaBLE_DemoApp: App { var body: some Scene { WindowGroup { ContentView() + // Inject on the root content (not only the Scene) so descendants always see the + // real manager after the scene graph materializes. + .environment(\.bleManager, reliaBLE) } .modelContainer(sharedModelContainer) - .environment(\.bleManager, reliaBLE) } } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift index 5ecba83..39d695d 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift @@ -29,7 +29,9 @@ import SwiftUI import ReliaBLE struct SettingsView: View { - @Environment(\.bleManager) private var reliaBLE + /// Optional because SwiftUI evaluates `EnvironmentKey.defaultValue` while wiring keys; + /// App injects the real manager on the root content view. + @Environment(\.bleManager) private var bleManager @State private var isLoggingEnabled: Bool = false @AppStorage("reconnectPolicy.maxAttempts") private var maxAttempts = 5 @@ -41,7 +43,9 @@ struct SettingsView: View { NavigationView { Form { Section("Logging") { + // Defensive only: App/previews always inject; disable is not a product UX path. Toggle("Enable Logging", isOn: $isLoggingEnabled) + .disabled(bleManager == nil) } Section { @@ -72,14 +76,21 @@ struct SettingsView: View { .navigationTitle("Settings") } .onAppear { - isLoggingEnabled = reliaBLE.loggingService.enabled + if let bleManager { + isLoggingEnabled = bleManager.loggingService.enabled + } } .onChange(of: isLoggingEnabled) { _, newValue in - reliaBLE.loggingService.enabled = newValue + bleManager?.loggingService.enabled = newValue } } } #Preview { SettingsView() + .environment(\.bleManager, { + var config = ReliaBLEConfig() + config.restoreIdentifier = "com.five3apps.relia-ble-demo.preview" + return ReliaBLEManager(config: config) + }()) } diff --git a/PRD.md b/PRD.md index d226db7..0924dce 100644 --- a/PRD.md +++ b/PRD.md @@ -159,9 +159,9 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - FR-8.2.1: Allow the library to scan continuously for BLE peripherals, providing real-time updates about nearby devices as **`DiscoveredPeripheral`** values (and/or equivalent), each resolvable to a `Peripheral` handle. - FR-8.2.2: Provide an interface for the app to start, stop, and check the status of the continuous scanning process. -- FR-8.3: Background Scanning: - - FR-8.3.1: Implement background scanning capabilities, ensuring compliance with iOS background execution rules. - - FR-8.3.2: Notify the integrating app when new devices come into range even when the app is not in the foreground, using appropriate iOS background modes like bluetooth-central. +- ✅ FR-8.3: Background Scanning: + - ✅ FR-8.3.1: Implement background scanning capabilities, ensuring compliance with iOS background execution rules. + - ✅ FR-8.3.2: Notify the integrating app when new devices come into range even when the app is not in the foreground, using appropriate iOS background modes like bluetooth-central. - ✅ FR-8.4: Processing of Advertisement Data: - ✅ FR-8.4.1: Extract and make available manufacturing data from advertisement packets to the integrating app. diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 8af5943..92dc081 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -70,10 +70,11 @@ private struct RestoredScanOptions: @unchecked Sendable { /// A single CoreBluetooth delegate callback, carried in delivery order across the nonisolated /// delegate-queue → ``BluetoothActor`` hop. /// -/// CoreBluetooth invokes delegate methods serially on its dispatch queue. ``BluetoothDelegateShim`` -/// yields one of these per callback into a single `AsyncStream`, and ``BluetoothActor`` drains them -/// with a single consumer so the original callback ordering is preserved — independent per-callback -/// `Task`s could be reordered before reaching the actor. +/// CoreBluetooth invokes delegate methods serially on its dispatch queue. The nonisolated delegate +/// shim (``BluetoothDelegateShim`` or ``RestoringBluetoothDelegateShim``) forwards each callback +/// through ``DelegateEventForwarder`` into a single `AsyncStream`, and ``BluetoothActor`` drains +/// them with a single consumer so the original callback ordering is preserved — independent +/// per-callback `Task`s could be reordered before reaching the actor. private enum DelegateEvent: Sendable { case stateUpdate case discovered(DiscoveryPayload) @@ -83,36 +84,87 @@ private enum DelegateEvent: Sendable { case willRestore(RestorationPayload) } +// MARK: - Teardown Boxes + +/// Nonisolated box so the actor's nonisolated `deinit` may finish the delegate-event pipeline. +fileprivate final class EventPipeline: @unchecked Sendable { + fileprivate let stream: AsyncStream + fileprivate let continuation: AsyncStream.Continuation + + init() { + (stream, continuation) = AsyncStream.makeStream( + of: DelegateEvent.self, + bufferingPolicy: .unbounded + ) + } + + func finish() { continuation.finish() } +} + +/// Thread-safe registry of unstructured task handles (reconnect ladder sleeps). +fileprivate final class TaskRegistry: @unchecked Sendable { + private let lock = NSLock() + private var tasks: [String: Task] = [:] + + func insert(_ id: String, _ task: Task) { + lock.lock() + let previous = tasks[id] + tasks[id] = task + lock.unlock() + previous?.cancel() + } + + func cancel(_ id: String) { + lock.lock() + let task = tasks.removeValue(forKey: id) + lock.unlock() + task?.cancel() + } + + func cancelAll() { + lock.lock() + let all = Array(tasks.values) + tasks.removeAll() + lock.unlock() + for task in all { task.cancel() } + } +} + // MARK: - BluetoothActor -/// Process-wide global actor that serializes all CoreBluetooth interactions. +/// Actor that serializes all CoreBluetooth interactions for a single ``ReliaBLEManager`` stack. /// /// All mutable BLE state—`CBCentralManager`, per-subscriber `AsyncStream` continuations, and -/// discovered peripherals—are owned exclusively by this actor. Two `ReliaBLEManager` instances -/// share the same isolation domain; this is acceptable because CoreBluetooth already -/// enforces a single central manager per process. +/// discovered peripherals—are owned exclusively by this actor. Each manager owns its own instance. /// -/// Delegate callbacks arrive on CoreBluetooth's internal queue and are hopped into this -/// actor's isolation via `Task { @BluetoothActor in … }` inside the nonisolated -/// ``BluetoothDelegateShim``. -@globalActor +/// Delegate callbacks arrive on CoreBluetooth's internal queue and are yielded into +/// ``EventPipeline`` by the nonisolated shim via ``DelegateEventForwarder``. actor BluetoothActor { - /// The process-lifetime shared instance. - static let shared = BluetoothActor() + + // MARK: - Nonisolated Teardown + + /// Nonisolated box so `deinit` may finish the pipeline without touching actor-isolated state. + private nonisolated let eventPipeline = EventPipeline() + /// Nonisolated box so `deinit` may cancel reconnect tasks without touching actor-isolated state. + private nonisolated let taskRegistry = TaskRegistry() // MARK: - Actor-Isolated State private let centralManagerQueue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated) var centralManager: CBCentralManager? - private var delegateShim: BluetoothDelegateShim? + /// Retained for the central's weak delegate; concrete type is a non-restoring or restoring shim. + private var delegateShim: (any CBCentralManagerDelegate)? - /// Drains delegate callbacks in order. Lives for the process lifetime of the singleton actor. + /// Drains delegate callbacks in order from ``eventPipeline``. private var delegateEventTask: Task? - /// Tracks one-time actor setup so ``ensureInitialized(log:)`` is idempotent across the many - /// `ReliaBLEManager` façades that may share this process-wide actor. - private var isInitialized = false + /// Once true, the actor is dead — ops no-op / throw and no second central can be created. + private var isShutdown = false + + /// Tracks whether ``ensureCentralManager()`` has run at least once so the initial + /// authorization-derived state is broadcast even when no central is created. + private var hasEnsuredOnce = false /// Continuations for in-flight ``authorize()`` calls awaiting an authorization decision, keyed by a /// per-call id so a cancelled call can resume just its own continuation. All pending continuations are @@ -136,8 +188,10 @@ actor BluetoothActor { // MARK: - AsyncStream Broadcaster State // // One continuation per active subscriber, keyed by a per-subscription UUID. Mutated only on - // the actor's serial executor: the stream factories register on a `@BluetoothActor` hop, the - // broadcast sites iterate to `yield`, and each `onTermination` handler prunes its own entry. + // the actor's serial executor: the stream factories register via `Task { await self.register(...) }`, + // the broadcast sites iterate to `yield`, and each `onTermination` handler prunes its own entry. + // Registration / onTermination Tasks capture `self` strongly while the stream is live — deliberate + // so a consumed stream keeps the stack alive (see design: stream-retains-actor). private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] private var discoveryContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -148,7 +202,7 @@ actor BluetoothActor { private var connectionStateChangesContinuations: [UUID: AsyncStream.Continuation] = [:] - private var reconnectPolicy: ReconnectPolicy = ReconnectPolicy() + private var reconnectPolicy: ReconnectPolicy /// Stable CoreBluetooth restore identifier; `nil` disables state restoration. private var restoreIdentifier: String? /// Scan filter restored via `willRestoreState` when the central was not yet powered on. @@ -157,11 +211,58 @@ actor BluetoothActor { private var reconnectEnabled: Set = [] private var intentionalDisconnects: Set = [] private var reconnectAttempts: [String: Int] = [:] - private var reconnectTasks: [String: Task] = [:] // MARK: - Initialization - private init() {} + /// Creates an actor with configuration only — no `CBCentralManager` is created here. + init(log: LoggingService, reconnectPolicy: ReconnectPolicy, restoreIdentifier: String?) { + self.log = log + self.reconnectPolicy = reconnectPolicy + self.restoreIdentifier = restoreIdentifier + } + + deinit { + eventPipeline.finish() + taskRegistry.cancelAll() + } + + /// Terminal teardown for tests/harness. Clears volatile state only — does **not** touch + /// persisted reconnect-intent `UserDefaults`. + func shutdown() { + guard !isShutdown else { return } + isShutdown = true + + eventPipeline.finish() + taskRegistry.cancelAll() + delegateEventTask?.cancel() + delegateEventTask = nil + + for continuation in stateContinuations.values { continuation.finish() } + for continuation in discoveryContinuations.values { continuation.finish() } + for continuation in peripheralsContinuations.values { continuation.finish() } + for continuation in connectionStateChangesContinuations.values { continuation.finish() } + stateContinuations.removeAll() + discoveryContinuations.removeAll() + peripheralsContinuations.removeAll() + connectionStateChangesContinuations.removeAll() + + let pendingAuth = authorizationContinuations + authorizationContinuations.removeAll() + for continuation in pendingAuth.values { + continuation.resume(throwing: CancellationError()) + } + + centralManager = nil + delegateShim = nil + cbPeripherals.removeAll() + discoveredPeripherals.removeAll() + connectionStates.removeAll() + reconnectEnabled.removeAll() + intentionalDisconnects.removeAll() + reconnectAttempts.removeAll() + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + } // MARK: - Event Streams @@ -172,7 +273,7 @@ actor BluetoothActor { /// new subscriber always observes the current state without waiting for the next broadcast. nonisolated func stateStream() -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - Task { await BluetoothActor.shared.register(stateContinuation: continuation) } + Task { await self.register(stateContinuation: continuation) } } } @@ -196,7 +297,7 @@ actor BluetoothActor { /// drops the oldest pending advertisements rather than growing memory without bound. nonisolated func peripheralDiscoveriesStream() -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(BluetoothActor.discoveryBufferLimit)) { continuation in - Task { await BluetoothActor.shared.register(discoveryContinuation: continuation) } + Task { await self.register(discoveryContinuation: continuation) } } } @@ -207,7 +308,7 @@ actor BluetoothActor { /// a new subscriber immediately observes the peripherals already discovered. nonisolated func discoveredPeripheralsStream() -> AsyncStream<[Peripheral]> { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - Task { await BluetoothActor.shared.register(peripheralsContinuation: continuation) } + Task { await self.register(peripheralsContinuation: continuation) } } } @@ -218,7 +319,7 @@ actor BluetoothActor { /// mirroring ``peripheralDiscoveriesStream()``. nonisolated func connectionStateChangesStream() -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(BluetoothActor.discoveryBufferLimit)) { continuation in - Task { await BluetoothActor.shared.register(connectionStateChangeContinuation: continuation) } + Task { await self.register(connectionStateChangeContinuation: continuation) } } } @@ -230,28 +331,31 @@ actor BluetoothActor { // missed by a *new* (replay-less) `peripheralDiscoveries` subscriber. Accepted and documented. private func register(stateContinuation continuation: AsyncStream.Continuation) { + guard !isShutdown else { continuation.finish(); return } let id = UUID() continuation.yield(currentBluetoothState) stateContinuations[id] = continuation continuation.onTermination = { _ in - Task { await BluetoothActor.shared.removeStateContinuation(id) } + Task { await self.removeStateContinuation(id) } } } private func register(discoveryContinuation continuation: AsyncStream.Continuation) { + guard !isShutdown else { continuation.finish(); return } let id = UUID() discoveryContinuations[id] = continuation continuation.onTermination = { _ in - Task { await BluetoothActor.shared.removeDiscoveryContinuation(id) } + Task { await self.removeDiscoveryContinuation(id) } } } private func register(peripheralsContinuation continuation: AsyncStream<[Peripheral]>.Continuation) { + guard !isShutdown else { continuation.finish(); return } let id = UUID() continuation.yield(discoveredPeripherals) peripheralsContinuations[id] = continuation continuation.onTermination = { _ in - Task { await BluetoothActor.shared.removePeripheralsContinuation(id) } + Task { await self.removePeripheralsContinuation(id) } } } @@ -260,10 +364,11 @@ actor BluetoothActor { private func removePeripheralsContinuation(_ id: UUID) { peripheralsContinuations[id] = nil } private func register(connectionStateChangeContinuation continuation: AsyncStream.Continuation) { + guard !isShutdown else { continuation.finish(); return } let id = UUID() connectionStateChangesContinuations[id] = continuation continuation.onTermination = { _ in - Task { await BluetoothActor.shared.removeConnectionStateChangeContinuation(id) } + Task { await self.removeConnectionStateChangeContinuation(id) } } } @@ -292,48 +397,20 @@ actor BluetoothActor { // MARK: - Configuration - func configure(log: LoggingService) { - self.log = log - } - - /// Performs idempotent actor setup, funneled through by every public ``ReliaBLEManager`` entry point - /// before it acts — so an operation invoked immediately after `init` (whose setup runs in a - /// fire-and-forget `Task`) cannot race ahead of setup and silently no-op. + /// Creates the central manager if Bluetooth is currently authorized (`.allowedAlways`) and one + /// does not already exist. Idempotent and actor-serialized. /// - /// The logger is configured exactly once. On *every* call this also creates the central manager if - /// Bluetooth is currently authorized (`.allowedAlways`) and one does not already exist — so an - /// operation issued after authorization is granted out-of-band (via Settings, app lifecycle, or - /// another owner) still finds a live manager instead of being permanently gated by the first call's - /// authorization status. - /// - /// Creating the central manager remains gated on existing `.allowedAlways` authorization, preserving + /// Creating the central remains gated on existing `.allowedAlways` authorization, preserving /// the lazy-permission contract: the iOS prompt only appears when the integrating app calls - /// ``ReliaBLEManager/authorizeBluetooth()``. The initial state is broadcast on first setup and - /// whenever the manager is created, but not on every redundant call. + /// ``ReliaBLEManager/authorizeBluetooth()``. An operation issued after authorization is granted + /// out-of-band still finds a live manager because every call retries creation. /// - /// When ``restoreIdentifier`` is non-`nil` and authorization is already `.allowedAlways`, the - /// central is created with `CBCentralManagerOptionRestoreIdentifierKey` so CoreBluetooth can - /// deliver `willRestoreState` as the first callback on relaunch. Authorization is never - /// relaxed — if Bluetooth is not authorized there is nothing to restore. - func ensureInitialized( - log: LoggingService, - reconnectPolicy: ReconnectPolicy = ReconnectPolicy(), - restoreIdentifier: String? = nil - ) { - let firstInitialization = !isInitialized - if firstInitialization { - isInitialized = true - configure(log: log) - self.reconnectPolicy = reconnectPolicy - self.restoreIdentifier = restoreIdentifier - } else if let restoreIdentifier, restoreIdentifier != self.restoreIdentifier { - // The actor is process-wide; the first manager's configuration wins for the process - // lifetime. Surface the mismatch instead of silently ignoring the new identifier. - let current = self.restoreIdentifier.map { "\"\($0)\"" } ?? "nil" - log.warn( - "Ignoring restoreIdentifier \"\(restoreIdentifier)\" — Bluetooth actor already initialized with \(current)" - ) - } + /// Stream and snapshot getters do **not** call this — only operational methods do. + func ensureCentralManager() { + guard !isShutdown else { return } + + let firstEnsure = !hasEnsuredOnce + hasEnsuredOnce = true var createdManager = false @@ -342,7 +419,7 @@ actor BluetoothActor { createdManager = true } - if firstInitialization || createdManager { + if firstEnsure || createdManager { updateState() } } @@ -350,21 +427,44 @@ actor BluetoothActor { // MARK: - Central Manager Setup func setupCentralManager() { + guard !isShutdown else { return } guard centralManager == nil else { return } log?.info("Initializing CBCentralManager") - // A single `AsyncStream` carries delegate callbacks in CoreBluetooth's delivery order; the lone - // consumer task below drains them so ordering is preserved end-to-end. The buffer is intentionally - // unbounded: state-change callbacks must never be dropped (unlike the public advertisements feed), - // and `process(_:)` is lightweight, so the actor keeps pace with CoreBluetooth's serial callback - // rate in practice. - let (events, continuation) = AsyncStream.makeStream( - of: DelegateEvent.self, - bufferingPolicy: .unbounded - ) - let shim = BluetoothDelegateShim(eventContinuation: continuation) + // Consumer-before-factory: start draining the (already-created) pipeline first so a + // synchronous `willRestoreState` inside the factory call is not lost. + // + // Shim choice is gated by the same `restoreIdentifier != nil` condition that adds + // `CBCentralManagerOptionRestoreIdentifierKey` below, so delegate and options never + // disagree. Two peer types (not inheritance) are required for *both* stacks: + // + // - **Real CoreBluetooth (ObjC):** uses `responds(to:)` and logs API MISUSE when the + // delegate implements `willRestoreState` without a restore identifier. The non-restoring + // shim must not declare that method at all (same pattern Nordic uses in + // `CBMCentralManagerNative`). + // - **CoreBluetoothMock (Swift):** `CBMCentralManagerMock` calls + // `delegate?.centralManager(_:willRestoreState:)` **unconditionally** via the Swift + // protocol (extension default is a no-op). Each peer class needs its own witness table + // so the restoring type's implementation is dispatched; a subclass of a base that omits + // the method would still hit the empty protocol-extension default. + let forwarder = DelegateEventForwarder(eventContinuation: eventPipeline.continuation) + let shim: any CBCentralManagerDelegate = if restoreIdentifier != nil { + RestoringBluetoothDelegateShim(forwarder: forwarder) + } else { + BluetoothDelegateShim(forwarder: forwarder) + } delegateShim = shim + + if delegateEventTask == nil { + delegateEventTask = Task { [weak self] in + guard let self else { return } + for await event in self.eventPipeline.stream { + await self.process(event) + } + } + } + // Use CBCentralManagerFactory for consistency between normal and test targets. // `forceMock: true` is load-bearing for the ReliaBLEMock test target — do not remove. centralManager = CBCentralManagerFactory.instance( @@ -373,19 +473,14 @@ actor BluetoothActor { options: centralManagerCreationOptions(), forceMock: true ) - - delegateEventTask = Task { [weak self] in - for await event in events { - await self?.process(event) - } - } } /// Builds the options dictionary passed to the central-manager factory. /// /// Factored out of ``setupCentralManager()`` so unit tests can assert the restore key is - /// included without tearing down the process-lifetime central. End-to-end factory option - /// fidelity is deferred to the mock-harness work in issue #42. + /// included without creating a central. When a restore identifier is configured and + /// `CBMCentralManagerMock.simulateStateRestoration` is set, central init delivers + /// `willRestoreState` faithfully (see the test harness cold-relaunch helpers). private func centralManagerCreationOptions() -> [String: Any]? { guard let restoreIdentifier else { return nil } return [CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier] @@ -393,6 +488,8 @@ actor BluetoothActor { /// Drains a single delegate event on the actor, preserving CoreBluetooth's callback order. private func process(_ event: DelegateEvent) { + guard !isShutdown else { return } + switch event { case .stateUpdate: handleCentralManagerStateUpdate() @@ -426,6 +523,8 @@ actor BluetoothActor { /// ``ReliaBLEManager`` façade, not here, to keep this actor-isolated method free of a construct the /// region-based isolation checker cannot yet analyze. func authorize(id: UUID) async throws { + guard !isShutdown else { throw PeripheralError.bluetoothUnavailable } + log?.info("Authorizing bluetooth") switch CBCentralManager.authorization { @@ -495,6 +594,10 @@ actor BluetoothActor { // MARK: - Scanning func startScanning(services: sending [CBUUID]? = nil) { + guard !isShutdown else { + log?.warn(tags: [.category(.scanning)], "Attempted to start scan after shutdown") + return + } guard let centralManager else { log?.warn(tags: [.category(.scanning)], "Attempted to start scan without a central manager") return @@ -523,6 +626,10 @@ actor BluetoothActor { } func stopScanning() { + guard !isShutdown else { + log?.warn(tags: [.category(.scanning)], "Attempted to stop scan after shutdown") + return + } guard let centralManager else { log?.warn(tags: [.category(.scanning)], "Attempted to stop scan without a central manager") return @@ -582,7 +689,7 @@ actor BluetoothActor { broadcast(state, to: stateContinuations) } - // MARK: - Delegate Entry Points (called by BluetoothDelegateShim) + // MARK: - Delegate Entry Points (called via DelegateEventForwarder) /// Rehydrates scan and connection state delivered by CoreBluetooth on app relaunch. /// @@ -850,8 +957,7 @@ actor BluetoothActor { // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. cbPeripherals.removeAll() connectionStates.removeAll() - for task in reconnectTasks.values { task.cancel() } - reconnectTasks.removeAll() + taskRegistry.cancelAll() reconnectAttempts.removeAll() reconnectEnabled.removeAll() persistReconnectIntent() @@ -923,6 +1029,10 @@ actor BluetoothActor { /// - 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 @@ -958,6 +1068,10 @@ actor BluetoothActor { /// - 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") + throw PeripheralError.bluetoothUnavailable + } guard let centralManager else { log?.warn(tags: [.peripheral(id)], "Attempted to disconnect without a central manager") throw PeripheralError.bluetoothUnavailable @@ -971,8 +1085,7 @@ actor BluetoothActor { intentionalDisconnects.insert(id) reconnectEnabled.remove(id) persistReconnectIntent() - reconnectTasks[id]?.cancel() - reconnectTasks[id] = nil + taskRegistry.cancel(id) reconnectAttempts[id] = nil connectionStates[id] = .disconnecting @@ -1027,8 +1140,7 @@ actor BluetoothActor { if payload.isReconnecting { // Defensively cancel any pending library ladder so Tier 0 (system) and Tier 1 // (library) cannot overlap under odd callback ordering. - reconnectTasks[id]?.cancel() - reconnectTasks[id] = nil + taskRegistry.cancel(id) connectionStates[id] = .reconnecting(source: .system, attempt: nil, nextRetryAt: nil) log?.info(tags: [.peripheral(id), .category(.connection)], "System auto-reconnect in progress") @@ -1089,7 +1201,7 @@ actor BluetoothActor { } private func scheduleReconnect(id: String, attempt: Int) { - reconnectTasks[id]?.cancel() + 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 @@ -1114,7 +1226,7 @@ actor BluetoothActor { connectionStates[id] = .reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt) broadcast(ConnectionStateChange(peripheralId: id, state: .reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt)), to: connectionStateChangesContinuations) - reconnectTasks[id] = Task { [weak self] in + let task = Task { [weak self] in do { try await Task.sleep(nanoseconds: sleepNanos) } catch is CancellationError { @@ -1128,6 +1240,7 @@ actor BluetoothActor { await self.performReconnect(id: id, attempt: attempt) } + taskRegistry.insert(id, task) } private func performReconnect(id: String, attempt: Int) { @@ -1152,8 +1265,7 @@ actor BluetoothActor { } private func clearReconnectState(for id: String) { - reconnectTasks[id]?.cancel() - reconnectTasks[id] = nil + taskRegistry.cancel(id) reconnectAttempts[id] = nil intentionalDisconnects.remove(id) } @@ -1192,25 +1304,19 @@ actor BluetoothActor { intentionalDisconnects.insert(id) } - /// Test-only hook: drives restoration with a hand-built CoreBluetooth restore dictionary, - /// routing through `process(.willRestore)` the same way ``BluetoothDelegateShim`` would. + /// Test-only hook: invokes ``handleWillRestoreState(_:)`` directly for defensive unit tests + /// (e.g. empty scan filter, defer-until-powered-on, disconnected-peripheral seeding) that + /// cannot use the faithful mock `simulateStateRestoration` path. /// - /// Needed because CoreBluetoothMock cannot synthesize `willRestoreState`. - func testInvokeWillRestoreState(_ state: [String: Any]) { - process(.willRestore(RestorationPayload(state: state))) - } - - /// Test-only hook: builds a restoration dictionary from actor-owned live peripherals and - /// drives ``testInvokeWillRestoreState(_:)``. Keeps non-`Sendable` `CBPeripheral` references - /// inside the actor isolation boundary. - func testInvokeWillRestoreState( - peripheralIds: [String], - scanServices: [CBUUID]? = nil, - scanOptions: [String: Any]? = nil + /// Does not go through the shim or event pipeline — production restoration is covered by + /// cold-relaunch tests that set `CBMCentralManagerMock.simulateStateRestoration`. + /// + /// - Parameter peripheralIds: Live `cbPeripherals` keys to include in the restoration dictionary. + func testHandleWillRestoreState( + peripheralIds: [String] = [], + scanServices: [CBUUID]? = nil ) { var state: [String: Any] = [:] - // Avoid compactMap/closure patterns the region-based isolation checker cannot analyze - // for non-Sendable CBPeripheral values. var peripherals: [CBPeripheral] = [] for id in peripheralIds { if let peripheral = cbPeripherals[id] { @@ -1223,10 +1329,7 @@ actor BluetoothActor { if let scanServices { state[CBCentralManagerRestoredStateScanServicesKey] = scanServices } - if let scanOptions { - state[CBCentralManagerRestoredStateScanOptionsKey] = scanOptions - } - testInvokeWillRestoreState(state) + handleWillRestoreState(RestorationPayload(state: state)) } /// Test-only hook: whether `id` is currently in ``reconnectEnabled``. @@ -1247,7 +1350,7 @@ actor BluetoothActor { /// Test-only hook: number of registered `connectionStateChanges` subscribers. /// /// Stream registration is asynchronous — the factory schedules `register(...)` on a detached - /// `@BluetoothActor` hop (see ``connectionStateChangesStream()``). Tests that must observe a + /// actor hop (see ``connectionStateChangesStream()``). Tests that must observe a /// broadcast emitted *right after* subscribing (e.g. the restore path) poll this until their /// subscription has landed, otherwise a non-replaying broadcast can be missed entirely. func testConnectionStateSubscriberCount() -> Int { @@ -1271,12 +1374,24 @@ actor BluetoothActor { } /// Test-only hook: keys of the options dictionary ``setupCentralManager()`` would pass to the - /// factory right now. Verifies restore-key wiring at the unit level; end-to-end factory option - /// fidelity is deferred to issue #42. + /// factory right now. Verifies restore-key wiring at the unit level. func testCentralCreationOptionKeys() -> [String] { centralManagerCreationOptions().map { Array($0.keys) } ?? [] } + /// Test-only hook: whether the installed delegate is the restoring peer shim. + /// + /// Paired with ``testCentralCreationOptionKeys()`` so tests can assert that the restore-id + /// option and the restoring delegate are always installed together. + func testDelegateIsRestoringShim() -> Bool { + delegateShim is RestoringBluetoothDelegateShim + } + + /// Test-only hook: whether the installed delegate is the non-restoring peer shim. + func testDelegateIsNonRestoringShim() -> Bool { + delegateShim is BluetoothDelegateShim + } + /// Test-only hook: reconnect intent persisted for the current restore identifier. func testPersistedReconnectIntent() -> Set { persistedReconnectIntent() @@ -1288,82 +1403,168 @@ actor BluetoothActor { UserDefaults.standard.removeObject(forKey: key) } - /// Test-only hook: overwrites the stored restore identifier (process-lifetime actor may already - /// have been initialized by an earlier test without one). - func testSetRestoreIdentifier(_ id: String?) { - restoreIdentifier = id } - /// Test-only hook: clears discovery snapshots and connection intent so a subsequent restore can - /// exercise the cold-relaunch (append-new) identity branch, while keeping live `CBPeripheral` - /// references available for ``testInvokeWillRestoreState(peripheralIds:scanServices:scanOptions:)``. - /// - /// Deliberately does **not** touch persisted reconnect intent — this simulates process death, - /// where in-memory state is lost but `UserDefaults` survives. - func testClearDiscoveredSnapshotsPreservingLiveReferences() { - discoveredPeripherals.removeAll() - connectionStates.removeAll() - reconnectEnabled.removeAll() - pendingRestoredScanServices = nil - pendingRestoredScanOptions = nil - } -} - // MARK: - BluetoothDelegateShim -/// Bridges `CBCentralManagerDelegate` callbacks—which arrive on CoreBluetooth's internal -/// queue—into ``BluetoothActor``-isolated handlers via unstructured `Task` hops. +/// Yields CoreBluetooth delegate callbacks into ``BluetoothActor``'s ordered event pipeline. /// -/// The shim holds no mutable state. All meaningful work happens inside ``BluetoothActor``. -/// No weak/unowned reference is needed because ``BluetoothActor/shared`` is a -/// process-lifetime singleton. -final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { - - /// Sink for delegate callbacks, drained in order by ``BluetoothActor``'s consumer task. +/// Shared by both shims so callback ferrying stays in one place. Holds no actor reference (only +/// the pipeline continuation), avoiding retain cycles. +fileprivate final class DelegateEventForwarder: @unchecked Sendable { private let eventContinuation: AsyncStream.Continuation - fileprivate init(eventContinuation: AsyncStream.Continuation) { + init(eventContinuation: AsyncStream.Continuation) { self.eventContinuation = eventContinuation - super.init() } - func centralManagerDidUpdateState(_ central: CBCentralManager) { + func stateUpdate() { // Yielding is synchronous and thread-safe; ordering is preserved because CoreBluetooth // invokes delegate methods serially on its dispatch queue. eventContinuation.yield(.stateUpdate) } - func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, + func discovered( + peripheral: CBPeripheral, advertisementData: [String: Any], - rssi RSSI: NSNumber + rssi: Int ) { // Ferry the non-Sendable CBPeripheral and advertisement dictionary across the actor isolation hop in a // single-purpose payload. They are extracted into Sendable types (Peripheral / AdvertisementData) inside // the actor. - let payload = DiscoveryPayload(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI.intValue) + let payload = DiscoveryPayload(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi) eventContinuation.yield(.discovered(payload)) } - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + func connected(peripheral: CBPeripheral) { let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: false, error: nil) eventContinuation.yield(.connected(payload)) } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + + func connectFailed(peripheral: CBPeripheral, error: Error?) { let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: false, error: error) eventContinuation.yield(.connectFailed(payload)) } - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { + func disconnected(peripheral: CBPeripheral, isReconnecting: Bool, error: Error?) { let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: isReconnecting, error: error) eventContinuation.yield(.disconnected(payload)) } - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + func willRestore(state: [String: Any]) { // First callback on relaunch when a restore identifier was used. Ferry the non-Sendable // restoration dictionary across the actor hop; extraction happens inside the actor. - eventContinuation.yield(.willRestore(RestorationPayload(state: dict))) + eventContinuation.yield(.willRestore(RestorationPayload(state: state))) + } +} + +/// Non-restoring `CBCentralManagerDelegate` bridge into ``DelegateEventForwarder``. +/// +/// Intentionally does **not** implement `centralManager(_:willRestoreState:)`. Real CoreBluetooth +/// uses ObjC `responds(to:)` and logs API MISUSE when that method is present without a restore +/// identifier. Use ``RestoringBluetoothDelegateShim`` when restoration is enabled. +/// +/// - Important: Keep the five shared callbacks below in lockstep with +/// ``RestoringBluetoothDelegateShim`` (same signatures, same forwarder calls). Drift silently +/// drops events on one path. Shared ferrying lives only in ``DelegateEventForwarder``. +final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { + private let forwarder: DelegateEventForwarder + + fileprivate init(forwarder: DelegateEventForwarder) { + self.forwarder = forwarder + super.init() + } + + // MARK: Shared callbacks — keep in sync with RestoringBluetoothDelegateShim + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + forwarder.stateUpdate() + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + forwarder.discovered(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI.intValue) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + forwarder.connected(peripheral: peripheral) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + forwarder.connectFailed(peripheral: peripheral, error: error) + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Bool, + error: Error? + ) { + forwarder.disconnected(peripheral: peripheral, isReconnecting: isReconnecting, error: error) + } +} + +/// Restoring `CBCentralManagerDelegate` bridge into ``DelegateEventForwarder``. +/// +/// Only installed when ``ReliaBLEConfig/restoreIdentifier`` is non-`nil`, so the central is always +/// created with a matching `CBCentralManagerOptionRestoreIdentifierKey`. +/// +/// Peer of ``BluetoothDelegateShim`` (not a subclass): CoreBluetoothMock dispatches +/// `willRestoreState` via an unconditional Swift protocol call (extension default is a no-op), so +/// each type needs its own witness table. Real CoreBluetooth additionally needs the method absent +/// on the non-restoring peer for ObjC `responds(to:)` / API MISUSE. +/// +/// - Important: Keep the five shared callbacks below in lockstep with ``BluetoothDelegateShim`` +/// (same signatures, same forwarder calls). Drift silently drops events on one path. +final class RestoringBluetoothDelegateShim: NSObject, CBCentralManagerDelegate { + private let forwarder: DelegateEventForwarder + + fileprivate init(forwarder: DelegateEventForwarder) { + self.forwarder = forwarder + super.init() + } + + // MARK: Shared callbacks — keep in sync with BluetoothDelegateShim + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + forwarder.stateUpdate() + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + forwarder.discovered(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI.intValue) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + forwarder.connected(peripheral: peripheral) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + forwarder.connectFailed(peripheral: peripheral, error: error) + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Bool, + error: Error? + ) { + forwarder.disconnected(peripheral: peripheral, isReconnecting: isReconnecting, error: error) + } + + // MARK: Restoration-only + + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + forwarder.willRestore(state: dict) } } diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index 9ed8a15..44649d1 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -22,9 +22,9 @@ This is a temporary overview. ### Concurrency & Isolation - -- ``ReliaBLEManager`` ### Advanced Usage - - +- diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 3bae4b6..b4fc592 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -22,6 +22,8 @@ bleConfig.loggingEnabled = true let bleManager = ReliaBLEManager(config: bleConfig) ``` +Most apps need a single ``ReliaBLEManager`` for the lifetime of the process. If you do create more than one, each is a **fully isolated stack** — its own actor, `CBCentralManager`, discovered peripherals, connection state, and streams — configured independently by the config you pass. Two rules apply when running managers side by side: Bluetooth authorization is process-global (authorizing one manager authorizes them all), and any ``ReliaBLEConfig/restoreIdentifier`` must be unique among simultaneously-live managers while remaining stable across launches. See for the full model. + ## Authorizing Bluetooth iOS requires permission from the user for BLE access. To set this up in your project: @@ -167,8 +169,6 @@ config.reconnectPolicy.jitter = 0.2 // ±20% randomization let bleManager = ReliaBLEManager(config: config) ``` -> Important: `ReconnectPolicy` (and logging configuration) is applied only during the **first** `ReliaBLEManager` initialization (the first actor setup) behind the library's process-wide actor singleton. Constructing a second `ReliaBLEManager(config:)` in the same process will **not** update the already-stashed policy — set your desired config before creating the first manager instance. - > Note: iOS's Tier-0 auto-reconnect give-up budget and timing (`CBConnectPeripheralOptionEnableAutoReconnect`) are not publicly documented by Apple. The exact retry duration and failure threshold still require on-device verification — this is deliberately deferred follow-up work. ```swift diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index 3405fb7..4381196 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -13,16 +13,22 @@ without manual locking or forced actor hops. ``ReliaBLEManager`` is a `nonisolated`, `Sendable` `final class`. It owns no mutable state itself; instead it forwards every operation to an internal -`@globalActor` (`BluetoothActor`) that serializes all Core Bluetooth -interactions. Because the manager is `Sendable` and nonisolated, you can hold a -single instance and share it freely across isolation domains — there is no -implicit `@MainActor` requirement and no forced main-thread hop. +`actor` (`BluetoothActor`) that serializes all Core Bluetooth interactions. +Because the manager is `Sendable` and nonisolated, you can hold a single +instance and share it freely across isolation domains — there is no implicit +`@MainActor` requirement and no forced main-thread hop. + +`BluetoothActor` is a plain `actor` **instance owned by each manager** — not a +`@globalActor` and not a shared singleton. Every ``ReliaBLEManager`` creates and +holds its own actor, so concurrent calls into one manager are serialized on that +manager's actor alone. For running more than one manager at once, see +. ``` ReliaBLEManager (nonisolated, Sendable) │ forwards async calls ▼ -BluetoothActor (@globalActor, internal) +BluetoothActor (per-manager actor instance, internal) │ serializes all access ▼ CBCentralManager delegate shim @@ -34,13 +40,16 @@ CoreBluetooth `BluetoothActor` is an **internal** implementation detail. Consumers must not reference it; interact only through ``ReliaBLEManager``. -> Note: The internal central manager is created lazily and only once Bluetooth -> authorization is `.allowedAlways` — the permission prompt stays under your -> app's control via ``ReliaBLEManager/authorizeBluetooth()``. The one exception: -> when ``ReliaBLEConfig/restoreIdentifier`` is set and authorization was already -> granted, the central is created eagerly at ``ReliaBLEManager`` init so state -> restoration can deliver its `willRestoreState` callback on relaunch. See -> for details. +> Note: `ReliaBLEManager` init never prompts for Bluetooth permission. Creating +> the internal central stays gated on existing `.allowedAlways` authorization so +> the prompt remains under your app's control via +> ``ReliaBLEManager/authorizeBluetooth()``. When authorization is already +> `.allowedAlways`, init eagerly creates the central (via a fire-and-forget +> `Task` into `BluetoothActor`) so a live stack is ready immediately. +> ``ReliaBLEConfig/restoreIdentifier`` only affects the options passed at that +> creation — it does not change *when* the central is created — so +> `willRestoreState` can be delivered on relaunch. See for +> details. ### Calling actions diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md b/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md new file mode 100644 index 0000000..2b3148b --- /dev/null +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md @@ -0,0 +1,52 @@ +# Multi-Manager + +Patterns beyond the single-manager happy path: running more than one +``ReliaBLEManager``, and how configuration applies when you do. + +## Overview + +Most apps hold a single ``ReliaBLEManager`` for the process lifetime. When you +need more than one — for example, separate stacks for different product +features or test harnesses that drive two live centrals side by side — each +manager is an independent unit. This topic covers that multi-manager model and +the few process-level rules that still apply. + +Each ``ReliaBLEManager`` is a **fully isolated stack**: its own actor, +`CBCentralManager`, discovered-peripheral snapshots, connection state, and event +streams. Constructing a second manager gives you a second, independent stack — +their discovered peripherals, connection state, and streams never cross over. +Two managers scanning the same physical device each hold their own +``Peripheral`` snapshot; there is no shared, cross-manager discovered list. + +Configuration — including `ReconnectPolicy` and logging — is applied **per +manager**. The config you pass to `init(config:)` governs only that instance. +Constructing a second `ReliaBLEManager(config:)` with different settings gives +you a second, independently-configured stack; it does not affect the first. + +A few things are shared at the process level and are worth keeping in mind when +you run more than one manager at once: + +- **Authorization is process-global.** `CBCentralManager.authorization` is + app-wide, so calling ``ReliaBLEManager/authorizeBluetooth()`` on one manager + affects every other manager in the process. Stacks are isolated in *state*, + not in *permission*. +- **The Bluetooth radio is shared.** Running multiple concurrent, aggressive + scans degrades all of them — prefer a single scanning manager, or coordinate + scan windows yourself. +- **Restore identifiers must be unique among simultaneously-live managers.** If + two live managers use the same ``ReliaBLEConfig/restoreIdentifier`` they + contend for the same reconnect-intent storage and CoreBluetooth's per-id + restoration domain, which is unsupported. Reusing the *same* identifier across + app launches, however, is **required** for state restoration to work. See + . + +> Note: A live subscriber stream retains its manager's stack until the stream +> terminates. As long as you are iterating one of the event streams +> (``ReliaBLEManager/state``, ``ReliaBLEManager/discoveredPeripherals``, +> ``ReliaBLEManager/peripheralDiscoveries``, or +> ``ReliaBLEManager/connectionStateChanges``), the underlying actor and central +> stay alive — dropping your reference to the manager alone is not enough to +> tear the stack down. + +For how each manager serializes Core Bluetooth work on its own actor, see +. diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index d8fdf03..1eeae57 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -32,14 +32,19 @@ import Willow /// The main entry point for the ReliaBLE library. /// /// `ReliaBLEManager` is a `nonisolated`, `Sendable` value-like façade: it owns no mutable state and -/// forwards every operation to a process-wide internal actor that serializes all Core Bluetooth +/// forwards every operation to its owned `BluetoothActor` that serializes all Core Bluetooth /// interactions. Because it is not bound to any actor, it is callable directly from `@MainActor` /// SwiftUI code *and* from background actors without forcing a main-actor hop on background callers. public final class ReliaBLEManager: Sendable { public let loggingService: LoggingService - - private let log: LoggingService - + + /// Per-manager BLE stack. Internal so `@testable` tests can reach actor hooks. + /// + /// Retain graph (no cycles): manager → actor → central → shim; shim holds only the + /// event-pipeline continuation (no actor ref). `delegateEventTask` and reconnect tasks + /// capture `[weak self]`. Live stream subscribers retain the actor until terminated. + let bluetooth: BluetoothActor + /// Initializes the ReliaBLEManager with the provided configuration, or a default configuration if none is provided. /// /// Initializing a ReliaBLEManager does not start the `CBCentralManager` unless the user has already authorized @@ -51,23 +56,23 @@ public final class ReliaBLEManager: Sendable { public init(config: ReliaBLEConfig = ReliaBLEConfig()) { loggingService = LoggingService(levels: config.logLevels, writers: config.logWriters, queue: config.logQueue) loggingService.enabled = config.loggingEnabled - - log = loggingService - - // `init` stays synchronous and kicks off one-time actor setup via a fire-and-forget `Task` + + bluetooth = BluetoothActor( + log: loggingService, + reconnectPolicy: config.reconnectPolicy, + restoreIdentifier: config.restoreIdentifier + ) + + // `init` stays synchronous and kicks off central creation via a fire-and-forget `Task` // rather than awaiting it, so the initializer never blocks. To prevent an operation invoked - // immediately after `init` from racing ahead of that setup, every public entry point funnels - // through `ensureInitialized(log:)` (which is idempotent) before acting — so this eager call - // is an optimization, not a correctness requirement. + // immediately after `init` from racing ahead of that setup, every operational entry point + // funnels through `ensureCentralManager()` (which is idempotent) before acting — so this + // eager call is an optimization, not a correctness requirement. Task { - await BluetoothActor.shared.ensureInitialized( - log: loggingService, - reconnectPolicy: config.reconnectPolicy, - restoreIdentifier: config.restoreIdentifier - ) + await bluetooth.ensureCentralManager() } } - + // MARK: - State /// A multi-subscriber `AsyncStream` of real-time state changes of the underlying Core Bluetooth @@ -81,16 +86,16 @@ public final class ReliaBLEManager: Sendable { /// } /// ``` public var state: AsyncStream { - BluetoothActor.shared.stateStream() + bluetooth.stateStream() } - + /// Asynchronous, thread-safe access to the current state of the underlying Core Bluetooth /// system. The read is serialized on the library's internal concurrency domain, so the access /// is `await`-ed. public var currentState: BluetoothState { - get async { await BluetoothActor.shared.currentBluetoothState } + get async { await bluetooth.currentBluetoothState } } - + /// A multi-subscriber `AsyncStream` of connection-state changes for all peripherals. /// /// Each property access returns a fresh, independent stream. This stream does **not** replay @@ -102,17 +107,17 @@ public final class ReliaBLEManager: Sendable { /// } /// ``` public var connectionStateChanges: AsyncStream { - BluetoothActor.shared.connectionStateChangesStream() + bluetooth.connectionStateChangesStream() } - + /// An async snapshot of the current per-peripheral connection states, useful for seeding /// a view on appearance without waiting for the next change event. public var currentConnectionStates: [String: ConnectionState] { - get async { await BluetoothActor.shared.currentConnectionStates } + get async { await bluetooth.currentConnectionStates } } - + // MARK: - Authorization - + /// Requests authorization to use Bluetooth, presenting the iOS permission prompt when authorization has not yet /// been determined. /// @@ -122,19 +127,19 @@ public final class ReliaBLEManager: Sendable { /// /// - Throws: An ``AuthorizationError`` if the user has denied or restricted Bluetooth access. public func authorizeBluetooth() async throws { - await BluetoothActor.shared.ensureInitialized(log: log) + await bluetooth.ensureCentralManager() // Own the cancellation wiring here, in the nonisolated façade. When authorization is // undetermined the actor suspends until the decision resolves; cancelling the calling task // unblocks that wait with a `CancellationError` rather than hanging indefinitely. let id = UUID() try await withTaskCancellationHandler { - try await BluetoothActor.shared.authorize(id: id) + try await bluetooth.authorize(id: id) } onCancel: { - Task { await BluetoothActor.shared.cancelAuthorizationContinuation(id) } + Task { await bluetooth.cancelAuthorizationContinuation(id) } } } - + // MARK: - Scanning /// A multi-subscriber `AsyncStream` that emits peripheral discovery events during scanning. It @@ -145,16 +150,16 @@ public final class ReliaBLEManager: Sendable { /// ``discoveredPeripherals`` this stream does **not** replay a value on subscription — subscribe /// before you start scanning to avoid missing early advertisements. public var peripheralDiscoveries: AsyncStream { - BluetoothActor.shared.peripheralDiscoveriesStream() + bluetooth.peripheralDiscoveriesStream() } /// A multi-subscriber `AsyncStream` that emits the current de-duplicated list of discovered /// peripherals each time it changes. Each property access returns a fresh, independent stream; /// the current list is replayed as the first element on subscription. public var discoveredPeripherals: AsyncStream<[Peripheral]> { - BluetoothActor.shared.discoveredPeripheralsStream() + bluetooth.discoveredPeripheralsStream() } - + /// Starts scanning for peripheral devices, optionally filtering by specific services. /// /// - Parameter services: An optional array of `CBUUID` objects representing the services to scan for. If provided, @@ -163,16 +168,16 @@ public final class ReliaBLEManager: Sendable { /// - 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 { - await BluetoothActor.shared.ensureInitialized(log: log) - await BluetoothActor.shared.startScanning(services: services) + await bluetooth.ensureCentralManager() + await bluetooth.startScanning(services: services) } - + /// Stops scanning for peripheral devices. public func stopScanning() async { - await BluetoothActor.shared.ensureInitialized(log: log) - await BluetoothActor.shared.stopScanning() + await bluetooth.ensureCentralManager() + await bluetooth.stopScanning() } - + // MARK: - Connection /// Initiates a connection to a previously discovered peripheral. @@ -189,8 +194,8 @@ public final class ReliaBLEManager: Sendable { /// snapshot), or ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up (for example, not /// yet authorized). public func connect(to peripheral: Peripheral, autoReconnect: Bool = true) async throws { - await BluetoothActor.shared.ensureInitialized(log: log) - try await BluetoothActor.shared.connect(id: peripheral.id, autoReconnect: autoReconnect) + await bluetooth.ensureCentralManager() + try await bluetooth.connect(id: peripheral.id, autoReconnect: autoReconnect) } /// Initiates a disconnection from a previously connected peripheral. @@ -202,8 +207,8 @@ public final class ReliaBLEManager: Sendable { /// - Throws: ``PeripheralError/notFound`` if the peripheral's live reference has been /// invalidated, or ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up. public func disconnect(from peripheral: Peripheral) async throws { - await BluetoothActor.shared.ensureInitialized(log: log) - try await BluetoothActor.shared.disconnect(id: peripheral.id) + await bluetooth.ensureCentralManager() + try await bluetooth.disconnect(id: peripheral.id) } } @@ -240,7 +245,7 @@ public enum BluetoothState: Sendable { /// This is a temporary state. After Core Bluetooth initializes or resets, ReliaBLE updates the /// state value. case unknown - + /// A user-friendly string representation of the `BluetoothState`. /// /// - Returns: A string describing the `BluetoothState`. diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index dd2fd68..a30a224 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -27,7 +27,7 @@ import Foundation import Testing -import CoreBluetoothMock +@preconcurrency import CoreBluetoothMock import Willow @testable import ReliaBLEMock @@ -35,16 +35,16 @@ import Willow /// All ReliaBLE behavioral tests live in a single **serialized** suite. /// -/// Two process-wide singletons make parallel execution unsafe: -/// 1. ``BluetoothActor/shared`` is a process-lifetime actor whose `CBCentralManager` is created once and never -/// torn down (the `centralManager == nil` guard in `setupCentralManager()`). -/// 2. Nordic's `CBMCentralManagerMock` keeps global static simulation state (authorization, power, peripherals). +/// Each test owns a fresh ``ReliaBLEManager`` / ``BluetoothActor`` stack via ``Mock/makeManager``. +/// Stacks are instance-isolated; Nordic's `CBMCentralManagerMock` globals (authorization, power, +/// peripheral specs, `simulateStateRestoration`) remain process-wide, so `.serialized` keeps tests +/// from racing the mock. /// -/// `.serialized` guarantees no two tests mutate that shared state concurrently — without it, a scan started by one -/// test would deliver advertisements into another test's `peripheralDiscoveries` subscriber. Every test creates its -/// manager via ``Mock/makeManager(loggingEnabled:)`` (which registers the simulated peripheral and pins -/// authorization before any central can be created), and stateful tests re-establish their baseline via -/// ``Mock/ensureReady(_:)``, so the suite is order-independent. +/// **Lifetime:** by default the suite keeps one active stack — ``makeManager`` tears down the +/// previous via ``tearDown(_:)``. Cold-relaunch tests shut down stack 1 without resetting mock +/// connections, install a spec-based `simulateStateRestoration` fixture, then build stack 2 with +/// the same restore id so central init delivers faithful `willRestoreState`. Always clear +/// `simulateStateRestoration` in a `defer`. @Suite(.serialized) struct ReliaBLEManagerTests { @@ -59,9 +59,11 @@ struct ReliaBLEManagerTests { await Task.detached { _ = manager.loggingService _ = await manager.currentState - _ = manager.state - _ = manager.peripheralDiscoveries - _ = manager.discoveredPeripherals + // Compile-time proof that stream factories stay `nonisolated` — sync getters must not require `await`. + let _: AsyncStream = manager.state + let _: AsyncStream = manager.peripheralDiscoveries + let _: AsyncStream<[Peripheral]> = manager.discoveredPeripherals + let _: AsyncStream = manager.connectionStateChanges await manager.startScanning() await manager.startScanning(services: []) await manager.stopScanning() @@ -233,20 +235,56 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) CBMCentralManagerMock.simulateAuthorization(.denied) - await BluetoothActor.shared.updateState() + await manager.bluetooth.updateState() #expect(await manager.currentState.description == "Denied") CBMCentralManagerMock.simulateAuthorization(.restricted) - await BluetoothActor.shared.updateState() + await manager.bluetooth.updateState() #expect(await manager.currentState.description == "Restricted") CBMCentralManagerMock.simulateAuthorization(.notDetermined) - await BluetoothActor.shared.updateState() + 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 BluetoothActor.shared.updateState() + await manager.bluetooth.updateState() + } + + @Test func freshManagerBroadcastsUnauthorizedNotDeterminedWithoutCentral() async throws { + // 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) + let manager = await Mock.makeManager() + + #expect(await Mock.waitForState("Not Authorized", on: manager)) + + var iterator = manager.state.makeAsyncIterator() + let replayed = await iterator.next() + #expect(replayed?.description == "Not Authorized") + } + + @Test func streamSubscriptionAfterShutdownCompletesImmediately() async throws { + // A stack torn down via shutdown() is terminal. Every stream factory registers its + // continuation on the actor; without a guard, a stream created after shutdown() would + // insert a live-but-orphaned continuation that never finishes, hanging the consumer's + // `for await` forever. Each registrar must instead finish the continuation immediately, + // symmetric with how shutdown() finishes already-registered subscribers. + let manager = await Mock.makeManager() + await manager.bluetooth.shutdown() + + var stateIterator = manager.state.makeAsyncIterator() + #expect(await stateIterator.next() == nil) + + var discoveryIterator = manager.peripheralDiscoveries.makeAsyncIterator() + #expect(await discoveryIterator.next() == nil) + + var peripheralsIterator = manager.discoveredPeripherals.makeAsyncIterator() + #expect(await peripheralsIterator.next() == nil) + + var connectionIterator = manager.connectionStateChanges.makeAsyncIterator() + #expect(await connectionIterator.next() == nil) } // MARK: - Scanning @@ -530,7 +568,9 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - let changes = manager.connectionStateChanges + var changes = manager.connectionStateChanges.makeAsyncIterator() + // Force an actor hop so registration completes before we connect. + await manager.bluetooth.updateState() // Discover the connectable test peripheral. await manager.startScanning() @@ -544,11 +584,11 @@ struct ReliaBLEManagerTests { try await manager.connect(to: discovered) - let connecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let connecting = await changes.next() #expect(connecting?.peripheralId == discovered.id) #expect(connecting?.state == .connecting) - let connected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let connected = await changes.next() #expect(connected?.peripheralId == discovered.id) #expect(connected?.state == .connected) } @@ -559,7 +599,8 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) - let changes = manager.connectionStateChanges + var changes = manager.connectionStateChanges.makeAsyncIterator() + await manager.bluetooth.updateState() await manager.startScanning() let peripheral = await Mock.waitForPeripheral( @@ -573,16 +614,16 @@ struct ReliaBLEManagerTests { try await manager.connect(to: discovered) // Drain .connecting and .connected. - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) - _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + _ = await changes.next() + _ = await changes.next() try await manager.disconnect(from: discovered) - let disconnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let disconnecting = await changes.next() #expect(disconnecting?.peripheralId == discovered.id) #expect(disconnecting?.state == .disconnecting) - let disconnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let disconnected = await changes.next() #expect(disconnected?.peripheralId == discovered.id) #expect(disconnected?.state == .disconnected(reason: nil)) } @@ -591,17 +632,13 @@ struct ReliaBLEManagerTests { // Pre-condition: no stale connection state from a preceding lifecycle test. Mock.connectionTestSpec.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) - // Drain any spurious events that simulateDisconnection may have injected - // into the actor's stream continuations. - await BluetoothActor.shared.updateState() - - Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) defer { Mock.connectionTestDelegate.connectionResult = .success(()) } let manager = await Mock.makeManager() await Mock.ensureReady(manager) - let changes = manager.connectionStateChanges + var changes = manager.connectionStateChanges.makeAsyncIterator() + await manager.bluetooth.updateState() await manager.startScanning() let peripheral = await Mock.waitForPeripheral( @@ -617,26 +654,19 @@ struct ReliaBLEManagerTests { Mock.connectionTestSpec.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) + // Configure failure only after discovery — a failed connectionResult can + // interfere with mock advertising while the previous stack tears down. + Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) + try await manager.connect(to: discovered) - var events: [ConnectionStateChange] = [] - for _ in 0..<3 { - if let change = await firstConnectionStateChange(from: changes, withinNanoseconds: 3_000_000_000) { - events.append(change) - } - } + let connecting = await changes.next() + let failed = await changes.next() - // The mock's connection callback fires on an async timer (0.045 s), so the - // time spent collecting events is well under the 3 s timeout per event. The - // failure test must see .connecting then .failed(reason: .connectionTimeout). - guard events.count >= 2 else { - #expect(Bool(false), "Expected at least 2 events, got \(events.count): \(events)") - return - } - #expect(events[0].peripheralId == discovered.id) - #expect(events[0].state == .connecting) - #expect(events[1].peripheralId == discovered.id) - #expect(events[1].state == .failed(reason: .connectionTimeout)) + #expect(connecting?.peripheralId == discovered.id) + #expect(connecting?.state == .connecting) + #expect(failed?.peripheralId == discovered.id) + #expect(failed?.state == .failed(reason: .connectionTimeout)) } @Test func connectionStateChangesSupportsConcurrentSubscribers() async throws { @@ -691,9 +721,10 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) - let changes = manager.connectionStateChanges + var changes = manager.connectionStateChanges.makeAsyncIterator() + await manager.bluetooth.updateState() await manager.startScanning() let peripheral = await Mock.waitForPeripheral( @@ -707,16 +738,16 @@ struct ReliaBLEManagerTests { try await manager.connect(to: discovered) // Drain .connecting and .connected. - let c1 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let c1 = await changes.next() #expect(c1?.state == .connecting) - let c2 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let c2 = await changes.next() #expect(c2?.state == .connected) // Simulate an unexpected disconnect with the OS auto-reconnect option active. Mock.connectionTestSpec.simulateDisconnection() // Tier 0: OS sends isReconnecting=true → library emits .system with nil metadata. - let c3 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) + let c3 = await changes.next() guard case .reconnecting(let source, let attempt, let nextRetryAt) = c3?.state else { Issue.record("Expected .reconnecting, got \(String(describing: c3?.state))") return @@ -725,13 +756,15 @@ struct ReliaBLEManagerTests { #expect(attempt == nil) #expect(nextRetryAt == nil) - // No library ladder should have been armed — verify no .library reconnecting events. - let remaining = await drainConnectionStateChanges(from: changes, withinNanoseconds: 2_000_000_000) - let libraryReconnects = remaining.filter { - if case .reconnecting(.library, _, _) = $0.state { return true } + // No library ladder should have been armed — give the mock a beat to surface any + // further events; a library reconnect would land in connectionStates. + try? await Task.sleep(nanoseconds: 500_000_000) + let states = await manager.currentConnectionStates + let libraryActive = states.values.contains { state in + if case .reconnecting(.library, _, _) = state { return true } return false } - #expect(libraryReconnects.isEmpty, "Expected no .library reconnect events, got \(libraryReconnects.count)") + #expect(!libraryActive, "Expected no .library reconnect state, got \(states)") // Cleanup: explicit disconnect to cancel any pending reconnect state. try? await manager.disconnect(from: discovered) @@ -751,7 +784,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: giveUpPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(giveUpPolicy) + await manager.bluetooth.setReconnectPolicy(giveUpPolicy) let changes = manager.connectionStateChanges @@ -820,7 +853,7 @@ struct ReliaBLEManagerTests { try? await manager.disconnect(from: discovered) var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 - await BluetoothActor.shared.setReconnectPolicy(cleanup) + await manager.bluetooth.setReconnectPolicy(cleanup) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -829,7 +862,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) let changes = manager.connectionStateChanges @@ -872,7 +905,7 @@ struct ReliaBLEManagerTests { // Cleanup: prevent further reconnect attempts from interfering with subsequent tests. var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 - await BluetoothActor.shared.setReconnectPolicy(cleanup) + await manager.bluetooth.setReconnectPolicy(cleanup) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -882,7 +915,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) let changes = manager.connectionStateChanges @@ -924,7 +957,7 @@ struct ReliaBLEManagerTests { try? await manager.disconnect(from: discovered) var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 - await BluetoothActor.shared.setReconnectPolicy(cleanup) + await manager.bluetooth.setReconnectPolicy(cleanup) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -939,7 +972,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) let changes = manager.connectionStateChanges @@ -962,7 +995,7 @@ struct ReliaBLEManagerTests { // Inject an unexpected drop via the test hook (mock would emit isReconnecting: false anyway // since the OS option wasn't passed, but we use the hook for explicitness). - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) let states = events.map { $0.state } @@ -982,7 +1015,7 @@ struct ReliaBLEManagerTests { try? await manager.disconnect(from: discovered) var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 - await BluetoothActor.shared.setReconnectPolicy(cleanup) + await manager.bluetooth.setReconnectPolicy(cleanup) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -991,7 +1024,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) let changes = manager.connectionStateChanges @@ -1014,7 +1047,7 @@ struct ReliaBLEManagerTests { _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // Inject OS give-up: isReconnecting: false unexpected disconnect. - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) // Observe .disconnected(reason:). let c1 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1037,7 +1070,7 @@ struct ReliaBLEManagerTests { try? await manager.disconnect(from: discovered) var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 - await BluetoothActor.shared.setReconnectPolicy(cleanup) + await manager.bluetooth.setReconnectPolicy(cleanup) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1055,7 +1088,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: slowPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(slowPolicy) + await manager.bluetooth.setReconnectPolicy(slowPolicy) let changes = manager.connectionStateChanges @@ -1074,7 +1107,7 @@ struct ReliaBLEManagerTests { _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // Arm the library ladder, then cancel it mid-sleep with an explicit disconnect. - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) let disconnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) guard case .disconnected = disconnected?.state else { @@ -1105,7 +1138,7 @@ struct ReliaBLEManagerTests { #expect(!hasConnecting, "Expected no reconnect .connecting after explicit cancel, got \(states)") #expect(!hasLibraryRetry, "Expected no further library retries after explicit cancel, got \(states)") - await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1114,7 +1147,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: ReconnectPolicy(maxAttempts: 0)) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) let changes = manager.connectionStateChanges @@ -1133,8 +1166,8 @@ struct ReliaBLEManagerTests { // Simulate an explicit disconnect that CoreBluetooth reports WITH a benign underlying error. // The contract is that an app-initiated disconnect reports `reason: nil` regardless. - await BluetoothActor.shared.testSeedIntentionalDisconnect(discovered.id) - await BluetoothActor.shared.testInjectDisconnect( + await manager.bluetooth.testSeedIntentionalDisconnect(discovered.id) + await manager.bluetooth.testInjectDisconnect( for: discovered.id, isReconnecting: false, error: NSError(domain: "test.explicit", code: 1) @@ -1146,7 +1179,7 @@ struct ReliaBLEManagerTests { "Explicit disconnect must report a clean nil reason even when CoreBluetooth supplies an error, got \(String(describing: disconnected?.state))" ) - await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1164,7 +1197,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: hostilePolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(hostilePolicy) + await manager.bluetooth.setReconnectPolicy(hostilePolicy) let changes = manager.connectionStateChanges @@ -1182,7 +1215,7 @@ struct ReliaBLEManagerTests { _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected // Unexpected drop arms the library ladder; scheduling must survive the non-finite delay. - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected let reconnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1193,7 +1226,7 @@ struct ReliaBLEManagerTests { #expect(attempt == 1) try await manager.disconnect(from: discovered) - await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1202,7 +1235,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: Self.testReconnectPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(Self.testReconnectPolicy) + await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) let changes = manager.connectionStateChanges @@ -1221,7 +1254,7 @@ struct ReliaBLEManagerTests { _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // First unexpected drop → ladder attempt 1, then successful reconnect. - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected let firstLadder = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1238,7 +1271,7 @@ struct ReliaBLEManagerTests { #expect(reconnectConnected?.state == .connected) // Second unexpected drop must start a fresh ladder at attempt 1, not continue at 2. - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected let secondLadder = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1249,7 +1282,7 @@ struct ReliaBLEManagerTests { #expect(secondAttempt == 1) try? await manager.disconnect(from: discovered) - await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1258,11 +1291,11 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) let id = "intentional-seed" - await BluetoothActor.shared.testSeedIntentionalDisconnect(id) - #expect(await BluetoothActor.shared.testContainsIntentionalDisconnect(id)) + await manager.bluetooth.testSeedIntentionalDisconnect(id) + #expect(await manager.bluetooth.testContainsIntentionalDisconnect(id)) - await BluetoothActor.shared.testInvalidatePeripherals() - #expect(!(await BluetoothActor.shared.testContainsIntentionalDisconnect(id))) + await manager.bluetooth.testInvalidatePeripherals() + #expect(!(await manager.bluetooth.testContainsIntentionalDisconnect(id))) } @Test func giveUpThenUnexpectedDropRearmsFreshLadder() async throws { @@ -1278,7 +1311,7 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager(reconnectPolicy: giveUpPolicy) await Mock.ensureReady(manager) - await BluetoothActor.shared.setReconnectPolicy(giveUpPolicy) + await manager.bluetooth.setReconnectPolicy(giveUpPolicy) var changes = manager.connectionStateChanges.makeAsyncIterator() // Force an actor hop so registration completes before we connect. @@ -1326,7 +1359,7 @@ struct ReliaBLEManagerTests { } // Intent survives give-up: a later unexpected drop must arm a fresh ladder at attempt 1. - await BluetoothActor.shared.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) let s5 = await changes.next() guard case .disconnected = s5?.state else { @@ -1342,7 +1375,7 @@ struct ReliaBLEManagerTests { #expect(a2 == 1) try? await manager.disconnect(from: discovered) - await BluetoothActor.shared.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) + await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1362,365 +1395,317 @@ struct ReliaBLEManagerTests { // must preserve the lazy contract: no central until authorize / allowedAlways. CBMCentralManagerMock.simulateAuthorization(.notDetermined) - // Only meaningful on a cold process where no earlier test created the singleton central. - let alreadyHadCentral = await BluetoothActor.shared.hasCentralManager let manager = await Mock.makeManager(restoreIdentifier: nil) try? await Task.sleep(nanoseconds: 200_000_000) - if !alreadyHadCentral { - #expect(!(await BluetoothActor.shared.hasCentralManager)) - } + #expect(!(await manager.bluetooth.hasCentralManager)) // Config default remains nil on the value type regardless of actor lifetime. #expect(ReliaBLEConfig().restoreIdentifier == nil) - _ = manager } @Test func ensureInitializedWithRestoreIdentifierCreatesCentralWhenAuthorized() async throws { let restoreId = "com.five3apps.relia-ble.tests.restore" // When authorized, a restoreIdentifier does not relax the auth gate — it only adds the - // restore-id option to the existing creation path. Process-lifetime central may already - // exist; still verify construction + ensureReady succeeds with the config set. + // restore-id option to the existing creation path. CBMCentralManagerMock.simulateAuthorization(.allowedAlways) CBMCentralManagerMock.simulatePowerOn() let manager = await Mock.makeManager(restoreIdentifier: restoreId) - // firstInitialization only runs once per process; set the id so later assertions on the - // stored value are meaningful even if this suite is not first to touch the actor. - await BluetoothActor.shared.testSetRestoreIdentifier(restoreId) - #expect(await BluetoothActor.shared.testRestoreIdentifier() == restoreId) - - // Unit-level wiring check: with the id set, the options dictionary handed to the factory - // contains the restore key (and without one, no options at all). End-to-end factory option - // fidelity is deferred to #42. - let optionKeys = await BluetoothActor.shared.testCentralCreationOptionKeys() + #expect(await manager.bluetooth.testRestoreIdentifier() == restoreId) + + let optionKeys = await manager.bluetooth.testCentralCreationOptionKeys() #expect(optionKeys.contains(CBMCentralManagerOptionRestoreIdentifierKey)) - await BluetoothActor.shared.testSetRestoreIdentifier(nil) - #expect(await BluetoothActor.shared.testCentralCreationOptionKeys().isEmpty) - await BluetoothActor.shared.testSetRestoreIdentifier(restoreId) await Mock.ensureReady(manager) - #expect(await BluetoothActor.shared.hasCentralManager) + #expect(await manager.bluetooth.hasCentralManager) + // Restore-id option and restoring peer shim are installed together (never disagree). + #expect(await manager.bluetooth.testDelegateIsRestoringShim()) + #expect(!(await manager.bluetooth.testDelegateIsNonRestoringShim())) + + let noRestore = await Mock.makeManager(restoreIdentifier: nil) + #expect(await noRestore.bluetooth.testCentralCreationOptionKeys().isEmpty) + await Mock.ensureReady(noRestore) + #expect(await noRestore.bluetooth.hasCentralManager) + #expect(await noRestore.bluetooth.testDelegateIsNonRestoringShim()) + #expect(!(await noRestore.bluetooth.testDelegateIsRestoringShim())) } @Test func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - - let manager = await Mock.makeManager() - await Mock.ensureReady(manager) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } - // Reconnect intent is persisted per restore identifier; pin a test-unique id and start - // from a clean persisted set so the re-arm assertion below is meaningful. - await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-broadcasts") - await BluetoothActor.shared.testClearPersistedReconnectIntent() + let restoreId = "com.five3apps.relia-ble.tests.restore-broadcasts" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager.startScanning() + await manager1.startScanning() let peripheral = await Mock.waitForPeripheral( id: Mock.connectionTestPeripheralID, - on: manager, + on: manager1, withinNanoseconds: 3_000_000_000 ) let discovered = try #require(peripheral) - await manager.stopScanning() + await manager1.stopScanning() - try await manager.connect(to: discovered) - let connected = await firstConnectionStateChange( - from: manager.connectionStateChanges, - withinNanoseconds: 5_000_000_000 - ) - // Drain connecting → connected if needed. - if connected?.state == .connecting { - let c2 = await firstConnectionStateChange( - from: manager.connectionStateChanges, - withinNanoseconds: 5_000_000_000 - ) - #expect(c2?.state == .connected) - } else { - #expect(connected?.state == .connected) + try await manager1.connect(to: discovered) + _ = await pollUntil(timeout: 3.0) { + await manager1.currentConnectionStates[discovered.id] == .connected } + #expect(await manager1.bluetooth.testPersistedReconnectIntent().contains(discovered.id)) - // connect(autoReconnect: true) persisted Tier-1 intent under the pinned restore id. - #expect(await BluetoothActor.shared.testPersistedReconnectIntent().contains(discovered.id)) + // Cold relaunch: shut down stack 1. Central deinit may zero virtualConnections, so + // re-mark the spec connected before install — persisted intent survives in UserDefaults. + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() - // Simulate cold relaunch: drop snapshots / in-memory intent while keeping live CBPeripheral - // refs so the restore path can rehydrate from the hand-built dictionary. Persisted intent - // survives, mirroring UserDefaults across process death. - await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() - #expect(await manager.currentConnectionStates[discovered.id] == nil) - #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) - - // Subscribe before restore so broadcasts are not missed. Note: no peripheralDiscoveries - // subscription — restored peripherals are deliberately kept off the advertisement feed. - // - // Stream registration is asynchronous: `makeAsyncIterator()` only *schedules* the - // continuation registration on a detached `@BluetoothActor` hop. The connection-state feed - // does not replay, so if the restore broadcast below fires before that hop lands, the - // `.connected` event is dropped and `connectionChanges.next()` blocks forever (this is the - // 30-min CI/Xcode hang). A single actor hop is not enough slack; poll until both - // subscriptions have actually registered before invoking restore. - let baseConnectionSubscribers = await BluetoothActor.shared.testConnectionStateSubscriberCount() - let basePeripheralsSubscribers = await BluetoothActor.shared.testPeripheralsSubscriberCount() - var peripherals = manager.discoveredPeripherals.makeAsyncIterator() - var connectionChanges = manager.connectionStateChanges.makeAsyncIterator() + let scanUUID = CBMUUID(string: "180D") + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], + scanServices: [scanUUID] + ) + + CBMCentralManagerMock.simulateAuthorization(.notDetermined) + let manager2 = await Mock.makeManager(restoreIdentifier: restoreId) + let baseConn = await manager2.bluetooth.testConnectionStateSubscriberCount() + let basePeriph = await manager2.bluetooth.testPeripheralsSubscriberCount() + var peripherals = manager2.discoveredPeripherals.makeAsyncIterator() + var connectionChanges = manager2.connectionStateChanges.makeAsyncIterator() let subscriptionsReady = await pollUntil(timeout: 3.0) { - let connectionReady = await BluetoothActor.shared.testConnectionStateSubscriberCount() > baseConnectionSubscribers - let peripheralsReady = await BluetoothActor.shared.testPeripheralsSubscriberCount() > basePeripheralsSubscribers - return connectionReady && peripheralsReady + let connReady = await manager2.bluetooth.testConnectionStateSubscriberCount() > baseConn + let periphReady = await manager2.bluetooth.testPeripheralsSubscriberCount() > basePeriph + return connReady && periphReady } #expect(subscriptionsReady) - let scanUUID = CBUUID(string: "180D") - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [discovered.id], - scanServices: [scanUUID], - scanOptions: nil - ) + CBMCentralManagerMock.simulateAuthorization(.allowedAlways) + CBMCentralManagerMock.simulatePowerOn() + try await manager2.authorizeBluetooth() + + let connectionSeeded = await pollUntil(timeout: 3.0) { + let state = await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] + return state == .connected || state == .connecting + } + #expect(connectionSeeded) + // Mock may restore as .connecting when virtualConnections was cleared by central deinit; + // simulateConnection before install prefers .connected. Either way maps rehydrate. + #expect(await manager2.bluetooth.testContainsCBPeripheral(Mock.connectionTestPeripheralID)) + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) - // Maps rehydrated with discovery identity (name-based id preserved). - #expect(await BluetoothActor.shared.testContainsCBPeripheral(discovered.id)) - let restoredList = await BluetoothActor.shared.discoveredPeripherals - #expect(restoredList.contains(where: { $0.id == discovered.id })) - #expect(await manager.currentConnectionStates[discovered.id] == .connected) - #expect(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id)) + let restoredList = await manager2.bluetooth.discoveredPeripherals + #expect(restoredList.contains(where: { $0.id == Mock.connectionTestPeripheralID })) - // Streams broadcast restored state. + // Restored peripherals are kept off the advertisement feed; discoveredPeripherals replays. let peripheralsEvent = await peripherals.next() - #expect(peripheralsEvent?.contains(where: { $0.id == discovered.id }) == true) + #expect(peripheralsEvent?.contains(where: { $0.id == Mock.connectionTestPeripheralID }) == true) let connectionEvent = await connectionChanges.next() - #expect(connectionEvent?.peripheralId == discovered.id) - #expect(connectionEvent?.state == .connected) + #expect(connectionEvent?.peripheralId == Mock.connectionTestPeripheralID) + #expect( + connectionEvent?.state == .connected + || connectionEvent?.state == .connecting + ) - // Scan resumed (central is powered on). - #expect(await BluetoothActor.shared.testIsScanning()) - #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) + let scanSettled = await pollUntil(timeout: 3.0) { + let scanning = await manager2.bluetooth.testIsScanning() + let pending = await manager2.bluetooth.testPendingRestoredScanServices() + return scanning && pending == nil + } + #expect(scanSettled) - try? await manager.disconnect(from: discovered) - await manager.stopScanning() - await BluetoothActor.shared.testClearPersistedReconnectIntent() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } @Test func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { + // Faithful path: connected restore re-arms Tier-1 from persisted intent. + // Disconnected-peripheral seeding is a direct-handler unit test (item 4) — iOS never + // restores disconnected peripherals, and the mock always restores specs as + // connected/connecting based on virtualConnections. Mock.connectionTestDelegate.connectionResult = .success(()) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - - let manager = await Mock.makeManager() - await Mock.ensureReady(manager) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } - // Pin a test-unique restore id and start from a clean persisted-intent set. - await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-seeding") - await BluetoothActor.shared.testClearPersistedReconnectIntent() + let restoreId = "com.five3apps.relia-ble.tests.restore-seeding" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - // Discover both peripherals so we have live refs for restore. - await manager.startScanning() + await manager1.startScanning() let connectionPeripheral = await Mock.waitForPeripheral( id: Mock.connectionTestPeripheralID, - on: manager, + on: manager1, withinNanoseconds: 3_000_000_000 ) let connected = try #require(connectionPeripheral) - let scanPeripheral = await Mock.waitForPeripheral( - id: Mock.testPeripheralID, - on: manager, - withinNanoseconds: 3_000_000_000 - ) - let disconnected = try #require(scanPeripheral) - await manager.stopScanning() + await manager1.stopScanning() - try await manager.connect(to: connected) - _ = await firstConnectionStateChange( - from: manager.connectionStateChanges, - withinNanoseconds: 5_000_000_000 - ) - // Wait until connected (may already be past .connecting). + try await manager1.connect(to: connected) _ = await pollUntil(timeout: 3.0) { - await manager.currentConnectionStates[connected.id] == .connected + await manager1.currentConnectionStates[connected.id] == .connected } - await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [connected.id, disconnected.id], + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], scanServices: nil ) - #expect(await manager.currentConnectionStates[connected.id] == .connected) - // Re-armed because connect(autoReconnect: true) persisted intent before "process death". - #expect(await BluetoothActor.shared.testIsReconnectEnabled(connected.id)) - // Disconnected restored peripherals do not seed connectionStates / reconnectEnabled. - #expect(await manager.currentConnectionStates[disconnected.id] == nil) - #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(disconnected.id))) + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) + + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID)) - try? await manager.disconnect(from: connected) - await BluetoothActor.shared.testClearPersistedReconnectIntent() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } @Test func willRestoreDoesNotRearmReconnectWithoutPersistedIntent() async throws { Mock.connectionTestDelegate.connectionResult = .success(()) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - - let manager = await Mock.makeManager() - await Mock.ensureReady(manager) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } - await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-no-intent") - await BluetoothActor.shared.testClearPersistedReconnectIntent() + let restoreId = "com.five3apps.relia-ble.tests.restore-no-intent" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager.startScanning() + await manager1.startScanning() let peripheral = await Mock.waitForPeripheral( id: Mock.connectionTestPeripheralID, - on: manager, + on: manager1, withinNanoseconds: 3_000_000_000 ) let discovered = try #require(peripheral) - await manager.stopScanning() + await manager1.stopScanning() - // autoReconnect: false must not persist Tier-1 intent. - try await manager.connect(to: discovered, autoReconnect: false) + try await manager1.connect(to: discovered, autoReconnect: false) _ = await pollUntil(timeout: 3.0) { - await manager.currentConnectionStates[discovered.id] == .connected + await manager1.currentConnectionStates[discovered.id] == .connected } - #expect(!(await BluetoothActor.shared.testPersistedReconnectIntent().contains(discovered.id))) + #expect(!(await manager1.bluetooth.testPersistedReconnectIntent().contains(discovered.id))) - await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [discovered.id], + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], scanServices: nil ) + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) - // The OS-held connection is still surfaced to the app model… - #expect(await manager.currentConnectionStates[discovered.id] == .connected) - // …but Tier-1 reconnect stays disarmed because no intent was persisted. - #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) + #expect(await pollUntil(timeout: 3.0) { + await manager2.currentConnectionStates[Mock.connectionTestPeripheralID] == .connected + }) + #expect(!(await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID))) - try? await manager.disconnect(from: discovered) - // Wait for the disconnect to land so the mock peripheral resumes advertising - // before the next test scans for it. - _ = await pollUntil(timeout: 3.0) { - await manager.currentConnectionStates[discovered.id] != .connected - } - await BluetoothActor.shared.testClearPersistedReconnectIntent() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } @Test func willRestoreIgnoresEmptyScanServiceFilter() async throws { + // Direct-handler unit test: CoreBluetoothMock treats a non-nil (even empty) scan-services + // array as `isScanning = true` at restore-init, so the faithful fixture cannot express + // "empty filter ignored" without fighting the mock. Production still ignores empty filters. let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( - id: Mock.testPeripheralID, - on: manager, - withinNanoseconds: 3_000_000_000 - ) - let discovered = try #require(peripheral) - await manager.stopScanning() - - await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() - - // An empty restored filter is background-useless; restoration must neither re-issue the - // scan nor stash it as pending. - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [discovered.id], - scanServices: [] - ) + await manager.bluetooth.testHandleWillRestoreState(scanServices: []) - #expect(!(await BluetoothActor.shared.testIsScanning())) - #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) + #expect(!(await manager.bluetooth.testIsScanning())) + #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) } @Test func invalidatePeripheralsClearsRestoredStateAndIntent() async throws { - // Pre-condition: no stale connection from a preceding lifecycle test — a still-connected - // mock peripheral does not advertise, so discovery below would time out. - Mock.connectionTestSpec.simulateDisconnection() - try? await Task.sleep(nanoseconds: 100_000_000) - Mock.connectionTestDelegate.connectionResult = .success(()) - defer { Mock.connectionTestDelegate.connectionResult = .success(()) } - - let manager = await Mock.makeManager() - await Mock.ensureReady(manager) + defer { + Mock.connectionTestDelegate.connectionResult = .success(()) + Mock.clearStateRestoration() + } - await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-invalidate") - await BluetoothActor.shared.testClearPersistedReconnectIntent() + let restoreId = "com.five3apps.relia-ble.tests.restore-invalidate" + let manager1 = await Mock.makeManager(restoreIdentifier: restoreId) + await Mock.ensureReady(manager1) + await manager1.bluetooth.testClearPersistedReconnectIntent() - await manager.startScanning() + await manager1.startScanning() let peripheral = await Mock.waitForPeripheral( id: Mock.connectionTestPeripheralID, - on: manager, + on: manager1, withinNanoseconds: 3_000_000_000 ) let discovered = try #require(peripheral) - await manager.stopScanning() + await manager1.stopScanning() - try await manager.connect(to: discovered) + try await manager1.connect(to: discovered) _ = await pollUntil(timeout: 3.0) { - await manager.currentConnectionStates[discovered.id] == .connected + await manager1.currentConnectionStates[discovered.id] == .connected } - await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + await Mock.tearDown(manager1, resetMockConnections: false) + Mock.connectionTestSpec.simulateConnection() - // Restore while powered on: seeds connection state and re-arms from persisted intent. - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [discovered.id], + Mock.installStateRestoration( + restoreIdentifier: restoreId, + peripherals: [Mock.connectionTestSpec], scanServices: nil ) - #expect(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id)) + let manager2 = try await Mock.makeRestoredManager(restoreIdentifier: restoreId) - // Power off so a second restore stashes its scan filter as pending. - CBMCentralManagerMock.simulatePowerOff() - #expect(await Mock.waitForState("Powered Off", on: manager)) + #expect(await pollUntil(timeout: 3.0) { + await manager2.bluetooth.testIsReconnectEnabled(Mock.connectionTestPeripheralID) + }) + // Direct-handler: stash a pending restored scan, then invalidate (faithful second restore + // while powered off is awkward because mock forces isScanning at restore-init). let scanUUID = CBUUID(string: "180D") - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [], - scanServices: [scanUUID] - ) - #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == [scanUUID]) + CBMCentralManagerMock.simulatePowerOff() + #expect(await Mock.waitForState("Powered Off", on: manager2)) + await manager2.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) + #expect(await manager2.bluetooth.testPendingRestoredScanServices() == [scanUUID]) - // The unauthorized/resetting teardown path clears the pending restored scan, connection - // state, in-memory reconnect intent, and the persisted mirror (plan critique seam). - await BluetoothActor.shared.testInvalidatePeripherals() - #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) - #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) - #expect(await manager.currentConnectionStates[discovered.id] == nil) - #expect(await BluetoothActor.shared.testPersistedReconnectIntent().isEmpty) + 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) - // Restore power for subsequent tests. CBMCentralManagerMock.simulatePowerOn() - _ = await Mock.waitForState("Ready", on: manager) - await BluetoothActor.shared.testClearPersistedReconnectIntent() + await manager2.bluetooth.testClearPersistedReconnectIntent() + await Mock.tearDown(manager2) } @Test func willRestoreDefersScanUntilPoweredOn() async throws { + // Direct-handler unit test: CoreBluetoothMock sets `isScanning = true` synchronously + // inside central init when scan services are restored, so a faithful cold-relaunch cannot + // observe a deferred pending filter. Exercise our handler's powered-off deferral directly. let manager = await Mock.makeManager() await Mock.ensureReady(manager) - await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( - id: Mock.testPeripheralID, - on: manager, - withinNanoseconds: 3_000_000_000 - ) - let discovered = try #require(peripheral) - await manager.stopScanning() - - // Power off before restore so resumeRestoredScan stashes the filter. CBMCentralManagerMock.simulatePowerOff() #expect(await Mock.waitForState("Powered Off", on: manager)) - await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() - let scanUUID = CBUUID(string: "180D") - await BluetoothActor.shared.testInvokeWillRestoreState( - peripheralIds: [discovered.id], - scanServices: [scanUUID] - ) + await manager.bluetooth.testHandleWillRestoreState(scanServices: [scanUUID]) - #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == [scanUUID]) - #expect(!(await BluetoothActor.shared.testIsScanning())) + #expect(await manager.bluetooth.testPendingRestoredScanServices() == [scanUUID]) + #expect(!(await manager.bluetooth.testIsScanning())) - // Power on should resume the deferred restored scan. CBMCentralManagerMock.simulatePowerOn() let becameScanning = await Mock.waitForState("Scanning", on: manager) if !becameScanning { @@ -1728,15 +1713,145 @@ struct ReliaBLEManagerTests { } let resumed = await pollUntil(timeout: 3.0) { - let pending = await BluetoothActor.shared.testPendingRestoredScanServices() - let scanning = await BluetoothActor.shared.testIsScanning() + let pending = await manager.bluetooth.testPendingRestoredScanServices() + let scanning = await manager.bluetooth.testIsScanning() return pending == nil && scanning } #expect(resumed) - #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) - #expect(await BluetoothActor.shared.testIsScanning()) + #expect(await manager.bluetooth.testPendingRestoredScanServices() == nil) + #expect(await manager.bluetooth.testIsScanning()) + + await manager.stopScanning() + } + + @Test func willRestoreDisconnectedPeripheralSeedsNothing() async throws { + // Direct-handler: iOS never restores disconnected peripherals; this only exercises our + // defensive `.disconnected` switch (no connectionStates / reconnectEnabled seeding). + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) await manager.stopScanning() + + #expect(await manager.currentConnectionStates[discovered.id] == nil) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(discovered.id))) + + await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [discovered.id]) + + #expect(await manager.currentConnectionStates[discovered.id] == nil) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(discovered.id))) + // Live reference remains registered from discovery. + #expect(await manager.bluetooth.testContainsCBPeripheral(discovered.id)) + } + + // MARK: - Multi-Manager Isolation + + @Test func twoManagersWithDistinctRestoreIdsHaveIndependentState() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let restoreA = "com.five3apps.relia-ble.tests.iso-a" + let restoreB = "com.five3apps.relia-ble.tests.iso-b" + + let managerA = await Mock.makeManager(restoreIdentifier: restoreA, tearDownPrevious: true) + await Mock.ensureReady(managerA) + + // Second stack stays live alongside the first — validates instance isolation end-to-end. + let managerB = await Mock.makeManager(restoreIdentifier: restoreB, tearDownPrevious: false) + await Mock.ensureReady(managerB) + + #expect(await managerA.bluetooth.hasCentralManager) + #expect(await managerB.bluetooth.hasCentralManager) + #expect(await managerA.bluetooth.testRestoreIdentifier() == restoreA) + #expect(await managerB.bluetooth.testRestoreIdentifier() == restoreB) + + // A discovers while B is idle — B's discovered list must stay empty. + await managerA.startScanning() + let discoveredOnA = await Mock.waitForPeripheral( + id: Mock.testPeripheralID, + on: managerA, + withinNanoseconds: 3_000_000_000 + ) + #expect(discoveredOnA != nil) + #expect(await managerA.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + #expect(await managerB.bluetooth.discoveredPeripherals.isEmpty) + #expect(!(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID))) + await managerA.stopScanning() + + // B discovers independently into its own maps. + await managerB.startScanning() + let discoveredOnB = await Mock.waitForPeripheral( + id: Mock.testPeripheralID, + on: managerB, + withinNanoseconds: 3_000_000_000 + ) + #expect(discoveredOnB != nil) + #expect(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + await managerB.stopScanning() + + // Connect only on A; B must not observe connection state for that peripheral. + await managerA.startScanning() + let connectableA = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: managerA, + withinNanoseconds: 3_000_000_000 + ) + let peripheralA = try #require(connectableA) + await managerA.stopScanning() + + try await managerA.connect(to: peripheralA) + #expect(await pollUntil(timeout: 3.0) { + await managerA.currentConnectionStates[peripheralA.id] == .connected + }) + #expect(await managerB.currentConnectionStates[peripheralA.id] == nil) + #expect(await managerB.bluetooth.testIsReconnectEnabled(peripheralA.id) == false) + + // Both stacks still alive after the cross-manager exercise. + #expect(await managerA.bluetooth.hasCentralManager) + #expect(await managerB.bluetooth.hasCentralManager) + + await Mock.tearDown(managerA) + await Mock.tearDown(managerB) + } + + @Test func authorizeCancellationDoesNotAffectOtherManager() async throws { + CBMCentralManagerMock.simulateAuthorization(.notDetermined) + + let managerA = await Mock.makeManager(tearDownPrevious: true) + let managerB = await Mock.makeManager(tearDownPrevious: false) + + let taskA = Task { try await managerA.authorizeBluetooth() } + let taskB = Task { try await managerB.authorizeBluetooth() } + + // Both should be suspended on the undetermined decision. + try? await Task.sleep(nanoseconds: 150_000_000) + taskA.cancel() + let resultA = await taskA.result + switch resultA { + case .failure(let error): + #expect(error is CancellationError) + case .success: + // Already resolved if mock auth flipped early — still must not break B. + break + } + + // Cancelling A must leave B's waiter intact — grant auth and bounce power so B's + // central receives didUpdateState and resolvePendingAuthorization runs. + CBMCentralManagerMock.simulateAuthorization(.allowedAlways) + CBMCentralManagerMock.simulatePowerOff() + CBMCentralManagerMock.simulatePowerOn() + + try await taskB.value + #expect(await managerB.bluetooth.hasCentralManager) + + await Mock.tearDown(managerA) + await Mock.tearDown(managerB) } // MARK: - Event Stream Broadcaster @@ -1769,7 +1884,7 @@ struct ReliaBLEManagerTests { _ = await subscriberB.next() // Force a state broadcast through the real actor path; both live subscribers receive it. - await BluetoothActor.shared.updateState() + await manager.bluetooth.updateState() let broadcastA = await subscriberA.next() let broadcastB = await subscriberB.next() @@ -1836,10 +1951,11 @@ final class CapturingLogWriter: LogWriter, @unchecked Sendable { } } + // MARK: - Mock Harness -/// Helpers for driving the Nordic `CBMCentralManagerMock` simulation under the constraints of the -/// process-wide ``BluetoothActor`` singleton. +/// Helpers for driving the Nordic `CBMCentralManagerMock` simulation against a per-manager +/// ``BluetoothActor`` stack. enum Mock { /// The resolved ``Peripheral/id`` of the simulated test peripheral. /// @@ -1863,19 +1979,107 @@ enum Mock { /// lifecycle tests that otherwise leak `virtualConnections` state. nonisolated(unsafe) static let connectionTestSpec = makeConnectionTestPeripheralSpec() + /// Active stack tracked for serialized-suite teardown. Cleared by ``tearDown(_:)``. + nonisolated(unsafe) private static var activeManager: ReliaBLEManager? + + /// Deterministically tears down a manager stack via ``BluetoothActor/shutdown()`` (volatile + /// state only — persisted reconnect intent is preserved). + /// + /// - Parameter resetMockConnections: When `true` (default), disconnects mock peripherals so + /// they advertise again for the next test. Pass `false` for cold-relaunch so + /// `CBMPeripheralSpec.virtualConnections` stays set and `simulateStateRestoration` can + /// restore peripherals as `.connected`. + static func tearDown(_ manager: ReliaBLEManager, resetMockConnections: Bool = true) async { + if resetMockConnections { + connectionTestSpec.simulateDisconnection() + } + await manager.bluetooth.shutdown() + if activeManager === manager { + activeManager = nil + } + } + + /// Installs a process-global `simulateStateRestoration` fixture built only from + /// ``CBMPeripheralSpec``s and scan-service UUIDs (no live `CBPeripheral` / actor state). + /// + /// **Always** pair with `defer { Mock.clearStateRestoration() }`. + static func installStateRestoration( + restoreIdentifier: String, + peripherals: [CBMPeripheralSpec] = [], + scanServices: [CBMUUID]? = nil + ) { + CBMCentralManagerMock.simulateStateRestoration = { id in + guard id == restoreIdentifier else { return nil } + var dict: [String: Any] = [:] + if !peripherals.isEmpty { + dict[CBMCentralManagerRestoredStatePeripheralsKey] = peripherals + } + if let scanServices { + dict[CBMCentralManagerRestoredStateScanServicesKey] = scanServices + } + return dict + } + } + + static func clearStateRestoration() { + CBMCentralManagerMock.simulateStateRestoration = nil + } + + /// 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. + /// + /// 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( + restoreIdentifier: String, + loggingEnabled: Bool = false, + reconnectPolicy: ReconnectPolicy? = nil + ) async throws -> ReliaBLEManager { + CBMCentralManagerMock.simulateAuthorization(.notDetermined) + let manager = await makeManager( + loggingEnabled: loggingEnabled, + reconnectPolicy: reconnectPolicy, + restoreIdentifier: restoreIdentifier, + tearDownPrevious: true + ) + CBMCentralManagerMock.simulateAuthorization(.allowedAlways) + CBMCentralManagerMock.simulatePowerOn() + try await manager.authorizeBluetooth() + _ = await pollUntil(timeout: 3.0) { + let powered = await manager.bluetooth.isCentralPoweredOn + let hasCentral = await manager.bluetooth.hasCentralManager + return powered || hasCentral + } + return manager + } + /// Builds a `ReliaBLEManager` after ensuring the one-time mock configuration has run. /// /// Every test routes manager creation through here so the simulated peripheral set is registered and authorization /// is pinned to `.notDetermined` **before** any central can be created — including by the maintainer's /// authorization tests, whose `.notDetermined` `authorize()` path itself creates a central. Use - /// ``ensureReady(_:)`` afterwards to bring the shared central online. + /// ``ensureReady(_:)`` afterwards to bring the manager's central online. + /// + /// **Serialized-suite policy:** tears down any previous active stack via ``tearDown(_:)`` so + /// tests stay single-stack by default. Callers that need two simultaneous managers should not + /// use this auto-teardown path alone — tear down explicitly and manage mock power carefully. + /// - 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( loggingEnabled: Bool = false, reconnectPolicy: ReconnectPolicy? = nil, - restoreIdentifier: String? = nil + restoreIdentifier: String? = nil, + tearDownPrevious: Bool = true ) async -> ReliaBLEManager { await SimulationConfig.shared.ensureConfigured() + if tearDownPrevious, let previous = activeManager { + await tearDown(previous) + } + var config = ReliaBLEConfig() config.loggingEnabled = loggingEnabled config.restoreIdentifier = restoreIdentifier @@ -1889,10 +2093,12 @@ enum Mock { defaultPolicy.maxAttempts = 0 config.reconnectPolicy = defaultPolicy } - return ReliaBLEManager(config: config) + let manager = ReliaBLEManager(config: config) + activeManager = manager + return manager } - /// Brings the shared central online: authorized, powered on, and reporting `.ready`. + /// Brings the manager's central online: authorized, powered on, and reporting `.ready`. /// /// Resets authorization to `.allowedAlways` (undoing any `.denied`/`.restricted`/`.notDetermined` left by an /// earlier test), ensures power is on, triggers central creation if needed, clears any leaked scan, then waits for @@ -1900,20 +2106,24 @@ enum Mock { /// suspending. 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. + connectionTestSpec.simulateDisconnection() + CBMCentralManagerMock.simulatePowerOff() CBMCentralManagerMock.simulatePowerOn() // Creates the central on first call (peripherals are already registered); a no-op once it exists. try? await manager.authorizeBluetooth() _ = await pollUntil(timeout: 3.0) { - await BluetoothActor.shared.isCentralPoweredOn + await manager.bluetooth.isCentralPoweredOn } - // Clear any scan leaked by an earlier test (the central is process-lifetime) and recompute the - // broadcast state now that authorization and power are settled. `stopScanning()` re-runs - // `updateState()`, so this also resolves to `.ready` when powered on and authorized. + // Clear any scan left on and recompute broadcast state now that authorization and power + // are settled. `stopScanning()` re-runs `updateState()`, so this also resolves to `.ready` + // when powered on and authorized. await manager.stopScanning() - await BluetoothActor.shared.updateState() + await manager.bluetooth.updateState() } /// Builds the simulated, discoverable, connectable test peripheral. @@ -2055,10 +2265,9 @@ func firstEvent( /// Returns the first event matching `predicate` from `stream`, or `nil` if none arrives within /// `nanoseconds`. /// -/// The mock registers multiple simulated peripherals that all advertise concurrently on the same -/// shared central, so a subscriber's *first* event is a race between them, not necessarily the one a -/// test cares about. Filtering by predicate makes the wait deterministic regardless of advertising -/// order. +/// The mock registers multiple simulated peripherals that all advertise concurrently, so a +/// subscriber's *first* event is a race between them, not necessarily the one a test cares about. +/// Filtering by predicate makes the wait deterministic regardless of advertising order. func firstEvent( from stream: AsyncStream, matching predicate: @escaping @Sendable (PeripheralDiscoveryEvent) -> Bool, diff --git a/docs/designs/bluetoothactor-instance-isolation-2026-07-19.md b/docs/designs/bluetoothactor-instance-isolation-2026-07-19.md new file mode 100644 index 0000000..30cadd2 --- /dev/null +++ b/docs/designs/bluetoothactor-instance-isolation-2026-07-19.md @@ -0,0 +1,371 @@ +# Design: Instance-Isolated BluetoothActor (retire `@globalActor`) +*Follow-up to issue #42 · 2026-07-19 · Status: **IMPLEMENTED** 2026-07-21 (all 7 work items landed; see Progress log). Issue #65.* + +## Goal + +Replace the process-wide `@globalActor BluetoothActor.shared` singleton with a plain +`actor BluetoothActor` instance owned per `ReliaBLEManager`. This: + +1. Enables **multiple, fully isolated library stacks in one process** — each with its own + `CBCentralManager`, restore identifier, scan policy, and streams (independent restoration + domains, SDK isolation, separate scan policies — all Apple-supported multi-central use cases). +2. Makes **state-restoration tests faithful**: each test constructs a fresh stack whose central + is created *with* the restore identifier, so CoreBluetoothMock's `simulateStateRestoration` + closure (PR #123, present in our pinned 1.0.6) fires `willRestoreState` during central `init` + — the actual production event — instead of a shim-injected replay into a long-lived central. +3. Removes singleton-workaround machinery from production code + (`restoreDeliveryWaiters`, `testDeliverWillRestoreStateThroughDelegate`, + `testSetRestoreIdentifier`, `testClearDiscoveredSnapshotsPreservingLiveReferences`, the + "already initialized with different restoreIdentifier" warning path, etc.). + +Public API on `ReliaBLEManager` is **source-compatible**: no signatures change; the manager +remains `Sendable` and callable from `@MainActor`. + +## Non-goals + +- No new public API for sharing an actor between managers (first cut is one-stack-per-manager; + a keyed registry or "two façades, one stack" type can be added later, non-breaking — sketched + under Future so it isn't re-litigated here). +- No change to the three-target mock trick, the factory seam, `forceMock: true`, or the + lazy-authorization contract. +- No change to the reconnect ladder, stream semantics, or logging behavior. + +## Design + +### 1. Actor shape + +```swift +// Before +@globalActor actor BluetoothActor { static let shared = BluetoothActor(); private init() {} } + +// After +actor BluetoothActor { + init(log: LoggingService, reconnectPolicy: ReconnectPolicy, restoreIdentifier: String?) { … } +} +``` + +- Delete `@globalActor`, `shared`, every `@BluetoothActor` annotation, and every + `Task { @BluetoothActor in … }` hop (grep the whole repo **including Demo** for + `@BluetoothActor` / `BluetoothActor.shared`). This call-site purge is the risky mechanical + core of the diff — treat it as a bulk migration, not a small edit. +- **Actor `init` sets configuration only** (log, policy, restore id) — synchronously, which is + strictly better than today's async first-wins `ensureInitialized`. **No `CBCentralManager` is + created in `init`**; the lazy-auth contract is unchanged. +- **Stream factories stay `nonisolated`.** `ReliaBLEManager.state` (and the other stream + properties) are synchronous getters; `BluetoothActor.stateStream()` etc. must remain + `nonisolated` methods that capture `self` in `Task { await self.register(…) }`. Accidentally + making them actor-isolated would force `await` on the public getters — a source break. Add a + compile-time proof to the Sendable test. +- The `restoreIdentifier`-mismatch warning is deleted — it can no longer occur. Config is + immutable after actor init; changing restore id or policy means creating a new manager (fine + for v1). + +### 2. Ownership, lifetime & teardown + +```swift +public final class ReliaBLEManager: Sendable { + private let bluetooth: BluetoothActor // strong; created synchronously in init +} +``` + +**Retain graph:** manager → actor → central → shim (as delegate); shim → event continuation +only (no actor reference — no cycle). `delegateEventTask` and `reconnectTasks` capture +`[weak self]`. Registration `Task`s are short-lived. Each live subscriber's `onTermination` +closure retains the actor until the stream terminates — **deliberate**: a stream being consumed +must keep the stack alive. Consequence (stated explicitly): the actor deinits only after the +manager *and every subscriber stream* are gone; deinit-based teardown never runs while anyone +is subscribed. + +**Teardown — compiler-legal design (oracle must-fix, r2).** Actor `deinit` is nonisolated and +must not touch actor-isolated state. A `let` cannot be assigned after actor init, so the +pipeline is created **at stored-property initialization**, not in `setupCentralManager()`: + + ```swift + /// Nonisolated box so the actor's nonisolated deinit may finish the pipeline. + final class EventPipeline: @unchecked Sendable { + let stream: AsyncStream + let continuation: AsyncStream.Continuation + init() { + (stream, continuation) = AsyncStream.makeStream(of: DelegateEvent.self, + bufferingPolicy: .unbounded) + } + func finish() { continuation.finish() } // thread-safe, idempotent + } + + actor BluetoothActor { + private nonisolated let eventPipeline = EventPipeline() + deinit { eventPipeline.finish() } + } + ``` + + `setupCentralManager()` then starts `delegateEventTask` from `eventPipeline.stream`, creates + the shim with `eventPipeline.continuation`, and calls the factory. Deinit reads only an + immutable `nonisolated let` — no `nonisolated(unsafe)`, no post-init write. Finishing the + stream ends the `[weak self]` consumer loop; ARC then releases central → shim. (The pipeline + existing before the central is also what makes the consumer-before-factory ordering in §3 + trivial.) +- **Reconnect tasks must also be cancellable from `deinit`** (oracle r2): `reconnectTasks` + are actor-isolated, so a `[weak self]` retry sleeping through a long delay could outlive the + actor uncancelled. Add a second nonisolated box: + + ```swift + /// Thread-safe (internal lock) registry of unstructured task handles. + final class TaskRegistry: @unchecked Sendable { + func insert(_ id: String, _ task: Task) { … } + func cancel(_ id: String) { … } + func cancelAll() { … } + } + + private nonisolated let taskRegistry = TaskRegistry() + deinit { eventPipeline.finish(); taskRegistry.cancelAll() } + ``` + + The registry **replaces** the actor's `reconnectTasks` dictionary outright (no mirrored + second store): actor code inserts/cancels via `taskRegistry.insert(id:)` / + `taskRegistry.cancel(id)` and keeps only the logical reconnect state (`reconnectAttempts`, + `connectionStates`) for stale-attempt guards. `deinit` and `shutdown()` can then cancel from + any context. +- Additionally add an **internal `shutdown()`** (actor-isolated) with **terminal semantics** + (oracle r2): sets `isShutdown = true`; finishes the pipeline; cancels the registry and + `delegateEventTask`; finishes all subscriber continuations; nils the central/shim. After + shutdown, `ensureCentralManager()` and all operations are guarded no-ops (the one-shot + pipeline `let` cannot host a second central — a shut-down actor is dead, not restartable; + create a new manager instead). Throwing operations (`connect`/`disconnect`) deterministically + **throw `PeripheralError.bluetoothUnavailable`** after shutdown; non-throwing operations + no-op with a warning log. Crucially, shutdown clears **volatile** state only — it must + **not** call `persistReconnectIntent()` or otherwise write/delete the reconnect-intent + `UserDefaults` key, so persisted intent survives for cold relaunch (add a test asserting + this). Not public in v1; the test harness uses it for deterministic teardown. Promoting it to + public API later is non-breaking. +- Do **not** rely on `AsyncStream.Continuation` deinit semantics implicitly; `finish()` / + `cancelAll()` are always called explicitly via the paths above. + +### 3. Central creation & event ordering (oracle must-fix) + +`setupCentralManager()` call order changes: + +1. Create the unbounded `AsyncStream` + continuation (pipeline box). +2. Create the shim. +3. **Start `delegateEventTask` first** (it suspends on `for await`). +4. *Then* call `CBCentralManagerFactory.instance(...)`. + +Rationale: with a restore identifier, the mock (PR #123) invokes `willRestoreState` +**synchronously inside the factory call**, and further callbacks (`didUpdateState`) can arrive +on the delegate queue immediately after. The unbounded buffer holds early yields either way, +but starting the consumer first removes the "task doesn't exist yet" window and shortens the +gap before restore side effects are applied. A single continuation + single consumer preserves +delivery order end-to-end — never fan out per-callback `Task`s. + +Ordering invariants: + +- **`willRestoreState`-before-`didUpdateState` is a mock/OS observation, not a library + invariant.** Production code must tolerate either order and restore while not powered on — + `handleWillRestoreState` already defers the scan; keep the handler synchronous (no awaits + holding half-restored state). Nothing may assume restore only runs when `.ready`. +- **Tests assert settled, observable state** (restored maps/streams visible after polling), not + internal event order. One optional mock-contract test may check restore-before-state, clearly + labeled as a mock behavior check. +- Restore side effects are applied asynchronously relative to `ensureInitialized` returning; + callers (tests) poll `pollUntil` / consume streams rather than expecting synchronous + visibility. + +### 4. `ensureInitialized` collapse — preserved guarantees + +| Guarantee | Today | After | +|---|---|---| +| `ReliaBLEManager.init` never blocks | fire-and-forget `Task` | unchanged (actor init is sync + cheap; eager `Task { await bluetooth.ensureCentralManager() }` optimization kept) | +| Op right after init can't race setup | every public entry awaits `ensureInitialized` | every public entry awaits `ensureCentralManager()` (idempotent, actor-serialized) | +| Logger/policy/restore id set before central exists | first `ensureInitialized` call | actor `init` — strictly earlier | +| No central until `.allowedAlways` (lazy-auth) | gate in `ensureInitialized`/`authorize` | identical gate in `ensureCentralManager()`/`authorize` | +| Out-of-band auth (Settings) picked up later | every call retries creation | every call retries creation | + +- The slim method is named `ensureCentralManager()`; exact member disposition: + - **Stream getters** (`state`, `peripheralDiscoveries`, `discoveredPeripherals`, + `connectionStateChanges`): do **not** create the central — subscriber registration only + (matches today). + - **Snapshot getters** (`currentState`, `currentConnectionStates`): documented cached-state + reads; do **not** create the central. + - **Operational methods** (`authorizeBluetooth`, `startScanning`, `stopScanning`, `connect`, + `disconnect`, and any future op): **must** await `ensureCentralManager()` first — audit on + implementation. +- Per-actor authorization-continuation maps mean cancelling manager A's `authorize` can no + longer touch manager B's waiters — add a two-manager test for this. + +**Test access to the per-manager actor (oracle must-fix, r2).** With `shared` gone, the suite's +many `BluetoothActor.shared.test…` calls need a defined replacement: + +- `ReliaBLEManager` keeps `bluetooth` **internal** (not `private`), so `@testable import + ReliaBLEMock` reaches the actor directly: `await manager.bluetooth.testIsScanning()` etc. + Actor test hooks stay `internal` on the actor as today; no public surface is added. (Narrow + test wrappers on the manager can replace this later if `bluetooth` should become private — + not required for v1.) +- Suite-level helpers (`Mock.ensureReady`, `pollUntil` predicates, the `hasCentralManager` / + `isCentralPoweredOn` extension accessors) take the actor (or manager) as a parameter instead + of referencing a global. + +### 5. Isolation model: one stack per manager + +Each `ReliaBLEManager` is an isolated stack — its own actor, central, discovered peripherals, +connection state, and streams. Document the model in DocC (as the library's behavior, not as a +change): + +- **Restore identifiers must be unique among simultaneously live managers.** Two live managers + with the same `restoreIdentifier` share the persisted reconnect-intent `UserDefaults` key + *and* contend for CoreBluetooth's restoration domain (Apple: one restore id ↔ one central) — + unsupported/undefined. Reusing an id across launches (or across a shut-down stack and its + successor, as the cold-relaunch tests do) is not just allowed but **required** for + restoration; DocC states both halves of the rule. +- **Authorization is process-global.** `CBCentralManager.authorization` is app-wide; manager A's + `authorizeBluetooth()` affects B. Stacks are isolated in state, not in permission. +- **Per-stack discovery.** Two stacks scanning the same device each hold their own `Peripheral` + snapshot and live-reference map; there is no cross-manager discovered list. +- Multiple concurrent aggressive scans degrade each other (shared radio) — DocC guidance. +- The incorrect "CoreBluetooth enforces a single central per process" comment is removed. +- Demo app: single manager; audit via sub-agent (reads `Demo/CLAUDE.md` first); expected impact + nil. + +### 6. Test architecture + +- **Fresh stack per test** via `Mock.makeManager(...)`. The suite stays `.serialized` and keeps + per-test pinning: Nordic's *mock* globals (authorization, power, peripheral specs, + `simulateStateRestoration`) remain process-wide even though our stacks no longer are. Tests + creating two managers must not assume isolated mock power/peripherals. Rewrite the suite's + header comments — the "process-wide singleton" mental model they document becomes wrong. +- **Faithful cold-relaunch pattern** (replaces `testClearDiscoveredSnapshotsPreservingLiveReferences`): + 1. Manager 1 with restore id `R`: discover, connect (persisting reconnect intent). + 2. **Tear down stack 1 deterministically** via the internal `shutdown()` — dropping the + manager alone is insufficient while any stream subscriber is alive (streams retain the + actor). The harness must release all iterators *and* call `shutdown()`. + 3. Set `CBMCentralManagerMock.simulateStateRestoration = { id in id == R ? dict : nil }` + (**always reset to `nil` in a `defer`** — it is process-global). **Fixture construction + (oracle must-fix, r2):** the #123 restoration dictionary is built from + `CBMPeripheralSpec`s — the same process-global specs the harness already owns + (`Mock.connectionTestSpec`, a `nonisolated(unsafe)` static) — plus `CBMUUID` scan + services. No live `CBPeripheral` or actor-isolated state is needed, so the closure is + built entirely in test code with no actor-boundary crossing. If a scenario ever needs + richer captured state, wrap it in a small test-only `@unchecked Sendable` `RestorationSeed` + box; do not reach into the actor from the closure. + 4. Manager 2 with the same `R`: central init fires `willRestoreState` → shim → stream → + `process` → `handleWillRestoreState`. Assert settled state by polling. +- **Deleted from production:** `restoreDeliveryWaiters`, `suspendForRestoreDelivery()`, + `resumeRestoreDeliveryWaiters()`, `testDeliverWillRestoreStateThroughDelegate`, + `testSetRestoreIdentifier`, `testClearDiscoveredSnapshotsPreservingLiveReferences`. A minimal + internal entry point to `handleWillRestoreState` is **retained** (no waiters, no shim + backdoor) for defensive unit tests only. +- **Test disposition:** + + | Scenario | Path | + |---|---| + | Connected restore, map rehydrate, stream broadcast | Faithful (#123) | + | Re-arm only with persisted intent / `autoReconnect: false` no re-arm | Faithful cold-relaunch (UserDefaults survives `shutdown()`) | + | Disconnected restored peripheral seeds nothing | Direct-handler unit test (iOS never restores disconnected peripherals; this tests our defensive switch) | + | Defer restored scan until powered on / empty-filter ignore | Validate against mock (it may set `isScanning = true` at restore-init); if the mock conflicts, keep as direct-handler unit tests and capture the gap for the upstream doc (work item 7) | + | Restore doesn't hit `peripheralDiscoveries` feed | Faithful | + | Invalidate clears pending scan + intent | Faithful (power/unauth on that stack's central) | + | **Two managers, distinct restore ids, independent state** | **New test — must add** (validates the whole migration) | + | Auth cancellation isolation between managers | New test | + | willRestore-before-stateUpdate ordering | Optional mock-contract test only | + +- Persisted reconnect intent (`UserDefaults`) unchanged; per-test unique restore ids keep tests + independent. Update the `ReliaBLEManager` Sendable-proof test so it doesn't rely on singleton + side effects. + +### 7. Concurrency notes (Swift 6 strict) + +- Manager stays trivially `Sendable` (`let` actor reference + `LoggingService`). +- `@unchecked Sendable` payload ferries (`DiscoveryPayload`, `RestorationPayload`, …) unchanged. +- `sending [CBUUID]?` parameters, checked-continuation helpers, and region-isolation-friendly + patterns port unchanged. +- The only new unchecked surfaces are the `EventPipeline` and `TaskRegistry` + `@unchecked Sendable` teardown boxes — scoped, documented, immutable `nonisolated let` + properties; **no new `nonisolated(unsafe)`**. + +### 8. Documentation + +- `AGENTS.md` / `CLAUDE.md`: rewrite "Swift Concurrency" — instance actor owned by the manager; + one-stack-per-manager model; unique-restore-id rule. +- DocC (`Topics/Concurrency.md`, `GettingStarted.md`): isolation model, stream-retains-actor + lifetime, multi-manager guidance (auth is global, radio shared, unique restore ids), + restoration unchanged from the consumer's view. +- Verify the resolved CoreBluetoothMock actually contains `simulateStateRestoration` at build + time (it does in 1.0.6; keep the `.upToNextMinor` pin and fail tests clearly if absent). + +## Progress log (orchestrator) + +- Baseline `cf33d84`: shim-routed restoration harness groundwork (#42) — `Mock.makeManager`, `Mock.simulateWillRestoreState`, specs. +- [x] **Item 1** — `8b5a6b4`: de-globalized `BluetoothActor`, internal `bluetooth`, `ensureCentralManager()`, `EventPipeline`/`TaskRegistry` + `deinit`, terminal `shutdown()`, consumer-before-factory, harness migrated to `manager.bluetooth`. 53/53 green. NOTE: two live centrals crash CoreBluetoothMock → `Mock.makeManager` currently shuts down the prior manager (interim); reconcile in item 2 and re-examine before the item-4 two-manager test. +- [x] **Item 2** — `0517264`: retain-cycle audit (no cycles), scrubbed process-lifetime comments in Sources, explicit `Mock.tearDown(_:)` (+ `tearDownPrevious:` flag). CONFIRMED: two concurrently-live centrals ARE feasible (mock tracks multiple managers); item-4 two-manager test viable via `tearDownPrevious: false` + explicit teardown of both, avoiding zombie centrals during power transitions. 53/53 green. +- [x] **Item 3** — `1323b2d`: spec-based `Mock.installStateRestoration`/`clearStateRestoration` faithful fixture (defer-reset), migrated connected-restore/re-arm/no-rearm/invalidate tests to faithful path, deleted all shim-injection machinery, kept minimal `testHandleWillRestoreState(scanServices:)` direct-handler entry. Defer-scan + empty-scan-filter confirmed mock-conflict → stay direct-handler (capture in item 7). 53/53 green. +- [x] **Item 4** — `5a4d1f0`: demoted disconnected-restore to direct-handler; added `twoManagersWithDistinctRestoreIdsHaveIndependentState` (two live stacks at once), `authorizeCancellationDoesNotAffectOtherManager`; Sendable proof instance-based. 56/56 green. +- [x] **Item 5** — `a4ee8c5`: rewrote AGENTS.md "Swift Concurrency" (CLAUDE.md is a symlink → AGENTS.md, one file); replaced `@globalActor` narrative in DocC `Topics/Concurrency.md` with instance-per-manager model + "One stack per manager" section (per-stack discovery, global auth, shared radio, both halves of restore-id rule, stream-retains-actor lifetime); corrected the now-false "process-wide actor singleton" config note in `GettingStarted.md` and added multi-manager guidance. Restoration documented as consumer-unchanged. +- [x] **Item 6** — Demo audit: 0 references to `BluetoothActor`/`@BluetoothActor`/`.shared`; single-manager wiring via EnvironmentKey; builds clean on iOS Simulator (~9s). No changes needed. +- [x] **Item 7** — `b9819d5`: verified all 3 gaps real in CoreBluetoothMock 1.0.6 (forced `isScanning` on non-nil restored scan key [High]; no `.disconnected` restored state [Med]; no on-demand `willRestoreState` [Low]). Doc: `docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md`. + +## Work items (each checkpoint compiles + tests green) + +*(Reordered per oracle r2: the singleton purge cannot compile alone — the first green +checkpoint bundles the actor refactor with the minimal test-access migration.)* + +1. **Checkpoint 1 — de-globalize + minimal harness migration** (one PR-sized step, riskiest): + remove `@globalActor`/`shared`; `init(log:reconnectPolicy:restoreIdentifier:)`; slim + idempotent `ensureCentralManager()`; stream factories stay `nonisolated` (sync manager + getters preserved); `EventPipeline` + `TaskRegistry` as `nonisolated let` stored properties + + `deinit` teardown; terminal internal `shutdown()` (persisted-intent-preserving); + consumer-task-before-factory ordering; manager owns + **internal** `bluetooth`; migrate every `BluetoothActor.shared` test call site to + `manager.bluetooth` and parameterize suite helpers. Existing restoration tests keep passing + via the (temporarily retained) shim-injection hook. +2. **Lifetime hardening & cleanup**: retain-cycle audit; remove singleton doc comments; + fresh-stack `Mock.makeManager`; `shutdown()`-based per-test teardown. +3. **Cold-relaunch harness**: spec-based `simulateStateRestoration` fixture helper (set/reset + in `defer`); then delete the relaunch-faking hooks (`testDeliverWillRestoreStateThroughDelegate`, + `restoreDeliveryWaiters`, `testClearDiscoveredSnapshotsPreservingLiveReferences`, + `testSetRestoreIdentifier`); rewrite suite header comments. +4. **Migrate restoration tests** to the faithful/cold-relaunch paths per the disposition table; + demote disconnected + (if mock conflicts) defer-scan scenarios to direct-handler unit tests; + **add** the two-manager isolation test and the auth-cancellation isolation test. +5. **Docs**: AGENTS.md/CLAUDE.md, DocC — document the one-stack-per-manager model and + requirements as the library's behavior (pre-release: no migration/behavior-change notes). +6. **Demo audit** (sub-agent, reads `Demo/CLAUDE.md` first): confirm single-manager usage + unaffected. +7. **Upstream follow-up (last)**: after all changes land, assess whether any CoreBluetoothMock + functionality is still missing (e.g. explicit restored state incl. `.disconnected`, + on-demand `willRestoreState` delivery); if so, write a fresh upstream feature-request doc + scoped to only those residual gaps. + +## Risks + +| Risk | Mitigation | +|---|---| +| Actor `deinit` touching isolated state (illegal) | Nonisolated `EventPipeline.finish()` + `TaskRegistry.cancelAll()` only; explicit internal `shutdown()` | +| Reconnect task outlives deinitted actor | Task handles in nonisolated `TaskRegistry`; cancelled from `deinit`/`shutdown()` | +| `shutdown()` clobbers persisted reconnect intent | Terminal `shutdown()` clears volatile state only; explicit test | +| Stream subscriber keeps actor+central alive unexpectedly | Documented; tests drop iterators + call `shutdown()` | +| `ensureCentralManager()` missed on some public API | Explicit audit of every façade method | +| Sync `var state` accidentally becomes async | Keep stream factories `nonisolated`; compile-time proof in tests | +| Same restore id on two managers | Documented unsupported; unique-id rule in DocC | +| `updateState()` runs before restore drained | Consumer-before-factory ordering + settled-state polling in tests; handler tolerates either callback order | +| Mock global statics still process-wide | Suite stays `.serialized`; `simulateStateRestoration` reset in `defer` | + +## Future (out of scope) + +- Public `shutdown()`/`invalidate()` on `ReliaBLEManager`. +- Keyed shared-stack registry ("two façades, one stack") if a real use case appears. +- Debug log when a second manager is created; N-manager stress tests. +- `package`-visibility test hooks replacing the remaining `test*` actor methods. + +## Oracle review + +**Round 2** (review-mode oracle, go/no-go, two passes): pass 2 added reconnect-task +cancellation via a nonisolated `TaskRegistry`, terminal `shutdown()` semantics that preserve +persisted reconnect intent, the exact `ensureCentralManager()` member disposition, and the +live-managers-only restore-id uniqueness wording. Pass 1's initial no-go was resolved by (a) `EventPipeline` as a +`nonisolated let` stored property initialized at actor init (no post-init write, no +`nonisolated(unsafe)`), (b) an explicit test-access strategy (internal `bluetooth` + +`@testable`), (c) a spec-based, actor-free cold-relaunch fixture, and (d) merging the singleton +purge and minimal harness migration into one green checkpoint. All incorporated above. + +**Round 1** (grok-4.5, chat-mode fallback). Must-fix findings — deinit-isolation-safe +teardown, consumer-before-factory ordering, preserved-guarantee table, sharing-break gaps +(global auth, restore-id uniqueness, per-stack discovery), cold-relaunch harness subtlety +(streams retain the actor), and the two-manager isolation test — are incorporated above. diff --git a/docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md b/docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md new file mode 100644 index 0000000..e16298d --- /dev/null +++ b/docs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md @@ -0,0 +1,195 @@ +# Upstream feature request: CoreBluetoothMock state-restoration fidelity gaps +*Target: [NordicSemiconductor/IOS-CoreBluetooth-Mock](https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock) · pinned ReliaBLE dep: **1.0.6** (revision `5748c9e`) · 2026-07-21* + +## Context + +ReliaBLE (Swift BLE library) uses CoreBluetoothMock 1.0.6 for unit tests via a three-target SPM seam. After migrating to per-manager `BluetoothActor` isolation, cold-relaunch restoration tests use the real production path: + +1. Set `CBMCentralManagerMock.simulateStateRestoration` +2. Construct a central with `CBCentralManagerOptionRestoreIdentifierKey` +3. Observe `centralManager(_:willRestoreState:)` fired **synchronously during `init`** + +That path (PR #123 / `simulateStateRestoration`) is sufficient for the common cases: restored **connected** peripherals, scan-services rehydrate, and cold-relaunch reconnect-intent re-arm. The gaps below are residual only — scenarios we still cover with a thin direct call into our `handleWillRestoreState` unit-test hook because the mock cannot present them faithfully. + +Verified against checkout source: + +- `CoreBluetoothMock/CBMCentralManagerMock.swift` init with options (~L314–L346) +- `CBMPeripheralMock.init(basedOn:by:andRestoreState:)` (~L955–L979) +- `public static var simulateStateRestoration` (~L419) + +--- + +## Gap 1 — `isScanning` forced `true` for any restored scan key + +### Current behavior (1.0.6) + +In `CBMCentralManagerMock.init(delegate:queue:options:)`: + +```swift +if let scanServiceKey = dict[CBMCentralManagerRestoredStateScanServicesKey] as? [CBMUUID] { + state[CBMCentralManagerRestoredStateScanServicesKey] = scanServiceKey + self.isScanning = true // ← always, including empty [] + self.scanFilter = scanServiceKey +} +if let scanOptions = dict[CBMCentralManagerRestoredStateScanOptionsKey] as? [String : Any] { + state[CBMCentralManagerRestoredStateScanOptionsKey] = scanOptions + self.isScanning = true // ← always + self.scanOptions = scanOptions +} +``` + +Any non-`nil` presence of the scan-services **or** scan-options key forces `isScanning = true`, even when the services array is `[]`. + +### Why it blocks faithful tests + +On real iOS, a restored central may report scan services in the restore dictionary while the radio is not yet `.poweredOn`; apps must **defer** restarting the scan until powered on, and must **not** treat an empty service filter as an active background-useful scan. ReliaBLE has two defensive handlers we can only unit-test by calling the handler directly: + +| Scenario | Faithful central-init path today | +|---|---| +| Defer restored scan until `.poweredOn` | Impossible — mock already claims `isScanning == true` at init, before `didUpdateState` | +| Empty scan-service filter is ignored (background-useless) | Impossible — empty `[]` still sets `isScanning = true` | + +### Proposed behavior + +1. **Do not set `isScanning = true` solely because restore keys are present.** Prefer one of: + - Leave `isScanning == false` after restore-init; let the app’s normal `scanForPeripherals` (or an explicit opt-in) drive the flag; **or** + - Gate `isScanning = true` on mock manager state already being `.poweredOn` **and** a non-empty service filter (when services key is present). +2. **Empty `[CBMUUID]` scan services:** still include the key in the `willRestoreState` dictionary (iOS can hand back what was stored), but **do not** set `isScanning = true` and ideally clear/ignore `scanFilter` for scan matching — matching “empty filter is not a live background scan.” +3. Optionally document the chosen semantics next to `simulateStateRestoration`. + +### Proposed API (minimal / optional) + +No new API strictly required if the init logic above is fixed. Optional knobs if callers need the old behavior: + +```swift +/// When true (default false after the fix), restoring non-nil scan keys +/// immediately sets `isScanning = true` even if services are empty / radio off. +public static var simulateRestoredScanAsAlreadyActive: Bool = false +``` + +--- + +## Gap 2 — Restored peripherals cannot be `.disconnected` + +### Current behavior (1.0.6) + +`CBMPeripheralMock.init(basedOn:by:andRestoreState: true)`: + +```swift +if restore { + guard mock.services != nil else { + // non-connectable → ignored + return + } + self.state = mock.isConnected && mock.proximity != .outOfRange + ? .connected + : .connecting +} +``` + +Restored connectable peripherals are **only** `.connected` or `.connecting`. There is no path to `.disconnected` (or `.disconnecting`) on the restore path. Specs built without a prior virtual connection still become `.connecting` when `restore == true`. + +### Why it blocks faithful tests + +Apple’s docs / practice: iOS does **not** put disconnected peripherals in the restore dictionary. Defensive client code still switches on `peripheral.state` for unexpected values. ReliaBLE’s switch includes a `.disconnected` arm that must stay covered; today that arm is only hit via a direct-handler unit test with a hand-built payload, not via central init + `simulateStateRestoration`. + +### Proposed behavior / API + +Allow the restore fixture to control peripheral state explicitly, e.g.: + +**Option A — richer restore dictionary entries (preferred):** + +```swift +// In simulateStateRestoration return value: +// CBMCentralManagerRestoredStatePeripheralsKey → [CBMRestoredPeripheral] +public struct CBMRestoredPeripheral { + public let spec: CBMPeripheralSpec + /// State presented on the restored CBMPeripheralMock. Default: current + /// connected/connecting inference from spec + proximity. + public var state: CBMPeripheralState = /* inferred */ +} +``` + +**Option B — keep `[CBMPeripheralSpec]` but honor a restore-time override on the spec:** + +```swift +extension CBMPeripheralSpec { + /// If non-nil, `andRestoreState: true` uses this instead of connected/connecting inference. + public var restoredStateOverride: CBMPeripheralState? { get set } // or builder API +} +``` + +Either way, when override/explicit state is `.disconnected`, the mock should still deliver the peripheral in `willRestoreState`’s peripherals array (so clients can exercise defensive handling) without implying an active link (`virtualConnections` / GATT should match disconnected). + +--- + +## Gap 3 — No on-demand `willRestoreState` after central creation + +### Current behavior (1.0.6) + +`simulateStateRestoration` is consulted **only** inside `init(delegate:queue:options:)` when a restore identifier option is present. After that, there is no public API to: + +- Re-fire `willRestoreState` on an existing manager +- Deliver a second restore dictionary (e.g. testing handler idempotency) +- Inject restore after the central was created without a restore id (negative / ordering tests) + +`initialize()` always schedules `centralManagerDidUpdateState` asynchronously on the manager queue; restore is always “once, sync, during init.” + +### Why it blocks faithful tests + +Most production code only needs the init-time path (and ReliaBLE’s cold-relaunch suite now uses it). Remaining needs: + +- Handler **idempotency** / double-delivery without tearing down the stack +- Ordering experiments (`didUpdateState` already delivered, then restore) without relying solely on race timing of async state vs sync restore +- Injecting restore into a long-lived test central when spinning a second manager is undesirable + +These are lower priority than Gaps 1–2; Gap 3 is a convenience. Workarounds: tear down + recreate with a new fixture, or keep a library-internal direct-handler hook (what we do today). + +### Proposed API + +```swift +extension CBMCentralManagerMock { + /// Delivers `centralManager(_:willRestoreState:)` on this instance’s delegate + /// with a dictionary built like init-time restoration (specs → peripheral mocks, + /// scan keys, optional isScanning policy from Gap 1). + /// + /// - Parameter state: Same shape as `simulateStateRestoration` return value. + /// - Parameter queue: If true (default), hop to the manager’s queue; if false, + /// invoke synchronously (mirrors init-time delivery). + public func simulateWillRestoreState( + _ state: [String: Any], + deliverOnManagerQueue: Bool = true + ) +} +``` + +Semantics to document: + +- Should **merge** restored peripherals into `manager.peripherals` the same way init does +- Should apply the **same** scan / `isScanning` rules as Gap 1’s fixed init path +- No-op or assert if `delegate == nil` +- Does not change `CBMCentralManagerMock.managerState` by itself + +--- + +## Priority for ReliaBLE + +| Gap | Blocks faithful tests today? | Upstream priority | +|---|---|---| +| 1 `isScanning` on restore | Yes (2 scenarios) | **High** | +| 2 `.disconnected` restored peripheral | Yes (1 defensive arm) | **Medium** (iOS doesn’t do this; still useful for clients) | +| 3 On-demand `willRestoreState` | Convenience only | **Low** | + +Until Gap 1 (and optionally 2) land upstream, ReliaBLE keeps those cases as **direct-handler** unit tests and uses `simulateStateRestoration` for all other restoration coverage. + +## Non-goals / out of scope for this request + +- Simulating `CBConnectPeripheralOptionEnableAutoReconnect` system reconnect (separate limitation; not re-litigated here) +- Multi-process restoration domains +- Changing when `centralManagerDidUpdateState` fires relative to restore (init-time restore-before-async-state is already usable) + +## How we verified + +Pinned package: `IOS-CoreBluetooth-Mock` **1.0.6** / `5748c9e8b1750e0d7bc09243c099ff618f211cdf`. +Read: `.build/checkouts/IOS-CoreBluetooth-Mock/CoreBluetoothMock/CBMCentralManagerMock.swift` (restore init, `simulateStateRestoration`, `CBMPeripheralMock` restore initializer). +Design backdrop: `docs/designs/bluetoothactor-instance-isolation-2026-07-19.md` work item 7.