Skip to content

WHOOP reconnect dies overnight: timed backoff never fires while suspended (the #1286 hole, unported) #1413

Closed

Description

@justinjor-bit

WHOOP reconnect dies overnight: timed backoff never fires while suspended (the #1286 hole, unported)

Device: WHOOP 5.0 / MG — DIS serialPrefix=5AM hwRev=WS50_r00
App: v10.1.0 (sideloaded), iOS 27.0 (24A5390f), iPhone17,2
Background App Refresh: Available · Low Power Mode: off · app backgrounded, never force-quit

Summary

#1286 fixed this for Oura: after a drop, the reconnect used a DispatchQueue.main.asyncAfter
backoff, which does not run in a suspended app, and — the real damage — left nothing outstanding
with CoreBluetooth, so iOS had no reason to ever wake the app.

The WHOOP path (BLEManager.swift) still uses exactly that pattern and has no standing-connect
fallback. standingConnect does not appear in BLEManager.swift in v10.0.0, v10.1.0, or main.

Measured on a WHOOP 5.0/MG last night: the reconnect fired 10 h 46 m 50 s late. #1286's own
measurement was 12 m 33 s.

Evidence

Strap log, 2026-08-16 → 08-17, verbatim:

[21:03:34] No data for >120s — bouncing link to resume streaming
[21:03:34] Disconnected; rescanning in 3s
[21:03:38] Connecting to selected strap <device> — targeted
[21:03:44] Failed to connect — Failed to encrypt the connection, the connection has timed out unexpectedly.
[21:03:44] Reconnecting in 3s (attempt 1)
[21:03:47] Connecting to selected strap <device> — targeted
[21:03:51] Failed to connect — Failed to encrypt the connection, the connection has timed out unexpectedly.
[21:03:51] Reconnecting in 6s (attempt 2)
[21:03:58] Connecting to selected strap <device> — targeted
[21:04:00] Failed to connect — Failed to encrypt the connection, the connection has timed out unexpectedly.
[21:04:00] Reconnecting in 12s (attempt 3)
[07:50:50] Connecting to selected strap <device> — targeted        <-- 10h46m50s later

Zero log lines between 21:04:00 and 07:50:50. The strap was unreachable for the entire night; the
07:50 reconnect coincides with the phone being picked up, not with any strap event.

Note the failure string — Failed to encrypt the connection — is the same one #1286 reports for
the Oura case. Same shape: two or three fast encryption failures, then a scheduled retry that the
suspension swallows.

Consequence for the user: the whole night banks on the strap, and the morning is spent draining it
in the foreground. That is the "recovery score takes hours to appear" class of report.

Root cause

BLEManager.swift (v10.1.0), the two involuntary-reconnect paths:

// ~4747, involuntary disconnect
log("Disconnected…; rescanning in 3s")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { … self.connectFromSystem() }

// ~4817, didFailToConnect
failedConnectAttempts += 1
let delay = min(60.0, 3.0 * pow(2.0, Double(failedConnectAttempts - 1)))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { … self.connectFromSystem() }

Quoting #1286, which diagnosed this precisely:

That timer does not run in a suspended app — the deadline simply passes and the block fires on
resume… 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.

This is a different hole from state restoration (#1213/#1215): that covers the app being TERMINATED
and relaunched; this covers it being merely SUSPENDED, which overnight is the common case. Neither
fixes the other — also #1286's wording, and this log demonstrates it (state restoration is present
and works; it just never gets an event to fire on, because nothing is outstanding).

Suggested fix

Port OuraLiveSource's standing-connect regime to BLEManager:

  • keep the short timed backoff for the first standingConnectAfterAttempts (3) failures — the app is
    demonstrably awake there and a 3 s/6 s retry fixes a transient blip;
  • after that, 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 strap advertises again,
    including from suspension;
  • keep the standingConnectFastFailureS / standingConnectRetryFloor guards so a near-instant
    failure can't hot-loop;
  • route both call sites (involuntary disconnect and didFailToConnect) through it — the
    disconnect path matters most here, since that is where the night died.

central.connect is the same call connect(_:) already makes: no new outbound command, nothing
written to the strap
, so the BLE safety contract is unaffected.

The pure policy (reconnectStep) and its tests already exist for Oura in
OuraReconnectPolicyTests; a WHOOP twin can mirror them and needs no strap to run.

Related

Also filed: the 120 s liveness fuse tears down a healthy 5/MG link every ~5 minutes while the wearer
is at rest (the whoop5EmptyOffload.historyEmpty gate in #580 is narrower than the HR-lull cause it
describes). That is what produces the drop at 21:03:34 above. The two compound: the fuse creates the
drop, and this issue means nothing recovers from it.

Offer

Happy to run diagnostic builds or captures on this hardware — real 5.0/MG (5AM / WS50_r00) with a
working bond and working offload, if useful for the 5/MG paths currently marked "confirm on real
hardware" (e.g. the feedsSync: false GET_DATA_RANGE gate).

Activity

  1. justinjor-bit commented on Aug 17, 2026

    @justinjor-bit
    Author

    Cross-reference: the drop that strands the link overnight is filed as #1414 (the 120s liveness fuse firing on a healthy 5/MG at rest). The two compound — #1414 creates the disconnect, this issue means nothing recovers from it. Fixing #1414 alone would make this far rarer; fixing this one makes it survivable when it does happen.

  2. pipiche38 commented on Aug 18, 2026

    @pipiche38

    Confirmed against current main (not just v10.1.0) — both call sites are unchanged: the disconnect
    handler's rescanning in 3s is still line 4747, didFailToConnect's backoff asyncAfter is still
    ~4820, and there is no standingConnect anywhere in BLEManager.swift. willRestoreState is wired
    (CBCentralManagerOptionRestoreIdentifierKey, BLEManager.restoreID), so the diagnosis is exactly
    right: restoration works, it just never gets an event to fire on.

    The reference implementation is OuraLiveSource.swift:

    • standingConnectAfterAttempts (3), standingConnectRetryFloor (30s), standingConnectFastFailureS
      (2s) — the three guards this issue's "suggested fix" describes.
    • reconnectStep(attempt:since:) is the pure decision function (.standingConnect /
      .standingConnectAfter(delay:) / a normal timed retry) — no CoreBluetooth, so it's exactly what a
      WHOOP twin should mirror.
    • OuraReconnectPolicyTests.swift pins it without a strap.

    One caveat worth carrying into the WHOOP port, from actually validating this on Oura hardware
    (2026-08-10 capture, analysis/2026-08-10-step2-disconnect-reasons-and-link-gaps.txt): a standing
    connect converts "nothing outstanding" into "outstanding and waiting" — a real fix for the dead-timer
    hole, but not a guaranteed bound on how long the wait is. Same night, same mechanism: one standing
    connect completed in 26m37s, the very next one took 5h27m. For that second window the app was
    provably NOT terminated (state restoration didn't apply) and a connect WAS outstanding (the dead-timer
    defect didn't apply either) — both client-side causes excluded, and the strap still didn't come back
    for 5.5h. The surviving explanation is either iOS deprioritizing a suspended app's pending connect(),
    or the peripheral genuinely not being connectable for a stretch — a log can't tell those apart.

    So worth measuring, once this lands: not just "did we leave a standing connect outstanding" but "how
    long did it take to complete" — the fix should collapse the worst-case dead-timer gaps (hours), but
    don't be surprised if some connects still take longer than the old timed-backoff cadence would suggest.

  3. ryanbr commented on Aug 18, 2026

    @ryanbr
    Owner

    Status for testers: the standing-connect fix for this landed after v10.1.0, so it is not in the last release — it is in the current testing build (10.1.1, build 342, just published on testing-latest).

    What changed: WHOOP now holds a central.connect outstanding with CoreBluetooth across suspension instead of relying on a timed backoff that iOS never runs while the app is suspended — the same regime the Oura path adopted in #1286. The symptom this should clear is the one described on #1414: the link is down by wake-up, so the night only drains once the app is foregrounded and the morning score arrives late.

    @justinjor-bit — you have the reproducing hardware and a clean before/after method, so if you are up for a night on 10.1.1 that would settle whether this is closed. Keeping it open until it is confirmed on real hardware; a BLE reconnect path is not something CI can validate.

  4. justinjor-bit commented on Aug 19, 2026

    @justinjor-bit
    Author

    Hardware result on d2356927 — negative, with a specific hypothesis

    Ran your standing-connect commit overnight on the 5/MG (serialPrefix=5AM hwRev=WS50_r00,
    iPhone 17,2 / iOS 27.0, sideloaded, Background App Refresh available, Low Power Mode off, app
    backgrounded and never force-quit).

    Cherry-picked d2356927 onto v10.1.0 + f40897ac and verified the applied diff byte-identical to
    upstream before building. Both targets built, your BLEManagerReconnectPolicyTests passed, full
    StrandTests clean.

    What happened

    The app logged its last line at 09:45:03 on 18 Aug, then nothing for ~20 h until I opened it
    at 06:02:46 on 19 Aug, where it was not connected and had to scan from scratch.

    The resulting drain:

    [06:44:36] Backfill: session persisted 92189 rows (23791 with motion, 23791 skin-temp) across 1 night(s).
    

    For scale, my previous worst single-session drain was 14,891 rows. This is ~6×, i.e. roughly a full
    day banked on the strap rather than a night.

    And the night did not score at all:

    sleep-detect day=2026-08-19 NO-NIGHT hr=105386 rr=44093 resp=0 grav=105413 steps=105413 provided=0 window=36h
    

    vs the previous night on the fuse-only build, which was clean:

    sleep day=2026-08-18 totalSleepMin=436 stages=126+142+169=436 eff=0.95 matched=1 source=computed
    hrv  day=2026-08-18 avgHrv=36.2 coverage=0.91 rrIntegrity=plausible
    

    No STANDING connect outstanding line anywhere in the log, and no Reconnecting in Ns (attempt N)
    either.
    The reconnect path left no trace at all.

    Two caveats, stated up front

    1. I can't confirm I never foregrounded NOOP during 18 Aug. I was on my phone through the day but
      don't specifically remember opening it. So part of that 20 h gap may be ordinary suspension rather
      than the fix failing.
    2. The rolled session buffer for that window shows only 32 lines, all from 09:45:03, so I can't
      rule out that later lines existed and were discarded rather than never written.

    I'd rather flag both than hand you an overconfident negative.

    Hypothesis: the escalation is gated behind timers that can't run

    This part doesn't depend on my phone habits. In reconnectStep:

    guard attempt >= standingConnectAfterAttempts else {          // 3
        return .timedRetry(delay: min(60.0, 3.0 * pow(2.0, Double(max(0, attempt - 1)))))
    }

    Attempts 1 and 2 are DispatchQueue.main.asyncAfter — the exact mechanism #1286 identifies as not
    firing in a suspended app. So for a drop that happens while the app is already suspended:

    1. iOS wakes the app briefly for didDisconnectPeripheral
    2. scheduleReconnect() runs, failedConnectAttempts → 1, schedules a 3 s timer
    3. the app is re-suspended
    4. that timer never fires, so attempt 2 never happens, so attempt 3 never happens, so
      issueStandingConnect() is never reached

    The standing connect is behind two doors that can't open in precisely the scenario it exists for. The
    Oura case in #1286 may not have hit this because the measured drop there (11:16:38) happened with the
    app demonstrably awake — attempt 3 was reached, just late.

    If that reading is right, the fix would need the involuntary-disconnect path to hand off to a standing
    central.connect immediately, rather than escalating through timed retries first — or at minimum
    to issue one alongside the first timed retry, so something is always outstanding. The quick retries
    are only safe when the app is known to be awake, and on a background drop that is exactly what isn't
    known.

    I'm not confident enough in that to call it the answer, but it fits the absence of both log lines.

    Re-testing tonight

    Running the same build again tonight with a tighter protocol so the gap is unambiguous: I'll note the
    exact time I last foreground the app, and won't touch it again until morning. That removes caveat (1)
    and gives a clean before/after against the same build.

    Will report either way — happy to keep iterating on this hardware.

  5. ryanbr commented on Aug 19, 2026

    @ryanbr
    Owner

    Thanks @justinjor-bit — this was an unusually actionable report, and the diagnosis held up in full.

    Fixed on main

    d23569270 — "fix(ble): standing-connect reconnect regime for WHOOP — survive suspension". It
    implements the regime you proposed, including the parts that are easy to get half-right:

    • short timed backoff for the first 3 failures (standingConnectAfterAttempts), where the app is
      demonstrably awake
    • then a standing central.connect, no timeout, left outstanding so iOS can wake the app on
      re-advertise
    • standingConnectFastFailureS (2 s) and standingConnectRetryFloor (30 s) so a near-instant failure
      cannot hot-loop
    • both call sites routed through it — scheduleReconnect() is called from the involuntary
      disconnect (:4901) and from didFailToConnect (:4952). You flagged the disconnect path as the one
      that mattered, and that is the one your night died on.

    No new outbound command and nothing written to the strap, exactly as you noted.

    It is NOT in the build you measured

    You were on v10.1.0, and the fix lands after it — so your report was accurate for that build, and
    standingConnect genuinely was absent from BLEManager.swift when you looked.

    It is in the 10.1.1 testing build, currently 345 / iOS 227:
    https://github.com/ryanbr/noop/releases/tag/testing-latest

    What would confirm it

    The marker to look for is this line, after the third consecutive failure:

    Leaving a STANDING connect outstanding for <device> — CoreBluetooth will reconnect …
    

    Then the real test is the shape of the morning: on 10.1.0 your 07:50 reconnect coincided with picking
    the phone up. If the fix works, the reconnect should land when the strap re-advertises — likely in the
    small hours, and well before you touch the phone. A log with a Leaving a STANDING connect line followed
    by a reconnect you did not trigger is the proof.

    This is still unvalidated on hardware for the overnight case — the code path is exercised, but nobody
    has yet produced the night that shows it recovering. Yours would be the first.

    One honest gap

    The fix is iOS-only: there is no standingConnect equivalent in the Android client. Android suspends
    differently (foreground service / doze rather than CoreBluetooth-mediated wake), so it is not a direct
    port, but the asymmetry is real and worth stating rather than leaving implied.

  6. ryanbr commented on Aug 19, 2026

    @ryanbr
    Owner

    Correcting the last paragraph of my previous comment — I said the fix was iOS-only and that Android had no
    standingConnect equivalent. That was wrong, and wrong in the more interesting direction.

    Android already has it, and has had it longer

    WhoopBleClient.passiveReconnectDecision(failedAttempts, aclHeld, threshold = 3):

    failedAttempts >= threshold && !aclHeld

    Past three failures the reconnect switches to connectGatt(autoConnect = true) — the Android analogue of
    a standing central.connect. It leaves a request outstanding in the BLE stack, which reconnects when the
    strap reappears without the app needing to be awake. Same threshold, same escalation, same principle, and
    it is pure and unit-tested (PassiveReconnectDecisionTest).

    So the hole you measured — nothing outstanding, so the OS never has a reason to wake the app — does not
    exist on the Android client. My grep for standingConnect found nothing there and I read that as "no
    equivalent" rather than "different name for the same idea". That is a bad inference and I should have
    checked what the reconnect actually calls.

    Android is ahead of iOS here, not behind

    The Android rule carries a discriminator iOS does not: aclHeld. Per its own note, a 5/MG that the OS
    still holds bonded and ACL-connected must stay on direct reconnect — passive will not recover it, and
    only a fast direct connect does. So Android escalates to passive only when the strap is genuinely out
    of range, and the comment records that the old plain failedAttempts >= 3 masked that failure mode
    (#61/#265), surviving mainly because a co-resident band usually flaps through STATE_CONNECTED and zeroes
    the counter.

    Which raises a real question about the fix you prompted

    iOS standingConnectAfterAttempts = 3 is the plain rule Android outgrew. If an iPhone holds a strap
    bonded and ACL-connected while connects keep failing, iOS would escalate to a standing connect that — by
    the Android reasoning — cannot recover it, when a fast direct retry would.

    I do not know whether that failure mode occurs on CoreBluetooth, or whether it even exposes an ACL-held
    signal (BLEManager.swift has no equivalent today). It may simply not apply: the two stacks differ enough
    that the Android hazard need not have an iOS twin. Worth investigating rather than assuming, and worth
    watching for in your capture — a night where the standing connect is armed and still does not recover
    would be that shape.

    None of this changes the ask: the standing-connect regime is in the 10.1.1 testing build (345 / iOS 227),
    and a log showing Leaving a STANDING connect outstanding for … followed by a reconnect you did not
    trigger is still what confirms it.

  7. justinjor-bit commented on Aug 19, 2026

    @justinjor-bit
    Author

    Quick clarification, since it changes how much weight my last comment deserves: that overnight run was
    on d2356927, not stock v10.1.0.

    I cherry-picked your commit onto v10.1.0 + f40897ac and verified the applied diff byte-identical to
    upstream before building:

    git checkout -b whoop5-upstream-1413 whoop5-fuse-test
    git cherry-pick d2356927
    git diff d2356927 HEAD -- Strand/BLE/BLEManager.swift   # empty
    

    Both targets built, BLEManagerReconnectPolicyTests passed, full StrandTests clean. I did it that way
    rather than taking the whole 10.1.1 staging line so the only variable versus the previous night was your
    reconnect change. I've since confirmed only two commits have touched BLEManager.swift between
    d2356927 and build 345 (5456325f R-R census, 83596b63 low-refresh), neither in the reconnect path —
    so what I ran should be materially what testers on 345 are running.

    So the result stands as a real negative on your code, not a stale build. Same caveats I gave: I can't
    swear I never foregrounded the app during 18 Aug, and the rolled session buffer may have discarded lines.

    Your aclHeld point may be the better explanation than mine

    I proposed the escalation never reaches issueStandingConnect() — attempts 1 and 2 are asyncAfter,
    which is precisely what doesn't fire while suspended, so a drop that happens during suspension stalls at
    attempt 1 and never gets to 3.

    Yours is different and, if the hazard has an iOS twin, more fundamental: the standing connect gets armed
    and simply cannot recover an ACL-held strap.

    They're distinguishable by one line, which is convenient:

    Log shows Reading
    no Leaving a STANDING connect outstanding escalation never reached — the timer-gating shape
    the line appears, no reconnect follows armed but ineffective — the aclHeld shape
    neither, and no reconnect attempt logged at all the app was never woken for the disconnect

    Last night showed neither the standing-connect line nor any Reconnecting in Ns (attempt N). That is
    weak evidence for the third row as much as the first — the reconnect path may simply never have run,
    which would leave both hypotheses untested rather than one refuted.

    Tonight

    Re-running the same build with a tighter protocol so the gap is unambiguous: noting the exact minute I
    last foreground the app, then not touching it until morning. That removes my caveat and should produce a
    log that lands in one of those three rows cleanly.

    If it lands in row 1, the cheap experiment is issuing the standing connect immediately on an involuntary
    disconnect — keeping the timed retries only for didFailToConnect, where the app is demonstrably awake
    because it just took the callback. Happy to run that as a one-line variant on this hardware if you want a
    data point before committing to a design.

  8. justinjor-bit commented on Aug 20, 2026

    @justinjor-bit
    Author

    Second night on d2356927 — link survived the whole night. Same build, opposite result.

    Re-ran last night on the identical build (v10.1.0 + f40897ac + d2356927, cherry-picked and verified
    byte-identical). Same hardware, same 5/MG, nothing rebuilt.

    The link stayed up from 20:28 to 05:34 with no gap. Roughly 60 consecutive periodic offloads,
    about one every ten minutes, uninterrupted through the night:

    20:28 · 20:38 · 20:48 · 20:58 · 21:08 · 21:18 · 21:28 · 21:38 · 21:48 · 21:58
    22:08 · 22:28 · 22:38 · 22:48 · 23:13 · 23:28 · 23:38 · 23:48 · 00:08 · 00:18
    00:28 · 00:48 · 01:08 · 01:18 · 01:28 · 01:38 · 01:48 · 02:11 · 02:28 · 02:38
    02:48 · 03:08 · 03:18 · 03:38 · 04:08 · 04:18 · 04:38 · 04:48 · 05:08 · 05:18 · 05:34
    

    Morning, at wake:

    [05:34:34] Backfill: session persisted 1161 rows (343 with motion, 343 skin-temp) across 1 night(s).
    [05:34:39] Backfill: reached the end of available history (trim=0xFFFFFFFF) - caught up
    

    1,161 rows, caught up in five seconds. The previous morning was 92,189 rows and roughly eight
    minutes of foreground draining.

    The night scored immediately — sleep day=2026-08-20 totalSleepMin=183 stages=52+39+93 eff=0.86 matched=1, hrv avgHrv=33.0 coverage=0.87, rhr floor=59, resp rpm=16.0 — all present the moment
    I opened the app, versus NO-NIGHT the day before.

    f40897ac is visibly working

    The link did drop twice overnight, and both are logged at the widened fuse:

    [22:28:15] No data for >600s — bouncing link to resume streaming
    [22:28:16] Reconnecting in 3s (attempt 1)
    [22:28:22] Connected — discovering services          ← 6 s down
    
    [02:08:30] No data for >600s — bouncing link to resume streaming
    [02:08:32] Reconnecting in 3s (attempt 1)
    [02:08:37] Connected — discovering services          ← 5 s down
    

    >600s, not >120s. Every recovery this night succeeded on attempt 1.

    The standing connect never fired — and did not need to

    No Leaving a STANDING connect outstanding line anywhere. failedConnectAttempts never reached 3,
    because the app was awake for every drop and the first timed retry landed. So this run neither confirms
    nor refutes the suspension-survival behaviour; it never entered the state that needs it.

    The variable was not the code

    Same build as the failing night. The difference was behavioural: I foregrounded the app roughly
    6–7 times during the preceding day, versus (probably) not at all the day before.

    That suggests the two states are self-sustaining in opposite directions:

    • Alive: link up → BLE traffic → iOS keeps waking the app → the 10-minute periodic offload keeps
      firing → link stays up. Small offloads, ~600 rows each, everything current.
    • Dead: link down while suspended → no BLE events → iOS has no reason to wake the app → nothing
      re-establishes it → stays down until manually opened. Backlog compounds.

    The transition into the dead state is the whole problem. Once in it, nothing recovers without user
    action — which is what #1413 describes and what the standing connect is meant to break.

    So my earlier timer-gating hypothesis is still untested rather than refuted: it only matters on a drop
    that happens while already suspended, and last night never produced one. Worth noting the escalation
    still cannot be reached from suspension by construction, whatever last night showed.

    Practical read

    For a 5/MG owner, entering the night with a healthy link appears to be sufficient — the periodic cycle
    sustains itself unattended for 9+ hours. That is a much better outcome than I had after the first
    night, and f40897ac looks like the larger contributor: with the 120 s fuse tearing the link down
    every 5–10 minutes, the chance of one teardown landing while suspended was high, and one is enough to
    lose the night.

    Happy to keep running nights on this hardware, including a deliberate "don't touch the app all day"
    run if a controlled dead-state entry would be useful for testing the standing connect specifically.

  9. justinjor-bit commented on Aug 21, 2026

    @justinjor-bit
    Author

    Third night — cleanest yet, and one log containing both states

    Same build as the previous two (v10.1.0 + f40897ac + d2356927). I checked that nothing in the
    reconnect path moved between d2356927 and v10.5.0 — only fbbfcfbb, 451b56ff, 5456325f,
    83596b63 touch BLEManager.swift in that range and none are reconnect logic, and standingConnect
    appears identically (16×). So this should read across to what shipped.

    The night: zero drops

    No No data for >600s lines at all this time — the previous night had two. Continuous periodic
    offloads every ten minutes, 23:19 → 05:55, roughly 40 cycles, uninterrupted. Each one ~2,300 rows and
    then immediately reached the end of available history … caught up.

    At wake:

    [05:55:00] Backfill: session persisted 831 rows (276 with motion, 276 skin-temp) across 1 night(s).
    [05:55:05] Backfill: reached the end of available history (trim=0xFFFFFFFF) - caught up
    

    831 rows, five seconds. Night scored on the spot:

    sleep day=2026-08-21 totalSleepMin=476 stages=116+155+206=476 eff=0.94 matched=1 source=computed
    hrv  day=2026-08-21 avgHrv=35.5  coverage=0.92 collapsedCov=0.90 rrIntegrity=plausible
    rhr  day=2026-08-21 floor=54  ·  resp rpm=15.0
    

    rrIntegrity=plausible rather than the previous night's underCovered — coverage 0.92 vs 0.87. The
    steadier link produced measurably better R-R data, not just a faster morning.

    The useful part: the dead state is in the same log

    Immediately before that night, on the same build, same day:

    [17:11:12]  … last line of the previous session
       (5 h 53 m of nothing)
    [23:04:54]  app opened by hand — "Scanning for WHOOP 5.0 / MG…", not connected
    [23:08:56]  Backfill: session persisted 70005 rows (20616 with motion) across 2 night(s)
    

    Five hours fifty-three minutes dead, 70,005 rows banked. 20,616 motion samples ≈ 5.7 h at 1 Hz,
    which matches the gap almost exactly, so the strap was recording fine — nothing was collecting it.

    No Reconnecting in Ns (attempt N), no Leaving a STANDING connect outstanding, no drop logged at
    all. The link went away while the app was suspended and the app was simply never woken again until I
    opened it.

    So one log now contains both states on identical code:

    17:11 → 23:04 dead — 5 h 53 m, 70,005 rows, no recovery, no log lines
    23:19 → 05:55 alive — 40 unbroken cycles, ~2,300 rows each, zero drops

    The only thing separating them is that I opened the app at 23:04.

    Where that leaves the fix

    Three consecutive nights now hold once the link enters the night healthy — that part looks solid, and
    f40897ac is doing the heavy lifting by removing the frequent teardowns that used to roll the dice
    every 5–10 minutes.

    But the standing connect still has not been observed doing anything on this hardware. In three nights
    failedConnectAttempts has never reached 3: every drop happened with the app awake and attempt 1
    recovered it. And the one genuine dead-state entry — the 5 h 53 m gap above — produced no reconnect
    attempt at all, which is the shape I flagged earlier: the escalation is gated behind two
    asyncAfter retries that cannot fire while suspended, so the standing connect is unreachable from
    exactly the state it exists to break.

    I am not claiming that is proven. But across three nights it has now failed to engage twice in the
    only circumstances that call for it, and engaged zero times overall.

    Happy to keep going. If a deliberate dead-state run would help — leave the app untouched all day, then
    capture whether anything at all fires overnight — say the word and I will run it.

  10. justinjor-bit commented on Aug 22, 2026

    @justinjor-bit
    Author

    The standing connect fired for the first time on this hardware, and it worked

    Four nights in, failedConnectAttempts finally reached the threshold and the escalation reached issueStandingConnect():

    [23:14:46] Disconnected — The connection has timed out unexpectedly.
    [23:14:46] Reconnecting in 3s (attempt 1)
    [23:14:49] Connecting to selected strap <device> — targeted
    [23:15:31] Failed to connect — Failed to encrypt the connection, the connection has timed out unexpectedly.
    [23:15:31] Reconnecting in 6s (attempt 2)
    [23:15:37] Connecting to selected strap <device> — targeted
    [23:15:42] Failed to connect — Failed to encrypt the connection, the connection has timed out unexpectedly.
    [23:15:42] Leaving a STANDING connect outstanding for <device> — CoreBluetooth will reconnect whenever the strap is reachable, including while the app is suspended (#1413)
    [23:18:01] Connected — discovering services
    

    2 m 19 s to recovery with nothing else outstanding. No timer, no rescan, no user action. That is the mechanism doing exactly what it was written to do, on a 5/MG, on stock 10.5.0.

    So the open question from my previous three comments is answered: the standing connect is reachable and it is effective. My earlier worry that the two asyncAfter retries would always block the escalation was too strong. It holds for a drop that lands while already suspended, but this drop happened with the app awake, both retries ran on schedule, and attempt 3 arrived. Your aclHeld concern also does not appear to bite here, since the strap was recoverable via the standing connect specifically.

    I would still like to see it exercised from a genuine background-suspended drop before calling it fully proven, but on the evidence this is a working fix rather than an untested one.

    The bad news is what happened three minutes later

    [23:19:28] Bond-loop (#617): 2 bond-then-timeout cycles — surfacing the re-pair guide and pausing auto-reconnect
    

    autoReconnectPausedForBondLoop latched with the app backgrounded, which shuts off the disconnect rescan, the didFailToConnect path, and any further standing connect. Nothing recovered for 6 h 17 m until the process was relaunched by willRestoreState at 05:37:09 and the flag defaulted back to false. 90,926 rows banked.

    So on this run the #1413 fix worked and was then switched off by a different safety mechanism a few minutes later. Filed separately rather than piling it in here, since it is really about #617/#844 rather than this issue. The overlap worth flagging for you: the pause path deliberately leaves nothing outstanding, which means it disables the one primitive that survives suspension. Issuing a standing connect when the pause trips might be a clean way for both mechanisms to coexist.

  11. ryanbr commented on Sep 27, 2026

    @ryanbr
    Owner

    Closing this, five weeks late, because your own reports settled it and nobody marked it.

    @justinjor-bit the standing-connect regime landed in d23569270 and you then validated it over four nights on the reporting hardware, ending on 2026-08-22 with the escalation firing for the first time:

    at 23:15:42 the escalation left a standing connect outstanding for the strap, and the strap was connected again at 23:18:01. 2 m 19 s to recovery, no timer, no rescan, no user action. That is the mechanism doing exactly what your report specified.

    Still live on main: issueStandingConnect at BLEManager.swift:1188, 26 references, the log line above at 1220. Android has the twin in passiveReconnectDecision, which predates the iOS side.

    The compounding cause you cross-referenced, issue 1414, was addressed by f40897ac and you confirmed that one too, roughly 66 teardowns a day to zero over about 30 hours. That change is also still on main: the liveness fuse now widens for the whole 5/MG family rather than only a strap whose firmware serves no history.

    Worth saying what made these two reports unusually useful, because it is rare. You diagnosed the mechanism rather than the symptom, down to the two call sites and the observation that nothing was left outstanding with CoreBluetooth. You cherry-picked the candidate and verified the applied diff byte-identical before building. You reported a NEGATIVE result first, then two clean nights, then the escalation actually firing, and you named the confound each time rather than leaving it to be found. @pipiche38's independent check against main in this thread is the other half of why the diagnosis was trusted quickly.

    One note for anyone arriving here from a similar symptom: a reconnect PR touching this path today is modifying a working, field-validated mechanism, not adding a missing one. #2531 is the worked example of why that distinction matters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions