Skip to content

fix(ble): stop WHOOP 4.0 EVENT frames from holding a stalled offload open - #2816

Open
solpxlb wants to merge 1 commit into
ryanbr:mainfrom
solpxlb:fix/whoop4-event-frames-idle-timeout
Open

solpxlb wants to merge 1 commit into
ryanbr:mainfrom
solpxlb:fix/whoop4-event-frames-idle-timeout

Conversation

@solpxlb

@solpxlb solpxlb commented Oct 20, 2026

Copy link
Copy Markdown

What this PR does

Fixes a WHOOP 4.0 sync hang. A stalled history offload could stay open forever, and while it was open the app refused every new sync. History and stats stopped updating until the app was killed or the strap reconnected.

The core problem

A history sync works like this:

  1. The app sends SEND_HISTORICAL_DATA.
  2. The strap answers PENDING, then streams type-47 history records.
  3. Each chunk ends with METADATA (type 49).
  4. While this runs, backfilling is true. A 60 s idle watchdog (armBackfillTimeout, backfillIdleTimeoutSeconds) ends the session if the strap goes quiet.
  5. The watchdog is re-armed on every frame that isOffloadFrame accepts: types 47, 48, 49, 50 and 56.

The bad case: a WHOOP 4.0 answers PENDING but never starts the type-47 stream. It is not silent, though. It keeps sending its normal EVENT frames (type 48) about every 15 s. EVENT is in the offload set, so each one re-arms the 60 s watchdog. 15 s is always less than 60 s, so:

  • the session never completes, because there is no HISTORY_END or COMPLETE;
  • the session never times out, because the watchdog is fed every ~15 s;
  • backfilling stays true for the whole connection;
  • requestSync refuses every later trigger while backfilling is set. That covers periodic, connect, foreground, manual and strap-initiated syncs.

So one stalled PENDING blocked all history for the life of the connection. Pull-to-refresh did nothing. The app looked connected and healthy.

Evidence (WHOOP 4.0, iPhone, strap log)

To see it, I added a temporary per-type frame counter to the keep-alive log while backfilling was set. It is not part of this PR. A hung session showed only EVENT frames, and nothing else, for minutes:

01:56:13  SEND_HISTORICAL_DATA -> PENDING
...       (no type-47 records, no "historical records use layout ...", no timeout)
02:12:20  Backfill progress: frames[48:2] chunks=0 decoded=0 ... rows=0
          Backfill progress: frames[48:4] chunks=0 ...
          Backfill progress: frames[48:6] chunks=0 ...
  • The app was in the foreground for over 9 minutes. The 60 s watchdog never fired.
  • The strap was far behind: Strap backlog pages behind: 25811. On a healthy day this reads 18–70.
  • An earlier log from 2026-10-14 shows the same pattern. PENDING came at 10:54 and the session ended only at 11:01. That session did eventually time out after 7 minutes instead of 60 s.
  • This looks related to Battery drainage #2780: a WHOOP 4.0 with a large backlog, repeated sync timeouts and nothing saved.

With the fix on the same phone and strap, the next session went PENDING, then historical records use layout v24 5 s later. It drained 500+ chunks and saved heart-rate rows for the missing days.

The fix

New helper on both platforms, next to isOffloadFrame:

  • Swift: BLEManager.offloadFrameShowsProgress(_:family:)
  • Kotlin: WhoopBleClient.offloadFrameShowsProgress(frame, family)

It answers one question: does this offload frame show that history is still moving? Only those frames re-arm the watchdog.

static func offloadFrameShowsProgress(_ frame: [UInt8], family: DeviceFamily) -> Bool {
    guard isOffloadFrame(frame, family: family) else { return false }
    return !(family == .whoop4 && frame[4] == 48)
}

It is used at the one re-arm site on each platform:

if BLEManager.offloadFrameShowsProgress(frame, family: .whoop4) { armBackfillTimeout() }
routeBackfillFrame(frame)

