Skip to content

fix(oura): keep a reconnect outstanding with CoreBluetooth across suspension (#1213 follow-up) - #1286

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-standing-connect-survives-suspension
Aug 12, 2026
Merged

fix(oura): keep a reconnect outstanding with CoreBluetooth across suspension (#1213 follow-up)#1286
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-standing-connect-survives-suspension

Conversation

@pipiche38

Copy link
Copy Markdown

The defect

After a drop, the Oura reconnect used a DispatchQueue.main.asyncAfter backoff throughout. That timer
does 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 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 — no timeout, stays outstanding
indefinitely, 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.connect is the same call connect(_:) 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 consecutive
failures, then Oura: leaving a STANDING connect outstanding at 01:27:52 — and revealed a hole in
itself.

[01:26:16] Oura: disconnected - The connection has timed out unexpectedly.
[01:26:19] Oura: connecting to <device>
[01:27:41] Oura: WARNING failed to connect - Failed to encrypt the connection...
[01:27:47] Oura: connecting to <device>
[01:27:52] Oura: WARNING failed to connect - Failed to encrypt the connection...
[01:27:52] Oura: leaving a STANDING connect outstanding for <device>      <- engaged correctly
[01:27:59] Oura: WARNING failed to connect - Failed to encrypt the connection...
[01:27:59] Oura: standing connect failed early - re-issuing in 23s (attempt 4)   <- THE HOLE
                                   ... 5 h 38 m of nothing ...
[07:06:07] Oura: leaving a STANDING connect outstanding for <device>
[07:06:08] Oura: connected - discovering services

CoreBluetooth rejected the standing connect within 7 s with Failed to encrypt the connection — which is
what this ring produces on every reconnect (1–2 failures, then success). The .standingConnectAfter
branch then nil'd standingConnectAt and 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:

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

What this does NOT fix, stated plainly

The night's connecting toconnected took 5 h 40 m, reproducing the 08-06 baseline (26m37s and
5h27m). 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 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

The policy is a pure static func reconnectStep(attempt:secondsSinceStandingConnect:) so it is
unit-testable with no CoreBluetooth, no radio and no ring (OuraLiveSource owns a CBCentralManager and
cannot be built in a test). 11 tests, including:

  • testASlowFailureIsReIssuedImmediatelySoSuspensionCannotStrandUs — the named regression test for the
    5 h 38 m overnight;
  • testTheTimerPathIsBoundedAndNarrow — every delay the timer path can produce is bounded by the floor
    and only reachable below the fast-failure threshold.

StrandTests 1110/0 (1 skipped). Strand macOS built and NOOPiOS iOS built locally — no default
CI compiles either
.

⚠️ Hardware status, honestly: the standing hand-off has now run overnight once, correctly. The
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.

…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
pipiche38 added a commit to pipiche38/noop that referenced this pull request Aug 12, 2026
…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
@pipiche38

Copy link
Copy Markdown
Author

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
CoreBluetooth itself takes to fail (7–11 s on this hardware)"
. The "7–11 s" is not supported. I
measured it properly across every capture that contains connect failures, and it is wrong in both
directions.

connect(_:) and issueStandingConnect(_:) make the same callcentral.connect(p, options: nil),
as the description itself notes — so ordinary-connect failure latencies are valid evidence for
standing-connect latencies, and pooling them is the only way to get a usable sample.

capture failures latencies
260806-0754 7 100, 5, 4, 15, 6, 4, 6 s
260812-0727 6 4, 3, 82, 5, 7 (the standing connect), 3 s

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 s

During a foreground outage the re-issue rate is roughly one attempt per 5 s (≈720/h), not one per
7–11 s (≈400/h). Nearly twice what the body claims. That is not a hot loop, but the description should
not understate it — especially now that the battery ledger's leading hypothesis is that connection
activity, not fetch interval, is the dominant cost
(2.03 %/h at 98.5 % connected vs 0.89 %/h at ~29 %,
same build, same ring, consecutive nights).

2. The self-limiting argument should rest on something else — and there is a stronger one

The honest bound is not the failure latency. It is this:

A re-issue only happens when didFailToConnect is delivered, and a suspended app receives no
callbacks at all. So the attempt rate is exactly zero while suspended — which is precisely the state
where we cannot afford to hammer and cannot observe it — and ~5 s only while the app is awake, which is
the state where aggressive reconnection is what the user wants and the phone is in hand.

That argument is both stronger and actually supported, and I would rather the PR carry it than the
latency number.

3. …and the timer path has never once been reachable

standingConnectFastFailureS is 2 s, and zero of the 13 observed failures were under 2 s (the
minimum is 3 s). So .standingConnectAfter does not fire on this hardware at all — it is a pure
hot-loop backstop for a shape we have never seen, not a path that carries traffic.

I think that is still the right shape (the cost of the guard is nothing, and an instant-failure loop is
a real hazard if some other ring or a future firmware produces one). But it should be described as a
backstop rather than as "the rate limiter", which is how the body currently reads.

What I am not proposing

No diff change. The behaviour is what the fix intends, and the correction makes the case for it better,
not worse: the previous code left nothing outstanding across a suspension and cost 5 h 38 m of ring
downtime on 2026-08-11/12; this leaves something outstanding at a foreground-only ~5 s cadence.

Happy to add a re-issue counter/cap if you would rather bound the foreground case explicitly — say it
and I will push it to this branch. Otherwise I will fix the two sentences in the description.

⚠️ Hardware status is unchanged and still the reason to hold the merge: the immediate-re-issue change
has not itself run overnight. It exercises only if the link drops, and the phone is now on a build that
carries it, so the next capture with an overnight drop settles it.

@pipiche38
pipiche38 marked this pull request as draft August 12, 2026 07:54
@ryanbr

ryanbr commented Aug 12, 2026

Copy link
Copy Markdown
Owner

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 reconnectStep(...) two-regime policy is sound and thoroughly tested, and the immediate-re-issue change correctly closes the hole the first overnight exposed in itself. standingConnectAt is cleared on every teardown/connect/didConnect path, and central.connect(_:options:) is the same call connect() already makes — no new outbound command. scheduleReconnect() still fires from both didDisconnect and didFailToConnect. Clean.

One nit (I'll clean it up in a quick follow-up, no need to touch this PR): nextReconnectDelay() is now dead — reconnectStep inlines the same min(60, 3·2^(n-1)) curve (it has to; the static func can't call the instance method), so the old one has no callers.

On the Apple-only scope — agreed, with a note. It holds today because Android's reconnect is a Handler.postDelayed on the main looper kept alive by the WhoopConnectionService foreground service, so the timer fires. But Android connects with connectGatt(autoConnect = false) and has no standing-connect fallback — so if that FGS is ever killed (aggressive OEM battery management / Doze), Android strands the exact same way. That's precisely the autoConnect = true analogue you flagged; I'll track it as a parity follow-up rather than hold this.

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.

@ryanbr
ryanbr marked this pull request as ready for review August 12, 2026 08:11
@ryanbr
ryanbr merged commit a5b46eb into ryanbr:main Aug 12, 2026
2 checks passed
ryanbr added a commit that referenced this pull request Aug 12, 2026
…#1288)

#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.
@pipiche38

Copy link
Copy Markdown
Author

Field record, third attempt: the immediate-re-issue path still has not been exercised — and the
reason is the strap log, not the fix.

Capture 260813-0736, night of 08-12/13. report.txt held 4 processes and all three previous ones
were head-clipped to 1000 of 2000 line(s)
:

proc 1/4  kept 14:54:54 -> 15:23:07   (clipped)
proc 2/4  kept 16:48:00 -> 17:14:53   (clipped)
proc 3/4  kept 07:11:42 -> 07:33:17   (clipped)   <- THE NIGHT
proc 4/4       07:35:10 -> 07:35:12

The overnight process ran 17:16 → 07:35 (~14 h) and only its last 21 minutes survived. Everything
before 07:11:42 is UNKNOWN, not silence — #1286's signatures are in the erased head. What is in the
kept window:

  • leaving a STANDING connect outstanding … — once, at 07:30:43, post-wake, with no connected before
    the log ends
  • no standing connect failed instantly, no standing connect failed early
  • reconnecting in 3s (attempt 1) ×7, in 6s (attempt 2) ×2 — all post-wake

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
"with ~35 min to spare, not by design"; this one missed by 14 hours. A 2,000-line ring cannot hold a
14 h process, and that blocks #1286 and #1297 equally — no overnight BLE fix can be validated at all
until it changes. A cap/volume change is in preparation and will come as its own PR.

(Unrelated to this PR but worth flagging while you have the context: the same capture's ring produced
almost nothing overnight — 3,640 night-suite beats and 1,628 SpO2 samples against a nine-night norm of
~75,000 and ~29,000. That is a separate filing, with data.)

@pipiche38
pipiche38 deleted the fix/oura-standing-connect-survives-suspension branch August 16, 2026 12:33
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…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>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…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.
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.

2 participants