Skip to content

WHOOP 4.0: offload auto-continue (#364/#451) spins empty-offload storms — guard 2a ignores rows #1144

Closed

Description

@ryanbr

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:

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

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