From 39a29130a26830e3b8938b7d6ff080ebee17ec0e Mon Sep 17 00:00:00 2001 From: Henri Koskenranta Date: Tue, 14 Jul 2026 14:53:38 +0300 Subject: [PATCH] fix: defer sensor forget on suspected session end A remote disconnect before authentication completes is treated as a session end, immediately forgetting the sensor and scanning from scratch. The same disconnect signature occurs on transient BLE handshake failures (auth notification timeouts, encryption failures), where forgetting the tracked peripheral downgrades reconnection from an OS-level pending connect to a throttled background scan, causing 10-40 minute glucose outages. Field logs showed 118 suspected session ends in 7 days of which 1 was a real session end. Keep tracking the sensor on a suspected session end and defer the scan-for-new-sensor by a 15 minute wall-clock grace period, cancelled when any glucose or backfill message arrives. A real session end still switches sensors: the stopped sensor stays silent, the grace period expires, and the new sensor is discovered during its warmup. Immediate switch on sensorFailed/sessionEnded algorithm states is unchanged. Also add DI seams (central manager factory, injectable bluetooth manager, internal manager init) so G7CGMManager is unit-testable, plus a shared scheme with a test action. --- G7SensorKit.xcodeproj/project.pbxproj | 4 + .../xcschemes/G7SensorKit.xcscheme | 68 +++++++++++++++ .../G7CGMManager/G7BluetoothManager.swift | 9 +- G7SensorKit/G7CGMManager/G7CGMManager.swift | 74 ++++++++++++++-- G7SensorKit/G7CGMManager/G7Sensor.swift | 9 +- G7SensorKitTests/G7CGMManagerTests.swift | 87 +++++++++++++++++++ 6 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme create mode 100644 G7SensorKitTests/G7CGMManagerTests.swift diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index f7db84d..2d6cc01 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */; }; C109F14A291ECCE2008EA5B6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C109F149291ECCE2008EA5B6 /* Assets.xcassets */; }; C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */; }; + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */; }; C139829829295D7D0047DB5F /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F514A291EB6F000555EB5 /* HKUnit.swift */; }; C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5126291EAF2F00555EB5 /* OSLog.swift */; }; C1409A09291EC22F006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; }; @@ -118,6 +119,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = ""; }; C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = ""; }; + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7CGMManagerTests.swift; sourceTree = ""; }; C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C1409A0A291EC258006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C17F50C6291EAC3800555EB5 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -247,6 +249,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */, C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */, C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */, + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */, ); path = G7SensorKitTests; sourceTree = ""; @@ -601,6 +604,7 @@ buildActionMask = 2147483647; files = ( C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */, + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */, C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */, C17F50D4291EAC3800555EB5 /* G7SensorKitTests.swift in Sources */, ); diff --git a/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme new file mode 100644 index 0000000..89bed09 --- /dev/null +++ b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 156bb80..3edb0e2 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -128,10 +128,17 @@ class G7BluetoothManager: NSObject { super.init() managerQueue.sync { - self.centralManager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) + self.centralManager = self.makeCentralManager(queue: self.managerQueue) } } + /// Factory seam so tests can substitute a central manager without the state + /// restoration option, which raises an exception outside an app with the + /// bluetooth-central background mode. + func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) + } + // MARK: - Actions func scanForPeripheral() { diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index 3fdc27b..feaf173 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -21,6 +21,16 @@ public protocol G7StateObserver: AnyObject { public class G7CGMManager: CGMManager { private let log = OSLog(category: "G7CGMManager") + /// How long to wait for communication to resume after a suspected session end + /// before forgetting the sensor and scanning for a new one. BLE handshake + /// failures are indistinguishable from a stopped session at disconnect time; + /// readings normally resume on the sensor's next 5-minute connection cycle. + var suspectedSessionEndGracePeriod: TimeInterval = TimeInterval(minutes: 15) + + /// Pending deferred scan-for-new-sensor, scheduled on a suspected session end + /// and cancelled when sensor communication resumes. + private let suspectedSessionEndScanItem = Locked(nil) + public var state: G7CGMManagerState { return lockedState.value } @@ -202,18 +212,20 @@ public class G7CGMManager: CGMManager { completion(.noData) } - public init() { - lockedState = Locked(G7CGMManagerState()) - sensor = G7Sensor(sensorID: nil) - sensor.delegate = self + public convenience init() { + self.init(state: G7CGMManagerState(), sensor: G7Sensor(sensorID: nil)) } - public required init?(rawState: RawStateValue) { + public required convenience init?(rawState: RawStateValue) { let state = G7CGMManagerState(rawValue: rawState) + self.init(state: state, sensor: G7Sensor(sensorID: state.sensorID)) + sensor.needsVersionInfo = state.extendedVersion == nil + } + + init(state: G7CGMManagerState, sensor: G7Sensor) { lockedState = Locked(state) - sensor = G7Sensor(sensorID: state.sensorID) + self.sensor = sensor sensor.delegate = self - sensor.needsVersionInfo = state.extendedVersion == nil } public var rawState: RawStateValue { @@ -251,6 +263,8 @@ public class G7CGMManager: CGMManager { } public func scanForNewSensor() { + cancelSuspectedSessionEndScan() + logDeviceCommunication("Forgetting existing sensor and starting scan for new sensor.", type: .connection) mutateState { state in @@ -339,8 +353,47 @@ extension G7CGMManager: G7SensorDelegate { public func sensorDisconnected(_ sensor: G7Sensor, suspectedEndOfSession: Bool) { logDeviceCommunication("Sensor disconnected: suspectedEndOfSession=\(suspectedEndOfSession)", type: .connection) if suspectedEndOfSession { - scanForNewSensor() + scheduleScanAfterSuspectedSessionEnd() + } + } + + /// A disconnect before authentication usually means the session was stopped, + /// but the same signature occurs on transient BLE handshake failures, where + /// forgetting the sensor immediately causes a long re-discovery outage. + /// Instead, keep tracking the current sensor and only scan for a new one if + /// communication does not resume within the grace period. + private func scheduleScanAfterSuspectedSessionEnd() { + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.suspectedSessionEndScanItem.value = nil + self.logDeviceCommunication("No sensor communication since suspected session end.", type: .connection) + self.scanForNewSensor() + } + + var scheduled = false + _ = suspectedSessionEndScanItem.mutate { item in + if item == nil { + item = workItem + scheduled = true + } } + + // A grace period is already running; keep its original deadline. + guard scheduled else { return } + + logDeviceCommunication("Suspected session end; waiting \(suspectedSessionEndGracePeriod.minutes) minutes for communication to resume before scanning for new sensor.", type: .connection) + // Wall-clock deadline: a mach-time deadline pauses while the device + // sleeps, which could postpone detection of a genuinely ended session. + DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + suspectedSessionEndGracePeriod, execute: workItem) + } + + private func cancelSuspectedSessionEndScan() { + var pendingItem: DispatchWorkItem? + _ = suspectedSessionEndScanItem.mutate { item in + pendingItem = item + item = nil + } + pendingItem?.cancel() } public func sensor(_ sensor: G7Sensor, logComms comms: String) { @@ -354,6 +407,9 @@ extension G7CGMManager: G7SensorDelegate { public func sensor(_ sensor: G7Sensor, didRead message: G7GlucoseMessage) { + // Receiving any glucose message proves the session is still active. + cancelSuspectedSessionEndScan() + guard message != latestReading else { logDeviceCommunication("Sensor reading duplicate: \(message)", type: .error) updateDelegate(with: .noData) @@ -422,6 +478,8 @@ extension G7CGMManager: G7SensorDelegate { } public func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) { + cancelSuspectedSessionEndScan() + for msg in backfill { logDeviceCommunication("Sensor didReadBackfill \(msg)", type: .receive) } diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index b7c7023..869c40e 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -91,14 +91,19 @@ public final class G7Sensor: G7BluetoothManagerDelegate { private let log = OSLog(category: "G7Sensor") - private let bluetoothManager = G7BluetoothManager() + private let bluetoothManager: G7BluetoothManager private let delegateQueue = DispatchQueue(label: "com.loopkit.G7Sensor.delegateQueue", qos: .unspecified) private var sensorID: String? - public init(sensorID: String?) { + public convenience init(sensorID: String?) { + self.init(sensorID: sensorID, bluetoothManager: G7BluetoothManager()) + } + + init(sensorID: String?, bluetoothManager: G7BluetoothManager) { self.sensorID = sensorID + self.bluetoothManager = bluetoothManager bluetoothManager.delegate = self } diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift new file mode 100644 index 0000000..7bf8494 --- /dev/null +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -0,0 +1,87 @@ +// +// G7CGMManagerTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreBluetooth +@testable import G7SensorKit + +/// CBCentralManager with the state restoration option raises an exception in a +/// test bundle, which lacks the bluetooth-central background mode. +private class TestBluetoothManager: G7BluetoothManager { + override func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue) + } +} + +final class G7CGMManagerTests: XCTestCase { + + private static let sensorID = "DXCM99" + + private func makeManager(gracePeriod: TimeInterval) -> G7CGMManager { + var state = G7CGMManagerState() + state.sensorID = Self.sensorID + state.activatedAt = Date(timeIntervalSinceNow: -54000) // ~15h old session + let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + let manager = G7CGMManager(state: state, sensor: sensor) + manager.suspectedSessionEndGracePeriod = gracePeriod + return manager + } + + private var okGlucoseMessage: G7GlucoseMessage { + // Same sample as G7GlucoseMessageTests: glucose 138, algorithm state ok + return G7GlucoseMessage(data: Data(hexadecimalString: "4e00c35501002601000106008a00060187000f")!)! + } + + func testSuspectedSessionEndKeepsSensorDuringGracePeriod() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testSuspectedSessionEndForgetsSensorAfterGracePeriodWithoutReadings() { + let manager = makeManager(gracePeriod: 0.1) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + let forgotten = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in manager.state.sensorID == nil }, + object: nil + ) + wait(for: [forgotten], timeout: 5) + } + + func testReadingDuringGracePeriodPreventsForgettingSensor() { + let manager = makeManager(gracePeriod: 0.5) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + + let graceElapsed = expectation(description: "grace period elapsed") + DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) { + graceElapsed.fulfill() + } + wait(for: [graceElapsed], timeout: 5) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testNonSuspectedDisconnectDoesNotForgetSensor() { + let manager = makeManager(gracePeriod: 0.1) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: false) + + let graceElapsed = expectation(description: "grace period elapsed") + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + graceElapsed.fulfill() + } + wait(for: [graceElapsed], timeout: 5) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } +}