From 8e86405dd4f258ef29b8bcc32498cbcd20baabbc Mon Sep 17 00:00:00 2001 From: Henri Koskenranta Date: Tue, 14 Jul 2026 14:53:38 +0300 Subject: [PATCH 1/5] 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 e7c0f76..642bc34 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 */; }; C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5126291EAF2F00555EB5 /* OSLog.swift */; }; C1409A09291EC22F006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; }; C1409A0B291EC258006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A0A291EC258006BE8D0 /* OSLog.swift */; }; @@ -116,6 +117,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; }; @@ -244,6 +246,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */, C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */, C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */, + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */, ); path = G7SensorKitTests; sourceTree = ""; @@ -597,6 +600,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 d940208..924658f 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -28,6 +28,16 @@ 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 } @@ -209,18 +219,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 { @@ -256,6 +268,8 @@ public class G7CGMManager: CGMManager { } public func scanForNewSensor() { + cancelSuspectedSessionEndScan() + logDeviceCommunication("Forgetting existing sensor and starting scan for new sensor.", type: .connection) mutateState { state in @@ -344,8 +358,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) { @@ -359,6 +412,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) @@ -427,6 +483,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 3507d17..19a3adb 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -90,14 +90,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 6edd59787623388498589097c28514bbc7326834 Mon Sep 17 00:00:00 2001 From: Henri Koskenranta Date: Wed, 26 Aug 2026 19:54:03 +0300 Subject: [PATCH 2/5] 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 924658f..bb0737c 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -38,6 +38,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 } @@ -368,11 +372,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 @@ -384,7 +386,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 @@ -392,6 +397,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 @@ -413,6 +432,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 { @@ -483,6 +503,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) From 730c57089b299b122ef507edf9f70178aab1b61b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 28 Aug 2026 12:20:49 -0500 Subject: [PATCH 3/5] Persist the suspected session end so a grace period survives termination The deferred scan is an in-memory DispatchWorkItem, so an app killed inside the 15-minute window loses it and nothing re-arms it on launch. For a session that genuinely ended, that leaves the manager tracking a sensor which will never advertise again -- the user has to scan manually. The behaviour it replaced forgot the sensor synchronously, so it always happened. Record the grace start in G7CGMManagerState and re-establish the deferral when the manager is constructed: - A reading timestamped after the grace start proves the session survived; clear the marker and keep the sensor. - A window that elapsed while we were not running, with no reading since, forgets the sensor and scans, as it would have done live. - A window still open re-arms for the remaining time. The marker is cleared whenever the deferral is cancelled, guarded on it being set, since that path runs for every glucose and backfill message and mutateState notifies observers and persists. The restore runs from the shared init(state:sensor:) rather than the rawState initialiser, so it is covered by the existing bluetooth seam; constructing through the public rawState path builds a real CBCentralManager with a restore identifier, which throws in a test bundle. Six tests over the restore and persistence paths. G7SensorKitTests green at 27, and the workspace builds. --- G7SensorKit/G7CGMManager/G7CGMManager.swift | 60 +++++++++++++++ .../G7CGMManager/G7CGMManagerState.swift | 7 ++ G7SensorKitTests/G7CGMManagerTests.swift | 77 +++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index bb0737c..d0d3936 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -237,6 +237,8 @@ public class G7CGMManager: CGMManager { lockedState = Locked(state) self.sensor = sensor sensor.delegate = self + // A grace period may have been in flight when the app was last terminated. + restorePendingSuspectedSessionEnd() } public var rawState: RawStateValue { @@ -391,6 +393,10 @@ extension G7CGMManager: G7SensorDelegate { return } + mutateState { state in + state.suspectedSessionEndAt = graceStart + } + 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. @@ -403,6 +409,11 @@ extension G7CGMManager: G7SensorDelegate { // 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 { + if state.suspectedSessionEndAt != nil { + mutateState { state in + state.suspectedSessionEndAt = nil + } + } logDeviceCommunication("Communication received during suspected session end grace period; keeping sensor.", type: .connection) return } @@ -418,6 +429,55 @@ extension G7CGMManager: G7SensorDelegate { item = nil } pendingItem?.cancel() + + // Only mutate when there is something to clear: this runs on every glucose + // and backfill message, and mutateState notifies observers and persists. + if state.suspectedSessionEndAt != nil { + mutateState { state in + state.suspectedSessionEndAt = nil + } + } + } + + /// Re-establish a grace period that was in flight when the app was last terminated. + /// + /// The deferred scan is an in-memory `DispatchWorkItem`, so it does not survive + /// termination. Without this, an app killed inside the window would leave a + /// genuinely ended session tracked forever -- the sensor never advertises again + /// and nothing re-arms the scan, so the user has to scan manually. + private func restorePendingSuspectedSessionEnd() { + guard let graceStart = state.suspectedSessionEndAt else { return } + + // Communication after the grace period began proves the session was alive. + if let latestReadingTimestamp = state.latestReadingTimestamp, latestReadingTimestamp > graceStart { + mutateState { state in + state.suspectedSessionEndAt = nil + } + return + } + + let deadline = graceStart.addingTimeInterval(suspectedSessionEndGracePeriod) + guard deadline > Date() else { + // The window elapsed while we were not running, with no reading since. + logDeviceCommunication("Grace period for suspected session end expired while app was not running.", type: .connection) + scanForNewSensor() + return + } + + let workItem = DispatchWorkItem { [weak self] in + self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) + } + var scheduled = false + _ = suspectedSessionEndScanItem.mutate { item in + if item == nil { + item = workItem + scheduled = true + } + } + guard scheduled else { return } + + logDeviceCommunication("Resuming suspected session end grace period; \(Int(deadline.timeIntervalSinceNow / 60)) minutes remaining.", type: .connection) + DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + deadline.timeIntervalSinceNow, execute: workItem) } public func sensor(_ sensor: G7Sensor, logComms comms: String) { diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index af88759..c51d69b 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -20,6 +20,11 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { public var latestReadingTimestamp: Date? public var latestConnect: Date? public var uploadReadings: Bool = true + /// When a suspected session end started its grace period, or nil if none is + /// pending. Persisted so a grace period survives app termination: the deferred + /// scan is an in-memory work item, so without this a genuinely ended session + /// would leave the manager tracking a sensor that will never advertise again. + public var suspectedSessionEndAt: Date? init() { } @@ -36,6 +41,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date self.latestConnect = rawValue["latestConnect"] as? Date self.uploadReadings = rawValue["uploadReadings"] as? Bool ?? true + self.suspectedSessionEndAt = rawValue["suspectedSessionEndAt"] as? Date } public var rawValue: RawValue { @@ -47,6 +53,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { rawValue["latestReadingTimestamp"] = latestReadingTimestamp rawValue["latestConnect"] = latestConnect rawValue["uploadReadings"] = uploadReadings + rawValue["suspectedSessionEndAt"] = suspectedSessionEndAt return rawValue } } diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift index 8ea6a38..676f1e6 100644 --- a/G7SensorKitTests/G7CGMManagerTests.swift +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -36,6 +36,83 @@ final class G7CGMManagerTests: XCTestCase { return G7GlucoseMessage(data: Data(hexadecimalString: "4e00c35501002601000106008a00060187000f")!)! } + /// A grace period in flight when the app is terminated must be re-established + /// on restore: the deferred scan is an in-memory work item and does not survive. + /// Restore runs during init, so this goes through the internal init to pick up + /// the bluetooth seam, and uses the production default grace period. + private func makeRestoredManager(suspectedSessionEndAt: Date?, + latestReadingTimestamp: Date?) -> G7CGMManager { + var state = G7CGMManagerState() + state.sensorID = Self.sensorID + state.activatedAt = Date(timeIntervalSinceNow: -54000) + state.suspectedSessionEndAt = suspectedSessionEndAt + state.latestReadingTimestamp = latestReadingTimestamp + + let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + return G7CGMManager(state: state, sensor: sensor) + } + + func testRestoreForgetsSensorWhenGraceExpiredWhileNotRunning() { + // Grace started an hour ago, nothing heard since: the session really ended. + let manager = makeRestoredManager( + suspectedSessionEndAt: Date(timeIntervalSinceNow: -3600), + latestReadingTimestamp: Date(timeIntervalSinceNow: -7200) + ) + + XCTAssertNil(manager.state.sensorID) + } + + func testRestoreKeepsSensorWhenReadingArrivedAfterGraceStart() { + // A reading after the grace period began proves the session survived. + let manager = makeRestoredManager( + suspectedSessionEndAt: Date(timeIntervalSinceNow: -3600), + latestReadingTimestamp: Date(timeIntervalSinceNow: -60) + ) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + XCTAssertNil(manager.state.suspectedSessionEndAt) + } + + func testRestoreKeepsSensorWhileGraceStillRunning() { + // Terminated one minute into a 15-minute grace period: still within the + // window, so keep the sensor and let the re-armed deferral decide. + let manager = makeRestoredManager( + suspectedSessionEndAt: Date(timeIntervalSinceNow: -60), + latestReadingTimestamp: Date(timeIntervalSinceNow: -120) + ) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + XCTAssertNotNil(manager.state.suspectedSessionEndAt) + } + + func testRestoreWithNoPendingGraceLeavesSensorAlone() { + let manager = makeRestoredManager( + suspectedSessionEndAt: nil, + latestReadingTimestamp: Date(timeIntervalSinceNow: -7200) + ) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testSuspectedSessionEndPersistsGraceStart() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + XCTAssertNotNil(manager.state.suspectedSessionEndAt) + } + + func testReadingClearsPersistedGraceStart() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + XCTAssertNotNil(manager.state.suspectedSessionEndAt) + + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + + XCTAssertNil(manager.state.suspectedSessionEndAt) + } + func testSuspectedSessionEndKeepsSensorDuringGracePeriod() { let manager = makeManager(gracePeriod: 10) From bd7786d4c3c5cca679bdc68640eb3d02744983e4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 28 Aug 2026 16:36:50 -0500 Subject: [PATCH 4/5] Drive the grace period from persisted state instead of a work item With the grace start recorded in G7CGMManagerState, the DispatchWorkItem machinery is redundant. suspectedSessionEndAt already says whether a grace period is running and identifies which one, so: - Cancellation is clearing the marker. A pending expiry re-reads it, finds a grace start that is no longer current, and does nothing. - Double-scheduling is prevented by the marker being non-nil rather than by a work item slot and a `scheduled` flag. - The race between an arriving reading and an already-dispatched expiry is covered by the same identity check, so the separate lastSensorCommsDate is no longer needed. - Restore after termination schedules through the same path with the remaining time, instead of duplicating the scheduling logic. Removes two Locked members and the work item lifecycle; the timer is now a plain asyncAfter. An uncancelled closure lives at most one grace period and no-ops when it fires. A timer is still required: when a session genuinely ends the sensor stops advertising, so no reading, disconnect or other callback would ever re-evaluate. Two tests invoked the expiry with a fabricated graceStart, which the identity check now correctly ignores; they pass the recorded value. Added a test that a superseded expiry does not forget the sensor. 28 tests green, workspace builds. --- G7SensorKit/G7CGMManager/G7CGMManager.swift | 111 ++++++-------------- G7SensorKitTests/G7CGMManagerTests.swift | 29 +++-- 2 files changed, 56 insertions(+), 84 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index d0d3936..e2b7d64 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -34,14 +34,6 @@ public class G7CGMManager: CGMManager { /// 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) - - /// 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 } @@ -374,46 +366,35 @@ 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 - self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) - } - - 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 { + // `suspectedSessionEndAt` is the single record of a live grace period: it + // says whether one is running, identifies it, and survives termination. + guard state.suspectedSessionEndAt == nil else { logDeviceCommunication("Suspected session end during active grace period; original deadline unchanged.", type: .connection) return } + let graceStart = Date() mutateState { state in state.suspectedSessionEndAt = graceStart } logDeviceCommunication("Suspected session end; waiting \(suspectedSessionEndGracePeriod.minutes) minutes for communication to resume before scanning for new sensor.", type: .connection) + scheduleGraceExpiry(graceStart: graceStart, after: suspectedSessionEndGracePeriod) + } + + private func scheduleGraceExpiry(graceStart: Date, after delay: TimeInterval) { // 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) + // Not cancellable, and does not need to be -- the expiry re-reads + // `suspectedSessionEndAt` and no-ops unless it still owns the window. + DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + delay) { [weak self] in + self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) + } } 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 { - if state.suspectedSessionEndAt != nil { - mutateState { state in - state.suspectedSessionEndAt = nil - } - } + // Cleared by resumed communication, or replaced by a later grace period. + guard state.suspectedSessionEndAt == graceStart else { logDeviceCommunication("Communication received during suspected session end grace period; keeping sensor.", type: .connection) return } @@ -422,62 +403,41 @@ extension G7CGMManager: G7SensorDelegate { scanForNewSensor() } + /// Clearing the marker is the cancellation: a pending expiry finds a grace + /// start that is no longer current and does nothing. private func cancelSuspectedSessionEndScan() { - var pendingItem: DispatchWorkItem? - _ = suspectedSessionEndScanItem.mutate { item in - pendingItem = item - item = nil - } - pendingItem?.cancel() - - // Only mutate when there is something to clear: this runs on every glucose - // and backfill message, and mutateState notifies observers and persists. - if state.suspectedSessionEndAt != nil { - mutateState { state in - state.suspectedSessionEndAt = nil - } + // Guarded because this runs on every glucose and backfill message, and + // mutateState notifies observers and persists. + guard state.suspectedSessionEndAt != nil else { return } + mutateState { state in + state.suspectedSessionEndAt = nil } } - /// Re-establish a grace period that was in flight when the app was last terminated. - /// - /// The deferred scan is an in-memory `DispatchWorkItem`, so it does not survive - /// termination. Without this, an app killed inside the window would leave a - /// genuinely ended session tracked forever -- the sensor never advertises again - /// and nothing re-arms the scan, so the user has to scan manually. + /// Re-establish a grace period that was in flight when the app was last + /// terminated. The expiry is dispatched in memory and does not survive, so + /// without this a genuinely ended session would be tracked forever -- the + /// sensor never advertises again and nothing re-arms the scan. private func restorePendingSuspectedSessionEnd() { guard let graceStart = state.suspectedSessionEndAt else { return } - // Communication after the grace period began proves the session was alive. + // Normally resumed communication has already cleared the marker. This + // covers the case where that clear was not persisted before we exited. if let latestReadingTimestamp = state.latestReadingTimestamp, latestReadingTimestamp > graceStart { - mutateState { state in - state.suspectedSessionEndAt = nil - } + cancelSuspectedSessionEndScan() return } - let deadline = graceStart.addingTimeInterval(suspectedSessionEndGracePeriod) - guard deadline > Date() else { - // The window elapsed while we were not running, with no reading since. + let remaining = graceStart.addingTimeInterval(suspectedSessionEndGracePeriod).timeIntervalSinceNow + guard remaining > 0 else { + // The window elapsed while we were not running, with nothing heard since. logDeviceCommunication("Grace period for suspected session end expired while app was not running.", type: .connection) scanForNewSensor() return } - let workItem = DispatchWorkItem { [weak self] in - self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) - } - var scheduled = false - _ = suspectedSessionEndScanItem.mutate { item in - if item == nil { - item = workItem - scheduled = true - } - } - guard scheduled else { return } - - logDeviceCommunication("Resuming suspected session end grace period; \(Int(deadline.timeIntervalSinceNow / 60)) minutes remaining.", type: .connection) - DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + deadline.timeIntervalSinceNow, execute: workItem) + logDeviceCommunication("Resuming suspected session end grace period; \(Int(remaining / 60)) minutes remaining.", type: .connection) + scheduleGraceExpiry(graceStart: graceStart, after: remaining) } public func sensor(_ sensor: G7Sensor, logComms comms: String) { @@ -492,7 +452,6 @@ 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 { @@ -563,7 +522,7 @@ extension G7CGMManager: G7SensorDelegate { } public func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) { - lastSensorCommsDate.value = Date() + // Backfill likewise proves the session is still active. cancelSuspectedSessionEndScan() for msg in backfill { diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift index 676f1e6..2efad79 100644 --- a/G7SensorKitTests/G7CGMManagerTests.swift +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -148,30 +148,43 @@ 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. + func testGraceExpiryAfterCommsSinceGraceStartKeepsSensor() throws { + // A reading races the expiry: it is already dispatched when the reading + // arrives and clears the marker. Expiry must notice it no longer owns + // the window and leave the sensor alone. let manager = makeManager(gracePeriod: 100) manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) - manager.sensor(manager.sensor, didRead: okGlucoseMessage) + let graceStart = try XCTUnwrap(manager.state.suspectedSessionEndAt) - manager.handleSuspectedSessionEndGraceExpiry(graceStart: Date(timeIntervalSinceNow: -60)) + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + manager.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) XCTAssertEqual(Self.sensorID, manager.state.sensorID) } - func testGraceExpiryWithoutCommsForgetsSensor() { + func testGraceExpiryWithoutCommsForgetsSensor() throws { let manager = makeManager(gracePeriod: 100) manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + let graceStart = try XCTUnwrap(manager.state.suspectedSessionEndAt) - manager.handleSuspectedSessionEndGraceExpiry(graceStart: Date(timeIntervalSinceNow: -60)) + manager.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) XCTAssertNil(manager.state.sensorID) } + /// An expiry from a superseded grace period must not forget the sensor. + func testStaleGraceExpiryDoesNotForgetSensor() { + let manager = makeManager(gracePeriod: 100) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + manager.handleSuspectedSessionEndGraceExpiry(graceStart: Date(timeIntervalSinceNow: -3600)) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + func testNonSuspectedDisconnectDoesNotForgetSensor() { let manager = makeManager(gracePeriod: 0.1) From baec49a569404606307653a5c09e28dcf5376c46 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 28 Aug 2026 20:48:47 -0500 Subject: [PATCH 5/5] Log the authentication handshake at .default so it reaches sysdiagnose iOS keeps info and debug os_log entries in a memory ring buffer and does not write them to the log archive, so none of the authentication path is visible in a sysdiagnose. Investigating a false session end, the archive showed 26 connects and 74 control responses but not one line about auth, which is indistinguishable from the handshake never happening. pendingAuth is what decides whether a disconnect is treated as a session end, so whether the gate fires on every connection or only after pairing is exactly the question field diagnostics need to answer, and today they cannot. Promoted to .default: - "Listening for authentication responses" (was .info) -- the gate armed - "Observed authenticated session" (was .debug) -- the gate fired - "Ignoring authentication response" (was .debug) -- a response arrived but was not bonded/authenticated, which is the interesting failure to tell apart from silence - "Listening for backfill responses" (was .debug) -- shows whether the subscribe happens per connection Diagnostics only; no behaviour change. --- G7SensorKit/G7CGMManager/G7Sensor.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index 19a3adb..70578c7 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -132,7 +132,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { private func handleGlucoseMessage(message: G7GlucoseMessage, peripheralManager: G7PeripheralManager) { activationDate = Date().addingTimeInterval(-TimeInterval(message.messageTimestamp)) peripheralManager.perform { (peripheral) in - self.log.debug("Listening for backfill responses") + self.log.default("Listening for backfill responses") // Subscribe to backfill updates do { try peripheral.listenToCharacteristic(.backfill) @@ -199,7 +199,10 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } peripheralManager.perform { (peripheral) in - self.log.info("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name)) + // .default so this survives into a sysdiagnose: info and debug are + // memory-only and are not written to the log archive, which makes the + // auth handshake invisible in field diagnostics. + self.log.default("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name)) do { try peripheral.listenToCharacteristic(.authentication) self.pendingAuth = true @@ -319,7 +322,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveAuthenticationResponse response: Data) { if let message = AuthChallengeRxMessage(data: response), message.isBonded, message.isAuthenticated { - log.debug("Observed authenticated session. enabling notifications for control characteristic.") + log.default("Observed authenticated session. enabling notifications for control characteristic.") pendingAuth = false peripheralManager.perform { (peripheral) in do { @@ -332,7 +335,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } } else { - log.debug("Ignoring authentication response: %{public}@", response.hexadecimalString) + log.default("Ignoring authentication response: %{public}@", response.hexadecimalString) } }