What changes and what doesn't:

  • Routing is unchanged. Every offload frame, EVENT included, still goes to routeBackfillFrame. Events are still stored, and the real-time gesture path (Strap log: surface WHERE offloaded rows landed + WHY (stale-RTC "missed sleep", #67) #69) still fires. Only the watchdog re-arm is filtered.
  • A real offload is unaffected. Type-47 records and METADATA (49) still re-arm. A healthy WHOOP 4.0 sync sends type-47 frames far more often than every 60 s.
  • A stalled offload now ends. If only EVENT frames arrive, the watchdog fires after 60 s. The existing timeout path exits the session and clears backfilling. The durable strap_trim cursor means the next session resumes where it left off.
  • CONSOLE (50) and 56 still re-arm. I left them alone to keep this change narrow.
  • 5/MG is unchanged. Its offload has not been seen stuck in this state, and its event cadence has not been measured. I didn't want to change a working path on guesswork.
  • Both doc comments on armBackfillTimeout are updated to say it re-arms on progress frames.

Risk

The case to think about: a backlog that is truly EVENT-only on WHOOP 4.0, with no type-47 at all. That stream would now depend on its METADATA (49) frames to keep the watchdog alive. A gap of over 60 s with only EVENTs would end the session early. That costs nothing: the trim cursor is durable, and the next sync trigger resumes it. Today the same case hangs every future sync for the connection, which is worse.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Hardware (WHOOP 4.0, iPhone, iOS build):

  • Before the fix: reproduced the hang. The frame counter showed only type 48 for 9+ minutes in the foreground, with no timeout and every sync refused.
  • After the fix: history flowed on the next session (layout v24, 500+ chunks drained, heart-rate rows saved for the missing days).
  • Not seen directly on hardware: the watchdog firing in the stalled case after the fix. The hung session I had was ended by the reinstall. The PENDING-with-no-stream state hasn't come back since, so this part rests on the timeout path, which already exists, plus the unit tests.

Unit tests, twins on both platforms:

  • StrandTests/OffloadProgressWatchdogTests.swift
  • android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt

They cover:

  • A WHOOP 4.0 EVENT is still an offload frame but is not progress.
  • 47, 49, 50 and 56 are progress.
  • Live types 40 and 43 are never progress, on either family.
  • A 5/MG EVENT at byte 8 is unchanged.
  • A short frame returns false.

Local runs:

  • xcodebuild -scheme Strand -destination 'platform=macOS' build: succeeded.
  • xcodebuild ... test-without-building -only-testing:StrandTests/OffloadProgressWatchdogTests: 5 tests, 0 failures.
  • Tools/doc_comment_lint.py: no new detached doc comments.
  • Android: not run locally, because there is no JDK on this machine. The Kotlin test follows the existing Whoop5OffloadTest pattern. I'm relying on android.yml to compile and run it.
  • Android on hardware: not tested. The Kotlin change mirrors the Swift change line for line.

Checklist

  • Swift package tests pass for any package I touched (no Packages/ changes)
  • Android unit tests pass if I touched android/ (not run locally, no JDK; see above, CI will run them)
  • No new build warnings introduced (builds succeed; warnings not separately audited)
  • UI changes use only StrandDesign tokens (no UI changes)
  • No hardcoded hex frame bytes; uses the same record-type numbers isOffloadFrame already uses
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Refs #2780

…open

A WHOOP 4.0 can answer SEND_HISTORICAL_DATA with PENDING and then never
start the type-47 record stream. The strap still emits a fresh EVENT
(type 48) roughly every 15 s, and EVENT is an offload frame, so each one
re-armed the 60 s backfill idle watchdog. The session never completed and
never timed out; `backfilling` stayed true, so every later sync trigger
(periodic, foreground, manual, strap) was refused for the rest of the
connection and no history landed.

Add offloadFrameShowsProgress on both platforms: every offload frame is
still routed to the drain, but on WHOOP 4.0 an EVENT no longer counts as
progress for the watchdog. Type 47 records and METADATA (49) still
re-arm, so a real offload is unaffected. 5/MG behaviour is unchanged.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant