From 39a29130a26830e3b8938b7d6ff080ebee17ec0e Mon Sep 17 00:00:00 2001 From: Henri Koskenranta Date: Tue, 14 Jul 2026 14:53:38 +0300 Subject: [PATCH 1/2] 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) + } +} From 7d1fc23fc83a92d3fe95dc01be50f131eec371c3 Mon Sep 17 00:00:00 2001 From: Henri Koskenranta Date: Wed, 26 Aug 2026 19:54:03 +0300 Subject: [PATCH 2/2] fix: re-check for comms when session end grace period expires A reading arriving as the grace timer fires could still trigger a spurious sensor scan: the work item is already dispatched and can no longer be cancelled. Track the last received message time and skip the scan if any communication arrived after the grace period began. Also log when a suspected session end occurs during an active grace period, and when an expiry is skipped due to resumed communication. --- G7SensorKit/G7CGMManager/G7CGMManager.swift | 31 +++++++++++++++++---- G7SensorKitTests/G7CGMManagerTests.swift | 24 ++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index feaf173..bfe5f55 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -31,6 +31,10 @@ public class G7CGMManager: CGMManager { /// and cancelled when sensor communication resumes. private let suspectedSessionEndScanItem = Locked(nil) + /// When the sensor last communicated (glucose or backfill message). Used to + /// resolve the race between an expiring grace period and an arriving message. + private let lastSensorCommsDate = Locked(nil) + public var state: G7CGMManagerState { return lockedState.value } @@ -363,11 +367,9 @@ extension G7CGMManager: G7SensorDelegate { /// 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 graceStart = Date() 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() + self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) } var scheduled = false @@ -379,7 +381,10 @@ extension G7CGMManager: G7SensorDelegate { } // A grace period is already running; keep its original deadline. - guard scheduled else { return } + guard scheduled else { + logDeviceCommunication("Suspected session end during active grace period; original deadline unchanged.", type: .connection) + 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 @@ -387,6 +392,20 @@ extension G7CGMManager: G7SensorDelegate { DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + suspectedSessionEndGracePeriod, execute: workItem) } + func handleSuspectedSessionEndGraceExpiry(graceStart: Date) { + suspectedSessionEndScanItem.value = nil + + // A message may have arrived after this expiry was already dispatched; + // any communication since the grace period began proves the session is alive. + if let lastComms = lastSensorCommsDate.value, lastComms > graceStart { + logDeviceCommunication("Communication received during suspected session end grace period; keeping sensor.", type: .connection) + return + } + + logDeviceCommunication("No sensor communication since suspected session end.", type: .connection) + scanForNewSensor() + } + private func cancelSuspectedSessionEndScan() { var pendingItem: DispatchWorkItem? _ = suspectedSessionEndScanItem.mutate { item in @@ -408,6 +427,7 @@ extension G7CGMManager: G7SensorDelegate { public func sensor(_ sensor: G7Sensor, didRead message: G7GlucoseMessage) { // Receiving any glucose message proves the session is still active. + lastSensorCommsDate.value = Date() cancelSuspectedSessionEndScan() guard message != latestReading else { @@ -478,6 +498,7 @@ extension G7CGMManager: G7SensorDelegate { } public func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) { + lastSensorCommsDate.value = Date() cancelSuspectedSessionEndScan() for msg in backfill { diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift index 7bf8494..8ea6a38 100644 --- a/G7SensorKitTests/G7CGMManagerTests.swift +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -71,6 +71,30 @@ final class G7CGMManagerTests: XCTestCase { XCTAssertEqual(Self.sensorID, manager.state.sensorID) } + func testGraceExpiryAfterCommsSinceGraceStartKeepsSensor() { + // A reading can race with the expiry timer: the work item is already + // dispatched when the reading arrives. Expiry must re-check for + // communication received since the grace period began. + let manager = makeManager(gracePeriod: 100) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + + manager.handleSuspectedSessionEndGraceExpiry(graceStart: Date(timeIntervalSinceNow: -60)) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testGraceExpiryWithoutCommsForgetsSensor() { + let manager = makeManager(gracePeriod: 100) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + manager.handleSuspectedSessionEndGraceExpiry(graceStart: Date(timeIntervalSinceNow: -60)) + + XCTAssertNil(manager.state.sensorID) + } + func testNonSuspectedDisconnectDoesNotForgetSensor() { let manager = makeManager(gracePeriod: 0.1)