fix(oura): keep a reconnect outstanding with CoreBluetooth across suspension (#1213 follow-up) - #1286
Conversation
…pension (ryanbr#1213 follow-up) Follow-up to ryanbr#1213/ryanbr#1215, and a DIFFERENT hole from the state restoration that fixed: ryanbr#1215 handles the app being TERMINATED and relaunched; this is the app merely being SUSPENDED, which overnight is far more common. Neither fixes the other. ## The defect After a drop, the reconnect used a `DispatchQueue.main.asyncAfter` backoff throughout. That timer does not run in a suspended app — the deadline simply passes and the block fires on resume. Measured 2026-08-10: the link dropped at 11:16:38, attempt 3 was scheduled for ~11:17:06 and actually ran at **11:29:27, 12m33s late**, with zero log lines in between. The ring was down for 12m51s of a 27m window (47%) and the wearer noticed. The damage is not the late timer — it is that after `didFailToConnect` the app held **nothing** outstanding, so there was no way to notice the ring at all. ## The fix After `standingConnectAfterAttempts` (3) consecutive failures, stop scheduling timed retries and hand the reconnect to CoreBluetooth as a STANDING `central.connect`, which has no timeout, stays outstanding indefinitely, and lets iOS wake the app when the ring advertises again — including from suspension. The first few attempts keep the short 3s/6s timed backoff, because the app is demonstrably awake there and a quick retry genuinely fixes a transient blip. No new outbound command: `central.connect` is the same call `connect(_:)` already makes. ## What the first overnight run changed (2026-08-12, build `91812d93`) The fix engaged exactly as designed — three consecutive failures, then `leaving a STANDING connect outstanding` at 01:27:52 — and then revealed a hole in itself. CoreBluetooth rejected the standing connect 7 s later with `Failed to encrypt the connection`, which is what this ring produces on **every** reconnect (1-2 failures, then success). The `.standingConnectAfter` branch responded by nil'ing `standingConnectAt` and arming a 23 s dispatch timer. The app suspended before it fired, so it went into the night holding nothing outstanding — the exact defect above, reintroduced by this fix's own backoff. The re-issue ran at **07:06:07, 5 h 38 m late**. So the rate-limit floor is now applied only to a NEAR-INSTANT failure (`standingConnectFastFailureS`, 2 s). Anything slower re-issues immediately, so a suspension can never catch us holding only a timer. This is safe and self-limiting: * the floor only ever existed to avoid hammering while the app is AWAKE. A suspended app has no loop to break — no callbacks are delivered — so paying it on a dispatch timer traded the one thing that survives suspension for protection against a problem that cannot occur there; * the re-issue rate is then set by how long CoreBluetooth itself takes to fail (7-11 s on this hardware), not by us; * a genuinely instant failure still takes the timer, and a timer is right there precisely because such a failure can only be observed with the app awake. ## Scope Apple-only by argument, not omission: Android's reconnect runs inside the `WhoopConnectionService` foreground service, so its timer does fire — it is not suspended the way iOS suspends. `autoConnect = true` would be the direct analogue if that ever stops holding. No analytics, no stored data, no migration, no new user-facing strings. ## Verification Policy is a pure `static func reconnectStep` so it is unit-testable with no CoreBluetooth, no radio and no ring: 11 tests, including a named regression test for the 5 h 38 m overnight and a bound proving the timer path is unreachable from suspension. StrandTests **1110/0** (1 skipped). `Strand` macOS built and `NOOPiOS` iOS built locally — no default CI compiles either.⚠️ **Not yet re-validated on hardware.** The standing hand-off itself has now run overnight once (correctly); the immediate-re-issue change this commit adds has not. It needs an overnight with a drop, exported before the app restarts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1vUfDUx7otejE16AMRvYx
…yanbr#1286 delta) Integration-only carry of the delta between the standing-connect fix already in this stack (`6d744b25`, the original fix A) and what was submitted upstream as PR ryanbr#1286 (`fix/oura-standing-connect-survives-suspension` @ `876e382d`). Applied as a delta rather than a cherry-pick because `876e382d` contains the WHOLE of fix A, which this branch already carries — cherry-picking it would duplicate the commit. The three hunks below plus the test file bring this branch's policy byte-identical to the PR's. The fix: the 30 s floor now applies only to a NEAR-INSTANT failure (`standingConnectFastFailureS`, 2 s). Anything slower re-issues immediately, so a suspension can never catch the app holding only a dispatch timer — which is exactly what happened on the 2026-08-11/12 overnight, costing 5 h 38 m of ring downtime after the standing connect was rejected 7 s in with `Failed to encrypt the connection`. Verified byte-identical to `876e382d` for both files after committing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1vUfDUx7otejE16AMRvYx
|
Correcting a number in the description before anyone relies on it. The body says the immediate-re-issue path is self-limiting because "the rate is set by how long
n = 13 — min 3 s, median 5 s, max 100 s. Distribution: 7 in 2–6 s, 3 in 6–12 s, 3 above 12 s. Two things follow, and the second is the one worth knowing: 1. The real cadence is ~5 s, not 7–11 sDuring a foreground outage the re-issue rate is roughly one attempt per 5 s (≈720/h), not one per 2. The self-limiting argument should rest on something else — and there is a stronger oneThe honest bound is not the failure latency. It is this:
That argument is both stronger and actually supported, and I would rather the PR carry it than the 3. …and the timer path has never once been reachable
I think that is still the right shape (the cost of the guard is nothing, and an instant-failure loop is What I am not proposingNo diff change. The behaviour is what the fix intends, and the correction makes the case for it better, Happy to add a re-issue counter/cap if you would rather bound the foreground case explicitly — say it
|
|
Reviewed — this is excellent work, and the measured-not-reasoned framing (two named regression tests for the two real incidents) is exactly right. Approving. Correctness/BLE: the pure One nit (I'll clean it up in a quick follow-up, no need to touch this PR): On the Apple-only scope — agreed, with a note. It holds today because Android's reconnect is a Hardware honesty noted and appreciated. Merging on the strength of the unit-tested policy + that it strictly improves a measured 47%-downtime defect with bounded downside — but the immediate-re-issue is a first hardware exposure, so if you can grab the next overnight-with-a-drop export (before the app restarts), that's the confirmation we want. Thanks @pipiche38. |
|
Field record, third attempt: the immediate-re-issue path still has not been exercised — and the Capture The overnight process ran 17:16 → 07:35 (~14 h) and only its last 21 minutes survived. Everything
Nothing to score either way. Not evidence against the fix; evidence that the test did not run. 📌 The real blocker is now the log cap, not the night. The previous capture kept from 00:50 and fitted (Unrelated to this PR but worth flagging while you have the context: the same capture's ring produced |
…pension (ryanbr#1213 follow-up) (ryanbr#1286) Follow-up to ryanbr#1213/ryanbr#1215, and a DIFFERENT hole from the state restoration that fixed: ryanbr#1215 handles the app being TERMINATED and relaunched; this is the app merely being SUSPENDED, which overnight is far more common. Neither fixes the other. ## The defect After a drop, the reconnect used a `DispatchQueue.main.asyncAfter` backoff throughout. That timer does not run in a suspended app — the deadline simply passes and the block fires on resume. Measured 2026-08-10: the link dropped at 11:16:38, attempt 3 was scheduled for ~11:17:06 and actually ran at **11:29:27, 12m33s late**, with zero log lines in between. The ring was down for 12m51s of a 27m window (47%) and the wearer noticed. The damage is not the late timer — it is that after `didFailToConnect` the app held **nothing** outstanding, so there was no way to notice the ring at all. ## The fix After `standingConnectAfterAttempts` (3) consecutive failures, stop scheduling timed retries and hand the reconnect to CoreBluetooth as a STANDING `central.connect`, which has no timeout, stays outstanding indefinitely, and lets iOS wake the app when the ring advertises again — including from suspension. The first few attempts keep the short 3s/6s timed backoff, because the app is demonstrably awake there and a quick retry genuinely fixes a transient blip. No new outbound command: `central.connect` is the same call `connect(_:)` already makes. ## What the first overnight run changed (2026-08-12, build `91812d93`) The fix engaged exactly as designed — three consecutive failures, then `leaving a STANDING connect outstanding` at 01:27:52 — and then revealed a hole in itself. CoreBluetooth rejected the standing connect 7 s later with `Failed to encrypt the connection`, which is what this ring produces on **every** reconnect (1-2 failures, then success). The `.standingConnectAfter` branch responded by nil'ing `standingConnectAt` and arming a 23 s dispatch timer. The app suspended before it fired, so it went into the night holding nothing outstanding — the exact defect above, reintroduced by this fix's own backoff. The re-issue ran at **07:06:07, 5 h 38 m late**. So the rate-limit floor is now applied only to a NEAR-INSTANT failure (`standingConnectFastFailureS`, 2 s). Anything slower re-issues immediately, so a suspension can never catch us holding only a timer. This is safe and self-limiting: * the floor only ever existed to avoid hammering while the app is AWAKE. A suspended app has no loop to break — no callbacks are delivered — so paying it on a dispatch timer traded the one thing that survives suspension for protection against a problem that cannot occur there; * the re-issue rate is then set by how long CoreBluetooth itself takes to fail (7-11 s on this hardware), not by us; * a genuinely instant failure still takes the timer, and a timer is right there precisely because such a failure can only be observed with the app awake. ## Scope Apple-only by argument, not omission: Android's reconnect runs inside the `WhoopConnectionService` foreground service, so its timer does fire — it is not suspended the way iOS suspends. `autoConnect = true` would be the direct analogue if that ever stops holding. No analytics, no stored data, no migration, no new user-facing strings. ## Verification Policy is a pure `static func reconnectStep` so it is unit-testable with no CoreBluetooth, no radio and no ring: 11 tests, including a named regression test for the 5 h 38 m overnight and a bound proving the timer path is unreachable from suspension. StrandTests **1110/0** (1 skipped). `Strand` macOS built and `NOOPiOS` iOS built locally — no default CI compiles either.⚠️ **Not yet re-validated on hardware.** The standing hand-off itself has now run overnight once (correctly); the immediate-re-issue change this commit adds has not. It needs an overnight with a drop, exported before the app restarts. Claude-Session: https://claude.ai/code/session_01M1vUfDUx7otejE16AMRvYx Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ow-up) (ryanbr#1288) ryanbr#1286 moved the reconnect backoff into the pure static reconnectStep(), which inlines the same min(60, 3*2^(n-1)) curve (a static func can't call the instance method), leaving nextReconnectDelay() with no callers. Remove the orphan. No behaviour change.
The defect
After a drop, the Oura reconnect used a
DispatchQueue.main.asyncAfterbackoff throughout. That timerdoes not run in a suspended app — the deadline passes and the block fires on resume.
Measured 2026-08-10: the link dropped at 11:16:38, attempt 3 was scheduled for ~11:17:06, and actually ran
at 11:29:27 — 12m33s late, with zero log lines in between. The ring was down for 12m51s of a 27m
window (47%) and the wearer noticed.
The damage is not the late timer. It is that after
didFailToConnectthe app held nothing outstanding,so there was no way to notice the ring at all.
The fix
After
standingConnectAfterAttempts(3) consecutive failures, stop scheduling timed retries and hand thereconnect to CoreBluetooth as a standing
central.connect— no timeout, stays outstandingindefinitely, and iOS wakes the app when the ring advertises again, including from suspension. The first
attempts keep the short 3s/6s timed backoff, because the app is demonstrably awake there and a quick retry
genuinely fixes a transient blip.
No new outbound command:
central.connectis the same callconnect(_:)already makes.What the first overnight run changed — the interesting half
Ran overnight 2026-08-11/12 on build
91812d93. It engaged exactly as designed — three consecutivefailures, then
Oura: leaving a STANDING connect outstandingat 01:27:52 — and revealed a hole initself.
CoreBluetooth rejected the standing connect within 7 s with
Failed to encrypt the connection— which iswhat this ring produces on every reconnect (1–2 failures, then success). The
.standingConnectAfterbranch then nil'd
standingConnectAtand armed a 23 s dispatch timer. The app suspended before it fired,so it entered the night holding nothing outstanding: the exact defect this fix exists to remove,
reintroduced by its own backoff.
So the rate-limit floor now applies only to a near-instant failure (
standingConnectFastFailureS,2 s). Anything slower re-issues immediately, so a suspension can never catch us holding only a timer:
to break — no callbacks are delivered — so paying it on a dispatch timer traded the one thing that
survives suspension for protection against a problem that cannot occur there;
hardware), not by us;
a failure can only be observed with the app awake.
What this does NOT fix, stated plainly
The night's
connecting to→connectedtook 5 h 40 m, reproducing the 08-06 baseline (26m37s and5h27m). When this ring goes unreachable overnight it stays unreachable for hours, and no client-side
change reaches that. This fix turns "nothing outstanding" into "outstanding and waiting"; it cannot make
the ring connectable.
Worth knowing for triage: banked recovery is unaffected — HR-minute fill for that night was 98.8 %
despite 5 h 40 m offline. The overnight connection problem costs live data only.
Scope
Apple-only by argument, not omission: Android's reconnect runs inside the
WhoopConnectionServiceforeground service, so its timer does fire — it is not suspended the way iOS suspends.
autoConnect = truewould be the direct analogue if that ever stops holding. No analytics, no stored data, no migration, no
new user-facing strings.
Verification
The policy is a pure
static func reconnectStep(attempt:secondsSinceStandingConnect:)so it isunit-testable with no CoreBluetooth, no radio and no ring (
OuraLiveSourceowns aCBCentralManagerandcannot be built in a test). 11 tests, including:
testASlowFailureIsReIssuedImmediatelySoSuspensionCannotStrandUs— the named regression test for the5 h 38 m overnight;
testTheTimerPathIsBoundedAndNarrow— every delay the timer path can produce is bounded by the floorand only reachable below the fast-failure threshold.
StrandTests1110/0 (1 skipped).StrandmacOS built andNOOPiOSiOS built locally — no defaultCI compiles either.
immediate-re-issue change in this commit has not yet run on hardware — it needs an overnight with a
drop, exported before the app restarts. Happy to hold the merge for that if preferred.