From b29613bd59e9ff66b863eb37a6b1960ea0e355d9 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:32:44 -0500 Subject: [PATCH 1/2] fix(apple): await active-device seed before WHOOP startup Refs #2604 --- Strand/BLE/BLEManager.swift | 143 ++++++++++++++++---------- Strand/BLE/BLEStartupGate.swift | 23 +++++ StrandTests/BLEStartupGateTests.swift | 84 +++++++++++++++ 3 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 Strand/BLE/BLEStartupGate.swift create mode 100644 StrandTests/BLEStartupGateTests.swift diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 0feffde48b..d07e60556e 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1248,6 +1248,7 @@ public final class BLEManager: NSObject, ObservableObject { private var bondLoopPausedAt: Date? /// NotificationCenter token for the app-foreground salvage probe (installForegroundSalvageProbe). private var foregroundSalvageObserver: NSObjectProtocol? + private var protectedDataStartupObserver: NSObjectProtocol? /// Multi-WHOOP stale-pin recovery (#52). Consecutive "Encryption/Authentication is insufficient" bond /// refusals on the CURRENTLY PINNED peripheral. A stale registry pin (pointing at a strap that bonds to /// the official app / isn't really here) makes `connect()` drop the strap that DOES bond and loop @@ -1375,8 +1376,6 @@ public final class BLEManager: NSObject, ObservableObject { installForegroundSalvageProbe() } - /// Build the WhoopStore + Collector + Backfiller asynchronously. Safe to call multiple - /// times — bails out early if the collector is already initialised. /// Seed the last-sync display from the ACTIVE strap's own stamp — PR #556's intent, correctly attributed. /// /// Resolved against the registry's active row, exactly as the debug export resolves firmware. NOT from @@ -1411,7 +1410,19 @@ public final class BLEManager: NSObject, ObservableObject { } } + private let startupGate = BLEStartupGate() + + /// Build the store once for overlapping startup callbacks, allowing retries after a failed open. func bootstrapStore() async { + _ = await startupGate.prepare { await self.prepareStoreForStartup() } + } + + private func prepareStoreForStartup() async -> Bool { + await openStore() + return collector != nil && registryStore != nil + } + + private func openStore() async { guard collector == nil else { return } // Surface store-open failures instead of swallowing them with `try?` (#222): a silent failure // here left `backfiller` nil forever and the only visible symptom was the downstream @@ -1450,8 +1461,8 @@ public final class BLEManager: NSObject, ObservableObject { // // Fail-open on an unknown row (nil): unchanged behaviour for anything the registry can't // classify. Only a POSITIVELY non-WHOOP active device is refused, and that same fact seeds the - // connect gate — which closes the launch race where `poweredOn` can reach the WHOOP flow - // before `SourceCoordinator` has wired up and asserted it. + // connect gate. Automatic startup awaits this seed before connecting or discovering services, + // even when `SourceCoordinator` has not wired up yet (#2604). let activeRow = (try? registry.all())?.first(where: { $0.id == activeId }) if let activeRow, !SourceIdentity.isWhoop(activeRow) { setWhoopIsActiveDevice(false) @@ -1965,9 +1976,27 @@ public final class BLEManager: NSObject, ObservableObject { foregroundSalvageObserver = NotificationCenter.default.addObserver( forName: name, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.salvageProbeIfBondLoopPaused() } + Task { @MainActor in + guard let self else { return } + if self.collector == nil && !self.intentionalDisconnect { + await self.resumeSystemConnection(reason: "foreground-store-retry") + } + self.salvageProbeIfBondLoopPaused() + } + } + #if os(iOS) + // A background restoration can meet a data-protected database. Retry on unlock without + // first connecting to an unknown active device merely to start the backfill retry timer. + protectedDataStartupObserver = NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + guard let self, self.collector == nil, !self.intentionalDisconnect else { return } + await self.resumeSystemConnection(reason: "protected-data-store-retry") + } } #endif + #endif } // MARK: Multi-WHOOP (additive — inert on the single-WHOOP path) @@ -5069,9 +5098,36 @@ public final class BLEManager: NSObject, ObservableObject { } } - /// Issue a direct connect to a restored peripheral, logging that it actually happened and arming the - /// #730 pending-connect probe. Both restore entry points funnel through here so the two paths can be - /// told apart in a strap log. + /// Resume radio-driven startup only after the registry has seeded the active-device gate. + private func resumeSystemConnection(reason: String) async { + await startupGate.resume( + prepare: { await self.prepareStoreForStartup() }, + isAllowed: { + self.central.state == .poweredOn && !self.intentionalDisconnect + && self.whoopConnectAllowed("startup/\(reason)") + }, + action: { + if let p = self.restoredPeripheral { + self.adoptSourceIdentity(for: p) + if p.state == .connected { + self.state.connected = true + // Inherited subscriptions need a real off→on cycle (#613), after the gate opens. + self.restoreNeedsResubscribe = true + self.log("Restored CONNECTED peripheral \(p.identifier) — re-discovering services (\(reason))") + self.discoverPrimaryServices(on: p) + } else { + self.state.connected = false + self.connectRestored(p, reason: reason) + } + } else { + // Radio-driven startup retains the bond-loop give-up; it is not a manual retry (#78). + self.connectFromSystem() + } + } + ) + } + + /// Issue a direct connect to a restored peripheral and arm the pending-connect probe (#730). private func connectRestored(_ p: CBPeripheral, reason: String) { guard whoopConnectAllowed("restored/\(reason)") else { return } log("Connecting to restored peripheral (\(reason)) — peripheral state=\(peripheralStateName(p.state))") @@ -5774,21 +5830,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { state.lastSyncError = nil radioStateErrorShown = false } - // Bootstrap the async store once on first poweredOn (idempotent if already set). - Task { @MainActor in await bootstrapStore() } - if let p = restoredPeripheral { - log("poweredOn with restored peripheral — reconnecting \(p.identifier)") - if p.state != .connected { - connectRestored(p, reason: "poweredOn") - } else { - discoverPrimaryServices(on: p) - } - } else { - // #78 hole-2: poweredOn is SYSTEM-initiated (every Bluetooth toggle / bluetoothd restart - // lands here), so it must not reset a latched bond-loop give-up - it gets ONE bounded - // attempt with the give-up intact (Android onBluetoothRadioOn parity), not a fresh hammer. - connectFromSystem() - } + Task { @MainActor in await resumeSystemConnection(reason: "poweredOn") } } public func centralManager(_ central: CBCentralManager, @@ -5882,6 +5924,27 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + // A restored pending connection can complete while the database is still opening. It must + // pass the same startup gate before publishing/adopting its identity or discovering services. + if restoredPeripheral?.identifier == peripheral.identifier { + Task { @MainActor in + await startupGate.resume( + prepare: { await self.prepareStoreForStartup() }, + isAllowed: { + self.central.state == .poweredOn && !self.intentionalDisconnect + && self.restoredPeripheral?.identifier == peripheral.identifier + && peripheral.state == .connected + && self.whoopConnectAllowed("startup/restored-didConnect") + }, + action: { self.completeConnect(peripheral) } + ) + } + return + } + completeConnect(peripheral) + } + + private func completeConnect(_ peripheral: CBPeripheral) { cancelScanFallback() cancelPendingConnectProbe() // #730: the connect resolved; no pending-connect log needed failedConnectAttempts = 0 // a successful connect clears the reconnect backoff (#414) @@ -6410,9 +6473,8 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } /// State restoration entry point (M3 background collection). - /// Stores the restored peripheral and — if already connected — immediately - /// re-discovers services so `cmdCharacteristic` is re-acquired and - /// notifications are re-routed without user interaction. + /// Stores the restored peripheral, then awaits the registry and active-device gate before + /// re-discovering services or reconnecting without user interaction. public func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], @@ -6451,32 +6513,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // Reset the flag so the post-restore didWriteValueFor issues exactly one getClock. clockRequested = false clockRetries = 0 - // Ensure the store is ready before restored BLE data arrives (idempotent; no-op if already built). - Task { @MainActor in - await bootstrapStore() - // #1881: `didConnect` never fires on this path (see above), so the identity adoption that lives - // there would be skipped for a restored link — the very path a radio toggle and a relaunch take. - // After `bootstrapStore`, because it is what creates `registryStore`. - if let p = self.peripheral ?? self.restoredPeripheral { self.adoptSourceIdentity(for: p) } - } - if p.state == .connected { - state.connected = true - // #613: the inherited notify subscriptions come back reported-active but dead. Force one real - // off→on re-subscribe this session (see `requestNotify`) so live HR/R-R resume AND - // `didUpdateNotificationStateFor` fires → `cmdNotifyConfirmedActive` → `connectSettled` → the - // alarm re-arm. Cleared when `connectSettled` bumps. - restoreNeedsResubscribe = true - log("Restored CONNECTED peripheral \(p.identifier) — re-discovering services") - discoverPrimaryServices(on: p) - } else { - state.connected = false - log("Restored DISCONNECTED peripheral \(p.identifier) — reconnect on poweredOn") - if central.state == .poweredOn { - connectRestored(p, reason: "willRestoreState") - } else { - log("Restore: central not poweredOn yet (state=\(central.state.rawValue)) — deferring to poweredOn") - } - } + // Even an already-connected restored link must await the active-device seed before discovery. + // poweredOn may arrive while the store opens; both callbacks share the same preparation (#2604). + Task { @MainActor in await resumeSystemConnection(reason: "willRestoreState") } } } diff --git a/Strand/BLE/BLEStartupGate.swift b/Strand/BLE/BLEStartupGate.swift new file mode 100644 index 0000000000..754c1fb44c --- /dev/null +++ b/Strand/BLE/BLEStartupGate.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Serializes store initialization before automatic BLE startup can consult the device registry. +@MainActor +final class BLEStartupGate { + private var preparation: Task? + + func prepare(_ operation: @escaping @MainActor () async -> Bool) async -> Bool { + if let preparation { return await preparation.value } + let task = Task { @MainActor in await operation() } + preparation = task + let ready = await task.value + preparation = nil + return ready + } + + func resume(prepare operation: @escaping @MainActor () async -> Bool, + isAllowed: @MainActor () -> Bool, + action: @MainActor () -> Void) async { + guard await prepare(operation), isAllowed() else { return } + action() + } +} diff --git a/StrandTests/BLEStartupGateTests.swift b/StrandTests/BLEStartupGateTests.swift new file mode 100644 index 0000000000..e5b151a69a --- /dev/null +++ b/StrandTests/BLEStartupGateTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import Strand + +final class BLEStartupGateTests: XCTestCase { + @MainActor + func testInactiveDeviceSeedPrecedesBothStartupCallbacks() async { + let gate = BLEStartupGate() + var releaseStore: CheckedContinuation? + var opens = 0 + var allowed = true // The launch default, before the persisted ring selection is read. + var gateReads = 0 + var actions: [String] = [] + let prepare: @MainActor () async -> Bool = { + opens += 1 + await withCheckedContinuation { releaseStore = $0 } + allowed = false + return true + } + let check: @MainActor () -> Bool = { gateReads += 1; return allowed } + let poweredOn = Task { @MainActor in + await gate.resume(prepare: prepare, isAllowed: check) { actions.append("connect") } + } + while releaseStore == nil { await Task.yield() } + var secondEntered = false + let restored = Task { @MainActor in + secondEntered = true + await gate.resume(prepare: prepare, isAllowed: check) { actions.append("discover") } + } + // Let the second callback enter the shared preparation while the first is suspended. + while !secondEntered { await Task.yield() } + XCTAssertEqual(opens, 1) + XCTAssertEqual(gateReads, 0) + XCTAssertTrue(actions.isEmpty) + releaseStore?.resume() + await poweredOn.value + await restored.value + XCTAssertEqual(opens, 1) + XCTAssertEqual(gateReads, 2) + XCTAssertTrue(actions.isEmpty, "An inactive WHOOP must neither reconnect nor discover services") + } + + @MainActor + func testSelectedWhoopResumesAfterStoreIsReady() async { + let gate = BLEStartupGate() + var order: [String] = [] + await gate.resume(prepare: { order.append("store"); return true }, isAllowed: { + order.append("gate") + return true + }) { order.append("discover") } + XCTAssertEqual(order, ["store", "gate", "discover"]) + } + + @MainActor + func testFailedStoreDoesNotConsultDefaultGateAndCanRetryOnUnlock() async { + let gate = BLEStartupGate() + var reads = 0 + var actions = 0 + await gate.resume(prepare: { false }, isAllowed: { reads += 1; return true }) { actions += 1 } + XCTAssertEqual(reads, 0) + XCTAssertEqual(actions, 0) + await gate.resume(prepare: { true }, isAllowed: { reads += 1; return true }) { actions += 1 } + XCTAssertEqual(reads, 1) + XCTAssertEqual(actions, 1) + } + + @MainActor + func testRadioOrDeviceChangeDuringStoreOpenIsCheckedAfterAwait() async { + let gate = BLEStartupGate() + var releaseStore: CheckedContinuation? + var allowed = true + var actions = 0 + let startup = Task { @MainActor in + await gate.resume(prepare: { + await withCheckedContinuation { releaseStore = $0 } + return true + }, isAllowed: { allowed }) { actions += 1 } + } + while releaseStore == nil { await Task.yield() } + allowed = false + releaseStore?.resume() + await startup.value + XCTAssertEqual(actions, 0) + } +} From cb4174aa94a66f119076f6dc08a48dc45a23ae0a Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:17:42 -0500 Subject: [PATCH 2/2] Log startup store and radio refusals --- Strand/BLE/BLEManager.swift | 22 ++++++++++++++++-- Strand/BLE/BLEStartupGate.swift | 18 ++++++++++++++- StrandTests/BLEStartupGateTests.swift | 33 +++++++++++++++++++-------- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index d07e60556e..9e6199d163 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -5102,8 +5102,17 @@ public final class BLEManager: NSObject, ObservableObject { private func resumeSystemConnection(reason: String) async { await startupGate.resume( prepare: { await self.prepareStoreForStartup() }, + radioIsPoweredOn: { self.central.state == .poweredOn }, + onRefusal: { refusal in + switch refusal { + case .storeNotReady: + self.log("Startup (\(reason)): store preparation not ready — deferring until a startup retry") + case .radioNotPoweredOn: + self.log("Startup (\(reason)): central not poweredOn yet (state=\(self.central.state.rawValue)) — deferring to poweredOn") + } + }, isAllowed: { - self.central.state == .poweredOn && !self.intentionalDisconnect + !self.intentionalDisconnect && self.whoopConnectAllowed("startup/\(reason)") }, action: { @@ -5930,8 +5939,17 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { Task { @MainActor in await startupGate.resume( prepare: { await self.prepareStoreForStartup() }, + radioIsPoweredOn: { self.central.state == .poweredOn }, + onRefusal: { refusal in + switch refusal { + case .storeNotReady: + self.log("Startup (restored-didConnect): store preparation not ready — deferring until a startup retry") + case .radioNotPoweredOn: + self.log("Startup (restored-didConnect): central not poweredOn yet (state=\(self.central.state.rawValue)) — deferring to poweredOn") + } + }, isAllowed: { - self.central.state == .poweredOn && !self.intentionalDisconnect + !self.intentionalDisconnect && self.restoredPeripheral?.identifier == peripheral.identifier && peripheral.state == .connected && self.whoopConnectAllowed("startup/restored-didConnect") diff --git a/Strand/BLE/BLEStartupGate.swift b/Strand/BLE/BLEStartupGate.swift index 754c1fb44c..cc83355f90 100644 --- a/Strand/BLE/BLEStartupGate.swift +++ b/Strand/BLE/BLEStartupGate.swift @@ -3,6 +3,11 @@ import Foundation /// Serializes store initialization before automatic BLE startup can consult the device registry. @MainActor final class BLEStartupGate { + enum Refusal: Equatable { + case storeNotReady + case radioNotPoweredOn + } + private var preparation: Task? func prepare(_ operation: @escaping @MainActor () async -> Bool) async -> Bool { @@ -15,9 +20,20 @@ final class BLEStartupGate { } func resume(prepare operation: @escaping @MainActor () async -> Bool, + radioIsPoweredOn: @MainActor () -> Bool, + onRefusal: @MainActor (Refusal) -> Void, isAllowed: @MainActor () -> Bool, action: @MainActor () -> Void) async { - guard await prepare(operation), isAllowed() else { return } + guard await prepare(operation) else { + onRefusal(.storeNotReady) + return + } + guard radioIsPoweredOn() else { + onRefusal(.radioNotPoweredOn) + return + } + // The active-device policy logs its own refusal; do not duplicate that evidence here. + guard isAllowed() else { return } action() } } diff --git a/StrandTests/BLEStartupGateTests.swift b/StrandTests/BLEStartupGateTests.swift index e5b151a69a..e1b0bda3c6 100644 --- a/StrandTests/BLEStartupGateTests.swift +++ b/StrandTests/BLEStartupGateTests.swift @@ -18,13 +18,13 @@ final class BLEStartupGateTests: XCTestCase { } let check: @MainActor () -> Bool = { gateReads += 1; return allowed } let poweredOn = Task { @MainActor in - await gate.resume(prepare: prepare, isAllowed: check) { actions.append("connect") } + await gate.resume(prepare: prepare, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: check) { actions.append("connect") } } while releaseStore == nil { await Task.yield() } var secondEntered = false let restored = Task { @MainActor in secondEntered = true - await gate.resume(prepare: prepare, isAllowed: check) { actions.append("discover") } + await gate.resume(prepare: prepare, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: check) { actions.append("discover") } } // Let the second callback enter the shared preparation while the first is suspended. while !secondEntered { await Task.yield() } @@ -43,7 +43,7 @@ final class BLEStartupGateTests: XCTestCase { func testSelectedWhoopResumesAfterStoreIsReady() async { let gate = BLEStartupGate() var order: [String] = [] - await gate.resume(prepare: { order.append("store"); return true }, isAllowed: { + await gate.resume(prepare: { order.append("store"); return true }, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: { order.append("gate") return true }) { order.append("discover") } @@ -55,30 +55,45 @@ final class BLEStartupGateTests: XCTestCase { let gate = BLEStartupGate() var reads = 0 var actions = 0 - await gate.resume(prepare: { false }, isAllowed: { reads += 1; return true }) { actions += 1 } + var refusals: [BLEStartupGate.Refusal] = [] + await gate.resume(prepare: { false }, radioIsPoweredOn: { XCTFail("Radio read before store readiness"); return true }, onRefusal: { refusals.append($0) }, isAllowed: { reads += 1; return true }) { actions += 1 } XCTAssertEqual(reads, 0) XCTAssertEqual(actions, 0) - await gate.resume(prepare: { true }, isAllowed: { reads += 1; return true }) { actions += 1 } + XCTAssertEqual(refusals, [.storeNotReady]) + await gate.resume(prepare: { true }, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: { reads += 1; return true }) { actions += 1 } XCTAssertEqual(reads, 1) XCTAssertEqual(actions, 1) } @MainActor - func testRadioOrDeviceChangeDuringStoreOpenIsCheckedAfterAwait() async { + func testRadioChangeDuringStoreOpenIsCheckedAfterAwait() async { let gate = BLEStartupGate() var releaseStore: CheckedContinuation? - var allowed = true + var radioReady = true + var refusals: [BLEStartupGate.Refusal] = [] var actions = 0 let startup = Task { @MainActor in await gate.resume(prepare: { await withCheckedContinuation { releaseStore = $0 } return true - }, isAllowed: { allowed }) { actions += 1 } + }, radioIsPoweredOn: { radioReady }, onRefusal: { refusals.append($0) }, isAllowed: { XCTFail("Policy read after radio changed"); return true }) { actions += 1 } } while releaseStore == nil { await Task.yield() } - allowed = false + radioReady = false releaseStore?.resume() await startup.value XCTAssertEqual(actions, 0) + XCTAssertEqual(refusals, [.radioNotPoweredOn]) } + @MainActor + func testRadioRefusalNamesReasonWithoutReadingDevicePolicy() async { + let gate = BLEStartupGate() + var refusals: [BLEStartupGate.Refusal] = [] + await gate.resume(prepare: { true }, radioIsPoweredOn: { false }, + onRefusal: { refusals.append($0) }, + isAllowed: { XCTFail("Device policy read with radio unavailable"); return true }, + action: { XCTFail("Startup ran with radio unavailable") }) + XCTAssertEqual(refusals, [.radioNotPoweredOn]) + } + }