Stale pairing connect teardown and BLE heartbeat for direct-BLE DASH pods - #155
Stale pairing connect teardown and BLE heartbeat for direct-BLE DASH pods#155ps2 wants to merge 3 commits into
Conversation
setBLEHeartbeatRequest branched on podType.mayUseRileyLink, which is true for DASH as well as Eros because DASH can use a RileyLink under the Pod Keep Alive option. A DASH pod on direct BLE with Pod Keep Alive off therefore took the RileyLink branch and only set timerTickEnabled on a RileyLink provider that has no devices. provideHeartbeat was never assigned and BlePodComms.setHeartbeatRequest was never called, so the delayed-connect probe that produces periodic background wakes was never scheduled. issueHeartbeatIfNeeded() also gates on provideHeartbeat, so even a probe that did fire would not have notified Loop. Loop was requesting the heartbeat correctly -- DeviceDataManager sets pumpManagerMustProvideBLEHeartbeat when the CGM cannot provide one -- and the request was logged and then dropped. With no wake source the app stayed suspended in the background until something else woke it. Seen as Loop Failure alerts after gaps of 20 to 264 minutes that end the instant the app is foregrounded; a report from an affected DASH pod shows provideHeartbeat: false moments after a setBLEHeartbeatRequest log line. Only reachable when the CGM cannot supply the heartbeat itself, so it needs a remote or networked CGM such as Nightscout. A BLE Dexcom provides its own heartbeat and masks it. Branch on whether a RileyLink is actually in use instead: Eros always, DASH only under the RileyLink Pod Keep Alive option. The other mayUseRileyLink call sites want the "may" semantics and are unchanged. (cherry picked from commit 0f3295f)
The zombie check in didDiscover treats a pairable pod heard while .connecting with no watchdog as a stale connect and cancels it. That is right for a connect abandoned by an earlier attempt, but it cannot tell that case from a connect we issued moments ago: a peripheral keeps advertising until the link is actually established, and CoreBluetooth reports .connecting from the instant connect() is called, so an advert inside the first second or two is the normal case. discoverPods pre-connects any already-known disconnected pod and then starts the discovery scan. timedConnect only arms the watchdog when shouldUseEagerConnect is true, which needs an affected iPhone model, so on any other phone that pre-connect is unsupervised. The scan hears the pod ~0.8s later at state=.connecting, the zombie branch cancels it, the teardown timer reissues timedConnect (again unsupervised), the next advert ~1.5s later cancels that, and pairing loops at the advert interval without ever completing. Two Xcode captures from an iPhone SE pairing an Omnipod 5 show 62 and 104 consecutive cycles of [pairing] heard pod ... pairable=true state=1 [pairing] ... stuck in .connecting with no watchdog -- cancelling zombie connect [pairing] reconnecting to pairable pod ... after zombie teardown Pairing with a pod that is not yet a known device takes the state=0 path and races the same window: it worked in earlier testing because the ~1.1s connect landed before the next advert. Stamp when the current pairing connect was issued and only treat a .connecting pod as a zombie once that connect is older than pairingConnectZombieSeconds (default 5s, past a healthy connect and inside the pairing budget). The stamp is separate from connectRequestedAt, which is deliberately kept across retries for the latency metric, and is cleared on didConnect and didFailToConnect. (cherry picked from commit 5a32565)
…istic A "zombie" was a pending connect left behind by a pairing attempt that gave up without cancelling. The didDiscover branch tried to detect one from an advert heard while the pod was .connecting, but that is equally true of a connect issued a second ago, and on phones where the watchdog does not arm it cancelled every in-flight pairing connect and looped (see 5a32565, which only narrowed the misfire). Remove the guess and close the source instead: - endPodDiscovery cancels any .connecting peripheral. The previous autoConnectIDs exemption is what leaked the orphans: discovery adds the pod to autoConnectIDs before ending, so any later abandoned connect to a pod that had once been found was never cancelled. Only a .connected paired pod keeps the exemption. - discoverPods cancels any unsupervised .connecting peripheral before issuing its own connects, so during discovery the only pending connect is ours and the discovery timeout owns it. This also covers a connect left pending across an app suspend or kill, which comes back through state restoration already .connecting. didDiscover goes back to connecting only when .disconnected. The pairingConnectIssuedAt stamp and pairingConnectZombieSeconds from 5a32565 go with the heuristic. (cherry picked from commit d70d7b8)
Test✅ successful test of ble-heartbeat using CGM with no heartbeat ConfigurationStart with next-dev branch, commit NarrativeAfter the build completes, confirm the rPi DASH is still connected. Reproduce the issueRestore OmnipodKit to the original commit,
Demonstrate the fixPoint OmnipodKit back to this PR and rebuild
✅ With PR, DASH pod continues to wake on command at 5 minute intervals |
marionbarker
left a comment
There was a problem hiding this comment.
Approve from test (ble-heartbeat fix) and code review (cancel stale pairing deterministically).
Forward-ports the fixes that landed on #141 after the eager-connect backport, none of which are on
next-dev.next-devalready has the eager connect watchdog (#133,366380d), so that commit is not carried here. The three that follow it are.What this brings
c7153cb— BLE heartbeat for DASH pods not using a RileyLinksetBLEHeartbeatRequestbranched onpodType.mayUseRileyLink, which is true for DASH as well as Eros because DASH can use a RileyLink under Pod Keep Alive. A DASH pod on direct BLE with Pod Keep Alive off therefore took the RileyLink branch and only settimerTickEnabledon a provider with no devices —provideHeartbeatwas never assigned and the delayed-connect probe that produces background wakes was never scheduled. Loop asked for a heartbeat, we logged it, and dropped it.With no wake source the app stays suspended until something else wakes it: Loop Failure alerts after gaps of 20 to 264 minutes that end the instant the app is foregrounded. Only reachable when the CGM cannot supply the heartbeat itself, so it needs a remote or networked CGM such as Nightscout — a BLE Dexcom provides its own and masks it.
Branches on whether a RileyLink is actually in use instead. The other
mayUseRileyLinkcall sites want the "may" semantics and are unchanged.Already on the
devline independently as #153; this is its first appearance onnext-dev.8f9b355+761e4ac— stale pairing connect teardownThis one is a regression already shipping on
next-dev, not a new improvement. The zombie heuristic came in with #133.didDiscovertreated a pairable pod heard while.connectingwith no watchdog as a stale connect and cancelled it. That cannot be distinguished from a connect issued moments ago — a peripheral keeps advertising until the link is established, and CoreBluetooth reports.connectingfrom the instantconnect()is called.discoverPodspre-connects any already-known disconnected pod, andtimedConnectonly arms the watchdog whenshouldUseEagerConnectis true, which requires an affected iPhone model. On every other phone that pre-connect is unsupervised: the scan hears the pod ~0.8s later at.connecting, the zombie branch cancels it, the teardown timer reissuestimedConnect(again unsupervised), the next advert cancels that, and pairing loops at the advert interval without completing. Two Xcode captures from an iPhone SE pairing an Omnipod 5 show 62 and 104 consecutive cycles.761e4acdrops the heuristic and closes the source instead:endPodDiscoverycancels any.connectingperipheral. The previousautoConnectIDsexemption is what leaked the orphans — discovery adds the pod toautoConnectIDsbefore ending, so any later abandoned connect to a pod that had once been found was never cancelled. Only a.connectedpaired pod keeps the exemption.discoverPodscancels any unsupervised.connectingperipheral before issuing its own, so during discovery the only pending connect is ours and the discovery timeout owns it. This also covers a connect left pending across an app suspend or kill, which comes back through state restoration already.connecting.8f9b355only narrowed the misfire and is superseded by761e4ac, which removes thepairingConnectIssuedAtstamp andpairingConnectZombieSecondsit added. Both are carried so761e4ac's message resolves the SHA it references; the net effect is one commit's worth of change.Conflicts
None. All three cherry-pick clean onto
next-devat6106c1c.The PR-141 conflict resolutions do not apply here — those existed only because
devpredates the Pod Keep Alive rework (#125).c7153cbusesself.state.podKeepAlive, whichnext-devalready has, so it applies as written.Result
Net change:
BluetoothManager.swift+13/−24,OmniPumpManager.swift+10/−1.BluetoothManager.swiftcomes out byte-identical to the #141 head.OmniPumpManager.swiftstill differs from thedevline by the expected Pod Keep Alive divergence, with the heartbeat hunk landing correctly.Verification
** BUILD SUCCEEDED **— LoopWorkspacenext-dev,Loopscheme, iPhone 17 simulator, with this branch checked out for OmnipodKit. TheusesRileyLinkbuild break that blocks thedevline (#148) does not exist onnext-dev.Not exercised on hardware — the same gap #141 flags. The pairing loop wants a non-iPhone-16/17e phone pairing a pod; the heartbeat fix wants a DASH pod on direct BLE with Pod Keep Alive off and a networked CGM.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UVHbBJsuTRzW33Kne1CJt3