Repository navigation
Description
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 afterdidFailToConnectthe 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/standingConnectRetryFloorguards 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
justinjor-bit commentedon Aug 17, 2026
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.
fix(ble): standing-connect reconnect regime for WHOOP — survive suspe…
pipiche38 commentedon Aug 18, 2026
Confirmed against current
main(not just v10.1.0) — both call sites are unchanged: the disconnecthandler's
rescanning in 3sis still line 4747,didFailToConnect's backoffasyncAfteris still~4820, and there is no
standingConnectanywhere inBLEManager.swift.willRestoreStateis wired(
CBCentralManagerOptionRestoreIdentifierKey,BLEManager.restoreID), so the diagnosis is exactlyright: 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 aWHOOP twin should mirror.
OuraReconnectPolicyTests.swiftpins 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 standingconnect 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.
ryanbr commentedon Aug 18, 2026
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.connectoutstanding 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.
justinjor-bit commentedon Aug 19, 2026
Hardware result on
d2356927— negative, with a specific hypothesisRan 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
d2356927onto v10.1.0 +f40897acand verified the applied diff byte-identical toupstream before building. Both targets built, your
BLEManagerReconnectPolicyTestspassed, fullStrandTestsclean.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 connectedand had to scan from scratch.The resulting drain:
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:
vs the previous night on the fuse-only build, which was clean:
No
STANDING connect outstandingline anywhere in the log, and noReconnecting in Ns (attempt N)either. The reconnect path left no trace at all.
Two caveats, stated up front
don't specifically remember opening it. So part of that 20 h gap may be ordinary suspension rather
than the fix failing.
09:45:03, so I can'trule 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:Attempts 1 and 2 are
DispatchQueue.main.asyncAfter— the exact mechanism #1286 identifies as notfiring in a suspended app. So for a drop that happens while the app is already suspended:
didDisconnectPeripheralscheduleReconnect()runs,failedConnectAttempts→ 1, schedules a 3 s timerissueStandingConnect()is never reachedThe 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.connectimmediately, rather than escalating through timed retries first — or at minimumto 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.
ryanbr commentedon Aug 19, 2026
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". Itimplements the regime you proposed, including the parts that are easy to get half-right:
standingConnectAfterAttempts), where the app isdemonstrably awake
central.connect, no timeout, left outstanding so iOS can wake the app onre-advertise
standingConnectFastFailureS(2 s) andstandingConnectRetryFloor(30 s) so a near-instant failurecannot hot-loop
scheduleReconnect()is called from the involuntarydisconnect (
:4901) and fromdidFailToConnect(:4952). You flagged the disconnect path as the onethat 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
standingConnectgenuinely was absent fromBLEManager.swiftwhen 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:
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 connectline followedby 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
standingConnectequivalent in the Android client. Android suspendsdifferently (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.
ryanbr commentedon Aug 19, 2026
Correcting the last paragraph of my previous comment — I said the fix was iOS-only and that Android had no
standingConnectequivalent. 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):Past three failures the reconnect switches to
connectGatt(autoConnect = true)— the Android analogue ofa standing
central.connect. It leaves a request outstanding in the BLE stack, which reconnects when thestrap 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
standingConnectfound nothing there and I read that as "noequivalent" 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 OSstill 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 >= 3masked that failure mode(#61/#265), surviving mainly because a co-resident band usually flaps through
STATE_CONNECTEDand zeroesthe counter.
Which raises a real question about the fix you prompted
iOS
standingConnectAfterAttempts = 3is the plain rule Android outgrew. If an iPhone holds a strapbonded 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.swifthas no equivalent today). It may simply not apply: the two stacks differ enoughthat 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 nottrigger is still what confirms it.
justinjor-bit commentedon Aug 19, 2026
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+f40897acand verified the applied diff byte-identical toupstream before building:
Both targets built,
BLEManagerReconnectPolicyTestspassed, fullStrandTestsclean. I did it that wayrather 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.swiftbetweend2356927and build 345 (5456325fR-R census,83596b63low-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
aclHeldpoint may be the better explanation than mineI proposed the escalation never reaches
issueStandingConnect()— attempts 1 and 2 areasyncAfter,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:
Leaving a STANDING connect outstandingaclHeldshapeLast night showed neither the standing-connect line nor any
Reconnecting in Ns (attempt N). That isweak 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 awakebecause 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.
justinjor-bit commentedon Aug 20, 2026
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 verifiedbyte-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:
Morning, at wake:
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 momentI opened the app, versus
NO-NIGHTthe day before.f40897acis visibly workingThe link did drop twice overnight, and both are logged at the widened fuse:
>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 outstandingline anywhere.failedConnectAttemptsnever 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:
firing → link stays up. Small offloads, ~600 rows each, everything current.
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
f40897aclooks like the larger contributor: with the 120 s fuse tearing the link downevery 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.
Correct the comment that still described the removed backoff (#1413)
justinjor-bit commentedon Aug 21, 2026
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 thereconnect path moved between
d2356927andv10.5.0— onlyfbbfcfbb,451b56ff,5456325f,83596b63touchBLEManager.swiftin that range and none are reconnect logic, andstandingConnectappears identically (16×). So this should read across to what shipped.
The night: zero drops
No
No data for >600slines at all this time — the previous night had two. Continuous periodicoffloads 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:
831 rows, five seconds. Night scored on the spot:
rrIntegrity=plausiblerather than the previous night'sunderCovered— coverage 0.92 vs 0.87. Thesteadier 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:
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), noLeaving a STANDING connect outstanding, no drop logged atall. 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:
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
f40897acis doing the heavy lifting by removing the frequent teardowns that used to roll the diceevery 5–10 minutes.
But the standing connect still has not been observed doing anything on this hardware. In three nights
failedConnectAttemptshas never reached 3: every drop happened with the app awake and attempt 1recovered 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
asyncAfterretries that cannot fire while suspended, so the standing connect is unreachable fromexactly 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.
Hand off to the standing connect on the first drop, not the third (#1520
justinjor-bit commentedon Aug 22, 2026
The standing connect fired for the first time on this hardware, and it worked
Four nights in,
failedConnectAttemptsfinally reached the threshold and the escalation reachedissueStandingConnect():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
asyncAfterretries 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. YouraclHeldconcern 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
autoReconnectPausedForBondLooplatched with the app backgrounded, which shuts off the disconnect rescan, thedidFailToConnectpath, and any further standing connect. Nothing recovered for 6 h 17 m until the process was relaunched bywillRestoreStateat 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.
fix(ble): resolve post-reboot and overnight auto-reconnect deadlocks …
fix(ble): resolve post-reboot and overnight auto-reconnect deadlocks …
ryanbr commentedon Sep 27, 2026
Closing this, five weeks late, because your own reports settled it and nobody marked it.
@justinjor-bit the standing-connect regime landed in
d23569270and 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:
issueStandingConnectatBLEManager.swift:1188, 26 references, the log line above at 1220. Android has the twin inpassiveReconnectDecision, which predates the iOS side.The compounding cause you cross-referenced, issue 1414, was addressed by
f40897acand 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.
fix(ble): resolve post-reboot and overnight auto-reconnect deadlocks …