Defer forgetting sensor on suspected session end (port of #62) - #63
Conversation
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.
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.
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.
Test✅ Loop picked up the G7 reading on the next Dexcom cycle after rebuilding with this PR. ConfigurationThis is my personal phone running LoopWorkspace next-dev. Updated to this PR, commit 730c570, at 2026-08-28 13:04. iPhone 15 pro, Omnipod 5 pod, Dexcom G7 15-day sensor. |
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.
Interim TestI missed the fact that another commit was pushed. This test was with: 730c570 instead of bd7786d i separated the phone from the sensor/pump so there was no connection. After restoring connection, the Loop app picked up the sensor on the next CGM value. I will rebuild with the latest and repeat the test and post again later. This Loop Report has the deliberate separation and ends just after a pod change. |
Updated test✅ successful reconnect after signal loss Configurationbuild LoopWorkspace next-dev (commit
iPhone 15 pro; Atlas DASH Pod, Dexcom G7 Sensor
NarrativeMove phone out of range at 16:12
Screenshots
Loop Report |
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.

Port of #62 by @cheets (Henri Koskenranta) onto
next-dev. Commits are cherry-picked with original authorship intact.What it fixes
G7SensorreportssuspectedEndOfSession=truewhenever the sensor disconnects before authentication completes, andG7CGMManageracted on that by immediately forgetting the sensor and scanning for a new one. Ordinary transient BLE handshake failures produce exactly the same signature, so a live sensor gets discarded mid-session. Recovery then drops from an OS-level pending connect to a throttled background scan plus full rediscovery, costing 10-40 minutes of glucose data.The author's field data across a week: 118 suspected session ends, exactly 1 of them real. Sensor forgets went from 15.2/day to 0 over five weeks on the patch.
Seen in our own reports as well, most recently a 25-minute gap where the "new" sensor discovered afterwards was the same one, still mid-session, while the Dexcom app received normally throughout.
The change
On a suspected session end, keep the sensor and defer
scanForNewSensor()behind a 15-minute wall-clock grace period. Any glucose or backfill message cancels it. If nothing arrives for the full period, the sensor is forgotten and scanning starts as before.sensorFailedand.known(.sessionEnded)are untouched (G7CGMManager.swift:368and:373), so a sensor that announces its own end still switches immediately.Conflict resolution
One conflict, in
project.pbxproj. Upstream's hunk added the newG7CGMManagerTests.swiftbuild file alongside aHKUnit.swiftentry; our fork removedHKUnit.swiftin theLoopQuantitymigration. Took the new test file only.The Swift files merged cleanly — our divergence in
G7CGMManager.swiftis allHKQuantity→LoopQuantity,pluginIdentifierstatic→instance, and asyncacknowledgeAlert, none of which touches the disconnect path.G7BluetoothManager.swiftwas identical to upstream.Validated the project file after resolving: brace balance zero,
xcodebuild -listparses, all four targets intact.Testing
Full workspace builds.
G7SensorKitTestsgreen through the workspace scheme, 21 tests, including all six newG7CGMManagerTests.Note the suite does not run standalone in this fork —
xcodebuild -project G7SensorKit.xcodeprojfails withunable to resolve module dependency: 'LoopAlgorithm' / 'LoopKit' / 'LoopKitUI', since those come from the enclosing workspace. Same shape as the LoopKit CI issue fixed in LoopKit/LoopKit#607.Open question upstream
Raised on #62: a pending grace period does not survive app termination. The deferral is a
DispatchWorkItemon a global queue, so if the app is killed inside the 15-minute window the scan is lost and nothing at launch re-arms it. For a genuinely ended session that leaves the manager waiting on a sensor that will never advertise again until the user manually scans. Given the 118:1 ratio this is still a large net improvement, but worth tracking.