Skip to content

Defer forgetting sensor on suspected session end (port of #62) - #63

Merged
ps2 merged 5 commits into
next-devfrom
fix/g7-suspected-session-end-grace
Aug 29, 2026
Merged

Defer forgetting sensor on suspected session end (port of #62)#63
ps2 merged 5 commits into
next-devfrom
fix/g7-suspected-session-end-grace

Conversation

@ps2

@ps2 ps2 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Port of #62 by @cheets (Henri Koskenranta) onto next-dev. Commits are cherry-picked with original authorship intact.

What it fixes

G7Sensor reports suspectedEndOfSession=true whenever the sensor disconnects before authentication completes, and G7CGMManager acted 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.

sensorFailed and .known(.sessionEnded) are untouched (G7CGMManager.swift:368 and :373), so a sensor that announces its own end still switches immediately.

Conflict resolution

One conflict, in project.pbxproj. Upstream's hunk added the new G7CGMManagerTests.swift build file alongside a HKUnit.swift entry; our fork removed HKUnit.swift in the LoopQuantity migration. Took the new test file only.

The Swift files merged cleanly — our divergence in G7CGMManager.swift is all HKQuantityLoopQuantity, pluginIdentifier static→instance, and async acknowledgeAlert, none of which touches the disconnect path. G7BluetoothManager.swift was identical to upstream.

Validated the project file after resolving: brace balance zero, xcodebuild -list parses, all four targets intact.

Testing

Full workspace builds. G7SensorKitTests green through the workspace scheme, 21 tests, including all six new G7CGMManagerTests.

Note the suite does not run standalone in this fork — xcodebuild -project G7SensorKit.xcodeproj fails with unable 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 DispatchWorkItem on 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.

cheets and others added 3 commits August 28, 2026 12:09
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.
@marionbarker

Copy link
Copy Markdown
Contributor

Test

✅ Loop picked up the G7 reading on the next Dexcom cycle after rebuilding with this PR.

Configuration

This is my personal phone running LoopWorkspace next-dev.

Updated to this PR, commit 730c570, at 2026-08-28 13:04.
Initially, Loop showed searching for sensor.
Next Dexcom cycle, the reading was picked up.

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.
@marionbarker

Copy link
Copy Markdown
Contributor

Interim Test

I 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.

@marionbarker

Copy link
Copy Markdown
Contributor

Updated test

✅ successful reconnect after signal loss

Configuration

build LoopWorkspace next-dev (commit 3e74b97) on personal phone again with the following updates:

  • Loop: tip of next-dev
  • LoopKit: tip of next-dev
  • OmnipodKit tip of next-dev
  • TrueTime.switch: tip of master
  • G7SensorKit: this PR: commit bd7786d

iPhone 15 pro; Atlas DASH Pod, Dexcom G7 Sensor

  • buildDateString: Fri Aug 28 16:07:59 PDT 2026 (UTC-7)

Narrative

Move phone out of range at 16:12
Restore range at 16:52

  • both the DASH pod and the CGM showed signal loss
  • the DASH comms were restored before I could grab a screenshot
  • the G7 value was picked up next

Screenshots

pr63_signalrecovery

Loop Report

Loop Report 2026-08-28 17:00:13-07:00.md

@ps2
ps2 marked this pull request as ready for review August 29, 2026 01:10
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants