From 0f44c8407f5685a8aa41810bbd9fb888af87aa9a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 26 Jul 2026 12:44:48 -0500 Subject: [PATCH 1/2] Reconnect: keep a CB connect intent alive through disconnect/failure races Two ways the reconnect state machine could strand itself "disconnected with nothing pending" (no monitor, no in-flight attempt, no CB connect intent to wake the app when the sensor returns), causing outages until a manual foreground: 1. scheduleReconnect re-armed the connect intent whenever the peripheral wasn't .connected/.connecting -- which included .disconnecting. Issuing central.connect while a cancelConnection is still in flight (e.g. the close-on-CCCD-failure path) leaves the peripheral in a phantom .connecting state that never fires didConnect and suppresses every later re-arm. Only request connect from .disconnected; wait for the didDisconnect otherwise. 2. After a failed attempt, the continuation cancelled the peripheral whenever it was .connected OR .connecting. But .connecting is normally the intent the preceding didDisconnect just re-armed, and cancelling a .connecting peripheral fires no terminal callback, silently dropping it. Only drop a fully .connected stale link (which does fire didDisconnect); leave .connecting/.disconnecting alone (a CB callback is coming) and re-arm from .disconnected. Field logs: 2026-07-25 15:53 (phantom .connecting, ~17-hour outage) and 2026-07-26 16:40 (cancelled the re-armed intent, ~6-min outage). --- .../Pairing/LibreLoopCGMManager+Pairing.swift | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index 4af7f85..05194e6 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -836,16 +836,29 @@ extension LibreLoopCGMManager { // We should NEVER sit "disconnected with no CB intent // pending" -- otherwise the peripheral returning to range // generates no callback. BUT only call requestConnect when - // the peripheral isn't already .connected/.connecting: - // calling central.connect on an already-connected peripheral - // on iOS empirically re-fires didConnect, and since our - // didConnect handler calls scheduleReconnect, that produces a - // tight loop bounded only by `monitor == nil`. Field log - // captured 3228 didConnect events in 1 second during the - // ~2.2s handshake window because of this. + // the peripheral is actually .disconnected: + // - .connected/.connecting: calling central.connect on an + // already-connected peripheral on iOS empirically re-fires + // didConnect, and since our didConnect handler calls + // scheduleReconnect, that produces a tight loop bounded only + // by `monitor == nil` (field log: 3228 didConnect events in + // 1 second during the ~2.2s handshake window). + // - .disconnecting: issuing connect while a cancelConnection is + // still in flight (e.g. the close-on-CCCD-failure path drops + // the link and immediately re-schedules) races the pending + // teardown. iOS leaves the peripheral in a phantom .connecting + // state that never fires didConnect, and that phantom state + // then suppresses every legitimate re-arm: the subsequent + // didDisconnect's scheduleReconnect skips connect (sees + // .connecting), and the failed-attempt continuation calls a + // no-op cancelConnection instead of re-arming -- leaving us + // "disconnected with nothing pending" until an app restart + // (field log 2026-07-25 15:53: 17-hour outage). Wait for the + // didDisconnect; its scheduleReconnect re-arms from + // .disconnected. if let peripheralID = state.peripheralID, let peripheral = scanner.retrievePeripherals(withIdentifiers: [peripheralID]).first, - peripheral.state != .connected, peripheral.state != .connecting { + peripheral.state == .disconnected { llog("ble: requesting connect \(peripheral.identifier.uuidString) (peripheral state=\(peripheral.state.rawValue))") scanner.requestConnect(peripheral) } @@ -885,22 +898,38 @@ extension LibreLoopCGMManager { // minutes. CB events alone are NOT a sufficient retry // trigger when the failure path doesn't go through CB. guard await MainActor.run(body: { self.monitor == nil }) else { return } - if let peripheralID, - let peripheral = scanner.retrievePeripherals(withIdentifiers: [peripheralID]).first, - peripheral.state == .connected || peripheral.state == .connecting { - // Active link survived the handshake failure -- - // drop it and let the resulting didDisconnect route - // through the events listener (which calls - // scheduleReconnect). Calling scheduleReconnect - // here too would be a duplicate within ms. - scanner.cancelConnection(peripheral) - } else { - // No active link, no in-flight Task, no CB event - // pending. Explicitly re-arm. scheduleReconnect - // always re-arms the CB connect intent at its top - // (idempotent), and its 0.5s debounce throttles - // Task spawning so this can't tight-loop with the - // events listener's own scheduleReconnect calls. + let peripheral = peripheralID.flatMap { + scanner.retrievePeripherals(withIdentifiers: [$0]).first + } + switch peripheral?.state { + case .connected: + // A full link survived the failed handshake but we never + // adopted a monitor. Drop it so its didDisconnect routes + // through the events listener into scheduleReconnect for a + // clean restart. cancelConnection on a .connected peripheral + // DOES fire a terminal callback, so this can't strand us. + if let peripheral { scanner.cancelConnection(peripheral) } + case .connecting, .disconnecting: + // A CB callback is already guaranteed to come and route to + // scheduleReconnect: .connecting will deliver didConnect when + // the peripheral is reachable (connect-on-demand, no timeout); + // .disconnecting will deliver didDisconnect imminently. Do + // NOTHING here -- in particular do NOT cancel a .connecting + // peripheral: that fires NO terminal callback, silently + // dropping the connect intent and stranding us "disconnected + // with nothing pending". This is exactly how the didDisconnect + // that FAILS this attempt strands us -- it re-arms the connect + // (requestConnect from .disconnected -> .connecting), and if we + // then cancel that, recovery dies (field log 2026-07-26 16:40: + // SensorSessionError.disconnected mid-handshake, didDisconnect + // re-armed, this continuation cancelled it -> 6-min outage). + break + default: + // .disconnected, unknown, or peripheral not retrievable: nothing + // reliable is pending -> re-arm. scheduleReconnect re-arms the + // CB connect intent at its top (idempotent) and its 0.5s + // debounce throttles Task spawning so this can't tight-loop + // with the events listener's own scheduleReconnect calls. await MainActor.run { self.scheduleReconnect() } } } From b435a97365c3233259a835108631fd8056c0f32e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 26 Jul 2026 12:44:48 -0500 Subject: [PATCH 2/2] Sensor monitor: bail if superseded during the CCCD refresh refreshPostAuthNotifications isn't cancellation-aware, so a monitor stopped mid-refresh runs to completion and then executes its close path -- cancelConnection on session.peripheral (same identifier as a newer attempt's peripheral) plus firing the disconnect handler -- derailing the live reconnect. Check Task.isCancelled after the refresh and return before touching the link. Field log 2026-07-25 15:53: a refresh started at :37, the monitor was stopped at :47, the refresh failed at :53 and dropped the fresh attempt mid-connect. --- LibreLoop/Sensor/LibreLoopSensorMonitor.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift index 97b63d9..5ecbe39 100644 --- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift +++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift @@ -164,6 +164,18 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable { llog("monitor starting; refreshing post-auth notifications") self.emitStatus("Refreshing notifications") let refreshOK = await self.refreshPostAuthNotifications() + // If this monitor was stopped while the (not cancellation-aware) + // CCCD refresh was still awaiting its acks, it has been superseded + // by a newer connection. Bail before touching the link: otherwise + // this stale monitor's close path would cancelConnection the *new* + // attempt's peripheral (same identifier) and fire the disconnect + // handler, derailing the live reconnect (field log 2026-07-25 + // 15:53: a refresh started at :37, monitor stopped at :47, refresh + // failed at :53 and dropped the fresh attempt mid-connect). + if Task.isCancelled { + llog("monitor superseded during CCCD refresh; not touching the link") + return + } // If CCCD refresh failed because the BLE link died between // handshake-complete and our first CCCD write, the session is // already dead. session.notifications() on a dead session