Repository navigation
Description
Symptom (real capture, WHOOP 4.0, 9.3.2-staging, 2026-08-08)
The offload path fired 7 bursts of 24 empty offloads each — ~168 empty offloads in ~27 minutes (11:55, 12:01, 12:06, 12:10, 12:15, 12:18, 12:22, each hitting the 24/24 cap). Each empty offload also did a 2M-PHY request+release, so it's a sustained radio/CPU storm — a real battery cost on a non-exempt phone (~4%/hr overnight in the same capture).
11:55:29 connect down (uptime 139s) → reconnect n=4 reason=status0
11:55:32 connect up
11:55:36 offload progress trim=73019 chunkRows=0 sessionRows=0 → result=empty
11:55:36 Backfill: auto-continuing (#364/#451) — … re-kicking offload 1/24 without waiting the 15-min floor.
11:55:37 offload progress trim=73020 chunkRows=0 → result=empty → re-kicking 2/24
… → … → re-kicking 24/24
211 of the session's offloads were chunkRows=0 (empty); the real ones return 245–251 rows.
Root cause
WhoopBleClient.shouldAutoContinue (mirrored in Swift BackfillContinuation.shouldAutoContinue) has two backlog guards:
- 2a:
strapNewest − ourFrontier > AUTO_CONTINUE_BEHIND_GAP_SECONDS (300)→return true - 2b (l10n(de): localize the Trends / Explore / Deep Timeline screens (Android) #451):
return rowsPersistedThisSession > 0
Only 2b checks rows. 2a returns true regardless of whether the offload actually pulled any rows. When the strap reports a newest that's ahead of our frontier but the offload for that range returns chunkRows=0 (a phantom frontier gap — the strap advertises a timestamp it won't hand over), the frontier never advances, strapNewest − frontier stays > 300, and 2a re-fires every pass to the full cap of 24. Guard #3 (lastTrimAdvanced) doesn't stop it because the trim u32 climbs by 1 on empty ENDs (the code comment at WhoopBleClient.kt:1425 notes this).
In the 11:55 burst the gap was 323s (strapNewest 1786146904 − frontier 1786146581), just over the 300s threshold — so 2a latched.
The comment at WhoopBleClient.kt:1478-1479 claims "empty / console-only ENDs persist 0 rows, so a stuck or caught-up strap won't spin" — but that's only enforced on the 2b path, not 2a.
Fix
An empty session (rowsPersistedThisSession <= 0) must never auto-continue — regardless of the reported frontier gap. Add that guard before 2a on both platforms (WhoopBleClient.shouldAutoContinue + Swift BackfillContinuation.shouldAutoContinue, kept byte-for-behaviour), and pin it with a test: strap-newest 400s ahead of frontier + rowsPersistedThisSession = 0 must return false. This makes the existing comment's promise actually hold and collapses each 24-spin into a single empty offload + fall back to the 15-min floor. Healthy deep-backlog drains are unaffected (their sessions persist 245–251 rows).
Related
- ble: count empty-offload stalls toward the backfill backoff (battery) #1123/ble: count empty-offload stalls toward the backfill backoff on iOS/macOS (#1123 parity) #1125 empty-offload backoff (steady-state works; a reconnect resets the streak and re-enables this storm — see also making that survive a reconnect).
- Sleep presentation: stitch short mid-night wakes into one displayed night (+ motion-aware wake classification) #364/l10n(de): localize the Trends / Explore / Deep Timeline screens (Android) #451 (the auto-continue this touches), Add HR insights to any strap worn workout #928/Health Connect import: range-scan the kcal lists instead of walking them per workout #1012 (future-dated-strap guards, unaffected).
- Broader battery context: History offload costs 41 min (5/MG) to 2.4 h (4.0) of radio per day; the official app does 24 h in 1–3 min #1007. crossSecondOverCount (WHOOP 4.0 R-R over-count contaminates HRV (not reproducible since 2026-08-25, cause unidentified) #1118) separately doubles the DB row volume (~180k HR rows/day) feeding the re-score cost.
Activity
ble: stop the auto-continue empty-offload storm — guard 2a on real ro…
fix(ble): gate empty-offload auto-continue on the persisted-rows verd…
ble: stop the auto-continue empty-offload storm — guard 2a on real ro…
fix(ble): gate empty-offload auto-continue on the persisted-rows verd…
feat(power): "Low refresh" hourly sync, a sub-option of Power saving
feat(power): "Low refresh" hourly sync, a sub-option of Power saving
feat(power): "Low refresh" hourly sync, a sub-option of Power saving
feat(power): "Low refresh" hourly sync, a sub-option of Power saving (#…
revert: the consumed-frontier change rested on a field I had not esta…
Say WHY the Rest card is pending sync, instead of changing in silence
Say why the Rest card is pending sync, instead of changing in silence (…
refactor(android): return the acknowledged buzz write to its own change