From bd9cc9fcfd06686514ca9193b2131a8be8df87cf Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 14 Jul 2026 20:21:11 -0500 Subject: [PATCH 1/3] Wire pump BLE heartbeat to the CGM reading schedule Send the pump a PumpHeartbeatRequest (last CGM reading date + expected reading interval) when it must provide the BLE heartbeat, or nil when the CGM wakes the app itself, via the new setBLEHeartbeatRequest API. The last-reading time is refreshed after every CGM reading (processCGMReadingResult already calls updatePumpManagerBLEHeartbeatPreference), so the pump's heartbeat cadence tracks the actual reading schedule. --- Loop/Managers/DeviceDataManager.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 2df637f27..20aac8224 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -837,7 +837,17 @@ extension DeviceDataManager { } func updatePumpManagerBLEHeartbeatPreference() { - pumpManager?.setMustProvideBLEHeartbeat(pumpManagerMustProvideBLEHeartbeat) + guard pumpManagerMustProvideBLEHeartbeat else { + pumpManager?.setBLEHeartbeatRequest(nil) + return + } + // Tell the pump when the last CGM reading landed and how often readings are expected, so it can + // schedule its next heartbeat to arrive just after the next reading is due (the pump adds its own + // buffer for the remote CGM value to be fetched and stored). + let request = PumpHeartbeatRequest( + lastCGMReadingDate: glucoseStore.latestGlucose?.startDate, + expectedCGMReadingInterval: cgmManager?.expectedGlucoseSampleInterval ?? .minutes(5)) + pumpManager?.setBLEHeartbeatRequest(request) } } From f6772ce119cdac98098451bdcde14fb87b71001c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 17 Jul 2026 12:37:17 -0500 Subject: [PATCH 2/3] Preserve persisted pump/CGM state when a plugin can't be instantiated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instantiateDeviceManagers restored a manager by assigning pumpManager/cgmManager = the result of ...FromRawValue(...). When the plugin wasn't available in the running build, that returned nil, and the nil assignment fired the didSet, which wrote rawPumpManager/rawCGMManager = nil and DELETED the persisted state — so launching a build missing a plugin permanently wiped the pod pairing (incl. session keys) or CGM config. Only assign when the manager instantiates; otherwise log and leave the saved state on disk so a later build that includes the plugin can restore it. --- Loop/Managers/DeviceDataManager.swift | 38 ++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 20aac8224..4f2d6dc64 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -310,26 +310,40 @@ final class DeviceDataManager { func instantiateDeviceManagers() { if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.appGroup?.legacyPumpManagerRawValue { - pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) - // Update lastPumpEventsReconciliation on DoseStore - if let lastSync = pumpManager?.lastSync { - Task { - try? await doseStore.addPumpEvents([], lastReconciliation: lastSync) + if let restoredPumpManager = pumpManagerFromRawValue(pumpManagerRawValue) { + pumpManager = restoredPumpManager + // Update lastPumpEventsReconciliation on DoseStore + if let lastSync = pumpManager?.lastSync { + Task { + try? await doseStore.addPumpEvents([], lastReconciliation: lastSync) + } } - } - if let status = pumpManager?.status { - updatePumpIsAllowingAutomation(status: status) + if let status = pumpManager?.status { + updatePumpIsAllowingAutomation(status: status) + } + } else { + // We have saved pump state, but its plugin couldn't be instantiated in this build (e.g. + // the app was built without that pump manager plugin). Do NOT assign pumpManager = nil — + // that fires the didSet, which writes rawPumpManager = nil and DELETES the persisted + // PumpManagerState, permanently losing the pod pairing/session keys. Leave the saved + // state on disk so a build that includes the plugin can restore it on a later launch. + log.error("Saved pump manager plugin unavailable; preserving persisted PumpManagerState for a future launch") } } else { pumpManager = nil } if let cgmManagerRawValue = rawCGMManager ?? UserDefaults.appGroup?.legacyCGMManagerRawValue { - cgmManager = cgmManagerFromRawValue(cgmManagerRawValue) - - // Handle case of PumpManager providing CGM - if cgmManager == nil && pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { + if let restoredCGMManager = cgmManagerFromRawValue(cgmManagerRawValue) { + cgmManager = restoredCGMManager + } else if pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { + // Handle case of PumpManager providing CGM cgmManager = pumpManager as? CGMManager + } else { + // Saved CGM state exists but its plugin couldn't be instantiated in this build. Don't + // assign cgmManager = nil — that would delete the persisted CGMManagerState (same + // footgun as the pump path above). Preserve it for a future launch with the plugin. + log.error("Saved CGM manager plugin unavailable; preserving persisted CGMManagerState for a future launch") } } } From 162cd9315d0def3347bd35a85345104fff77862f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 19 Jul 2026 23:02:30 -0500 Subject: [PATCH 3/3] LiveActivityManager: fix cooperative-pool starvation deadlock getGlucoseSample() bridged async work to sync via DispatchGroup.wait(timeout: .distantFuture). It runs from update(), which executes on a Swift Concurrency cooperative-pool thread; blocking that thread starves the (core-count-sized) pool and deadlocks the whole concurrency runtime under repeated Live Activity updates (Loop stops looping). Observed on device as all cooperative threads stuck in getGlucoseSample -> dispatch_group wait, with the Nightscout upload queues blocked behind them. Make getGlucoseSample async and await getGlucoseSamples directly instead of blocking. Upstream fix (both sites) submitted as LoopKit/Loop#2465. --- .../Live Activity/LiveActivityManager.swift | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index 179b4a053..9f480877d 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -115,7 +115,7 @@ class LiveActivityManager : LiveActivityManagerProxy { let statusContext = UserDefaults.appGroup?.statusExtensionContext let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) - let glucoseSamples = self.getGlucoseSample(unit: unit) + let glucoseSamples = await self.getGlucoseSample(unit: unit) guard let currentGlucose = glucoseSamples.last else { print("ERROR: No glucose sample found...") return @@ -327,24 +327,19 @@ class LiveActivityManager : LiveActivityManagerProxy { return iobFormatter.string(from: iob) ?? "??" } - private func getGlucoseSample(unit: LoopUnit) -> [StoredGlucoseSample] { - let updateGroup = DispatchGroup() - var samples: [StoredGlucoseSample] = [] - + private func getGlucoseSample(unit: LoopUnit) async -> [StoredGlucoseSample] { // When in spacious mode, we want to show the predictive line // In compact mode, we only want to show the history let timeInterval: TimeInterval = self.settings.addPredictiveLine ? .hours(-2) : .hours(-6) - updateGroup.enter() - Task { - samples = (try? await self.glucoseStore.getGlucoseSamples( - start: adjustedChartStart(Date.now.addingTimeInterval(timeInterval)), - end: Date.now - )) ?? [] - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - return samples + + // NOTE: Previously this bridged async→sync via DispatchGroup.wait(.distantFuture), + // which blocked a Swift Concurrency cooperative-pool thread. Because update() itself + // runs on that pool, repeated calls starved every cooperative thread and deadlocked + // the whole concurrency runtime (Loop stopped looping). Await directly instead. + return (try? await self.glucoseStore.getGlucoseSamples( + start: adjustedChartStart(Date.now.addingTimeInterval(timeInterval)), + end: Date.now + )) ?? [] } // If the chart start falls past the half-hour mark (HH:31–HH:59), pull it back to HH:30