ble: stop the auto-continue empty-offload storm — guard 2a on real rows - #1145
Merged
Conversation
…ws (#1144) A real WHOOP 4.0 capture showed the offload auto-continue (#364/#451) firing 7 bursts of 24 empty offloads (~168 in 27 min), each with a 2M-PHY round-trip — a battery/radio storm. Root cause: guard 2a (strapNewest - frontier > 300s -> continue) returns true regardless of whether the offload pulled any rows. When the strap advertises a newest ahead of our frontier but the offload hands back chunkRows=0 (a phantom gap), the frontier can't advance, the gap stays > 300s, and 2a re-fires to the full 24 cap. Guard 3 (lastTrimAdvanced) doesn't catch it — the trim u32 climbs on empty ENDs. Fix (both platforms, shouldAutoContinue kept byte-for-behaviour): an EMPTY session (rowsPersistedThisSession <= 0) never auto-continues, whatever the reported gap. Collapses each 24-spin into a single empty offload + fall back to the 15-min floor. Healthy deep backlogs persist real rows (245-251/chunk), so they're unaffected. Tests: added the phantom-gap regression (gap holds, rows=0 -> stop) both platforms; continue-expecting cases now pass rows>0 (a frontier can't advance with 0 rows). Verified: Kotlin testFullDebugUnitTest green; Swift shouldAutoContinue via isolated harness.
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1144. From a real WHOOP 4.0 capture (9.3.2-staging) I analysed today.
The bug (measured)
The offload auto-continue (#364/#451) fired 7 bursts of 24 empty offloads (~168 in 27 min), each with a 2M-PHY round-trip — a sustained battery/radio storm (the same capture showed ~4%/hr overnight background drain).
Root cause:
shouldAutoContinueguard 2a (strapNewest − frontier > 300s → return true) fires regardless of whether the offload pulled any rows. When the strap advertises anewestahead of our frontier but the offload returnschunkRows=0(a phantom gap), the frontier can't advance without rows, sonewest − frontierstays > 300s and 2a re-fires to the full 24 cap. Guard 3 (lastTrimAdvanced) doesn't catch it — the trim u32 climbs on empty ENDs. Only guard 2b checked rows.The fix
Both platforms (
WhoopBleClient.shouldAutoContinue+ SwiftBackfillContinuation.shouldAutoContinue, kept byte-for-behaviour): an empty session (rowsPersistedThisSession <= 0) never auto-continues, whatever the reported gap — making 2b's row check the one authority for both cases. Each 24-spin collapses to a single empty offload + fall back to the 15-min floor. Healthy deep backlogs persist 245–251 rows/chunk, so they're unaffected.Verification
BackfillContinuationTest— green locally (24 tests, incl. the new phantom-gap regression).shouldAutoContinue— validated via an isolated harness (StrandTests runs only under xcodebuild): happy-path continue, phantom-gap stop, stale-epoch-with-rows continue, caught-up stop all correct.app-build.Tests updated: continue-expecting cases now pass
rows>0(a frontier can't advance with 0 rows, so the oldrows=0happy-paths modelled an impossible state).