From b6a5fab5486f3dffcf2290ed6f4aa7854733672c Mon Sep 17 00:00:00 2001 From: solpxlb <239856292+solpxlb@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:20:26 +0530 Subject: [PATCH] fix(ble): stop WHOOP 4.0 EVENT frames from holding a stalled offload 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 #2780 --- Strand/BLE/BLEManager.swift | 22 ++++++-- .../OffloadProgressWatchdogTests.swift | 47 +++++++++++++++++ .../main/java/com/noop/ble/WhoopBleClient.kt | 25 +++++++-- .../noop/ble/OffloadProgressWatchdogTest.kt | 52 +++++++++++++++++++ 4 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 StrandTests/OffloadProgressWatchdogTests.swift create mode 100644 android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..a7c4ce8a42 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -2641,8 +2641,24 @@ public final class BLEManager: NSObject, ObservableObject { } } - /// Re-arm the idle watchdog. Called on every offload frame during backfill so the timer resets - /// as long as the strap keeps sending HISTORY; if the strap goes silent the timer fires and we + /// True when an offload frame is evidence that the strap is still handing over history, so the + /// backfill idle watchdog may re-arm on it. Every `isOffloadFrame` frame is still routed to the + /// Backfiller; this only decides which ones count as PROGRESS. + /// + /// A WHOOP 4.0 EVENT frame (type 48) does not. The strap emits a fresh event roughly every 15 s + /// whether or not it is offloading, and when it answers SEND_HISTORICAL_DATA with PENDING but never + /// starts the type-47 stream, those events alone re-armed the 60 s watchdog forever: the session + /// neither completed nor timed out, and `backfilling` stayed true, so every later sync trigger was + /// refused for the life of the connection. A real offload streams type-47 records and closes chunks + /// with METADATA (49), both of which still re-arm. 5/MG is unchanged: its offload has not been + /// observed in this state, and its event cadence is unmeasured. + static func offloadFrameShowsProgress(_ frame: [UInt8], family: DeviceFamily) -> Bool { + guard isOffloadFrame(frame, family: family) else { return false } + return !(family == .whoop4 && frame[4] == 48) + } + + /// Re-arm the idle watchdog. Called on every progress frame (`offloadFrameShowsProgress`) so the + /// timer resets as long as the strap keeps sending HISTORY; if the strap goes silent the timer fires and we /// exit the session (the durable strap_trim cursor means the next session resumes where we left /// off). Timeout is generous (60 s, not 20 s): the unstoppable ~2/s type-43 raw flood eats BLE /// airtime, so genuine offload frames can arrive in bursts with multi-second lulls between chunks @@ -7403,7 +7419,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // is a permanent loss. The verdict is formed once here, from the verifier, and // handed to the counter — no parse on this path, which is why it was skipped. router.noteOffloadFrameVerdict(verifyFrame(frame, family: .whoop4)) - armBackfillTimeout() + if BLEManager.offloadFrameShowsProgress(frame, family: .whoop4) { armBackfillTimeout() } routeBackfillFrame(frame) // …but a REAL-TIME physical gesture (double-tap / wrist) must still fire even mid- // offload (#69). Gated on ts≈now so replayed historical EVENTs (old ts) are ignored. diff --git a/StrandTests/OffloadProgressWatchdogTests.swift b/StrandTests/OffloadProgressWatchdogTests.swift new file mode 100644 index 0000000000..8b03390fa9 --- /dev/null +++ b/StrandTests/OffloadProgressWatchdogTests.swift @@ -0,0 +1,47 @@ +import XCTest +import WhoopProtocol +@testable import Strand + +/// Which offload frames may re-arm the backfill idle watchdog (`BLEManager.offloadFrameShowsProgress`). +/// +/// A WHOOP 4.0 that answered SEND_HISTORICAL_DATA with PENDING and then sent only its periodic EVENT +/// frames kept the session open indefinitely, because every event re-armed the 60 s watchdog. These pin +/// that events no longer count as progress on a 4.0 while every genuine offload type still does, and +/// that 5/MG keeps its existing behaviour. Twin of the Android `OffloadProgressWatchdogTest`. +@MainActor +final class OffloadProgressWatchdogTests: XCTestCase { + + private func frame(type: UInt8, at index: Int) -> [UInt8] { + var f = [UInt8](repeating: 0, count: 16) + f[index] = type + return f + } + + func testWhoop4EventFrameIsNotProgress() { + XCTAssertTrue(BLEManager.isOffloadFrame(frame(type: 48, at: 4), family: .whoop4), + "an EVENT is still an offload frame and is still routed to the Backfiller") + XCTAssertFalse(BLEManager.offloadFrameShowsProgress(frame(type: 48, at: 4), family: .whoop4)) + } + + func testWhoop4HistoryTypesAreProgress() { + for t: UInt8 in [47, 49, 50, 56] { + XCTAssertTrue(BLEManager.offloadFrameShowsProgress(frame(type: t, at: 4), family: .whoop4), + "type \(t) must re-arm the watchdog") + } + } + + func testLiveFramesAreNeverProgress() { + for t: UInt8 in [40, 43] { + XCTAssertFalse(BLEManager.offloadFrameShowsProgress(frame(type: t, at: 4), family: .whoop4)) + XCTAssertFalse(BLEManager.offloadFrameShowsProgress(frame(type: t, at: 8), family: .whoop5)) + } + } + + func testWhoop5EventFrameIsUnchanged() { + XCTAssertTrue(BLEManager.offloadFrameShowsProgress(frame(type: 48, at: 8), family: .whoop5)) + } + + func testShortFrameIsNotProgress() { + XCTAssertFalse(BLEManager.offloadFrameShowsProgress([0xAA, 0x01, 0x00], family: .whoop4)) + } +} diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 8fd7e63730..0267a381eb 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -1290,6 +1290,25 @@ class WhoopBleClient( } } + /** + * True when an offload frame is evidence that the strap is still handing over history, so the + * backfill idle watchdog may re-arm on it. Every [isOffloadFrame] frame is still routed to the + * drain; this only decides which ones count as PROGRESS. + * + * A WHOOP 4.0 EVENT frame (type 48) does not. The strap emits a fresh event roughly every 15 s + * whether or not it is offloading, and when it answers SEND_HISTORICAL_DATA with PENDING but + * never starts the type-47 stream, those events alone re-armed the 60 s watchdog forever: the + * session neither completed nor timed out, and `backfilling` stayed true, so every later sync + * trigger was refused for the life of the connection. A real offload streams type-47 records and + * closes chunks with METADATA (49), both of which still re-arm. 5/MG is unchanged: its offload + * has not been observed in this state, and its event cadence is unmeasured. + * Twin of Swift `BLEManager.offloadFrameShowsProgress`. + */ + fun offloadFrameShowsProgress(frame: ByteArray, family: DeviceFamily): Boolean { + if (!isOffloadFrame(frame, family)) return false + return !(family == DeviceFamily.WHOOP4 && (frame[4].toInt() and 0xFF) == 48) + } + /** * The gate every offload kick passes through: a sync may start ONLY when the link is up * ([connected]), the command channel is usable ([bonded]), and no offload is already running @@ -8528,7 +8547,7 @@ class WhoopBleClient( // it; feeding it only delays each chunk's insert->trim-ack and stalls the strap). if (isOffloadFrame(frame, connectedFamily)) { offloadFramesThisSession++ - armBackfillTimeout() + if (offloadFrameShowsProgress(frame, connectedFamily)) armBackfillTimeout() routeBackfillFrame(frame) } } else { @@ -11093,8 +11112,8 @@ class WhoopBleClient( } /** - * Re-arm the idle watchdog. Called on every offload frame during backfill; if the strap goes - * silent the timer fires and we exit the session. Port of `armBackfillTimeout`. + * Re-arm the idle watchdog. Called on every progress frame ([offloadFrameShowsProgress]); if the + * strap goes silent the timer fires and we exit the session. Port of `armBackfillTimeout`. */ private fun armBackfillTimeout() { handler.removeCallbacks(backfillTimeoutRunnable) diff --git a/android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt b/android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt new file mode 100644 index 0000000000..3080b620ef --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt @@ -0,0 +1,52 @@ +package com.noop.ble + +import com.noop.protocol.DeviceFamily +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which offload frames may re-arm the backfill idle watchdog ([WhoopBleClient.offloadFrameShowsProgress]). + * + * A WHOOP 4.0 that answered SEND_HISTORICAL_DATA with PENDING and then sent only its periodic EVENT + * frames kept the session open indefinitely, because every event re-armed the 60 s watchdog. These pin + * that events no longer count as progress on a 4.0 while every genuine offload type still does, and + * that 5/MG keeps its existing behaviour. Twin of the Swift `OffloadProgressWatchdogTests`. + */ +class OffloadProgressWatchdogTest { + + private fun frame(type: Int, at: Int): ByteArray = ByteArray(16).also { it[at] = type.toByte() } + + @Test + fun whoop4EventFrameIsNotProgress() { + assertTrue("an EVENT is still an offload frame and is still routed to the drain", + WhoopBleClient.isOffloadFrame(frame(48, 4), DeviceFamily.WHOOP4)) + assertFalse(WhoopBleClient.offloadFrameShowsProgress(frame(48, 4), DeviceFamily.WHOOP4)) + } + + @Test + fun whoop4HistoryTypesAreProgress() { + for (t in intArrayOf(47, 49, 50, 56)) { + assertTrue("type $t must re-arm the watchdog", + WhoopBleClient.offloadFrameShowsProgress(frame(t, 4), DeviceFamily.WHOOP4)) + } + } + + @Test + fun liveFramesAreNeverProgress() { + for (t in intArrayOf(40, 43)) { + assertFalse(WhoopBleClient.offloadFrameShowsProgress(frame(t, 4), DeviceFamily.WHOOP4)) + assertFalse(WhoopBleClient.offloadFrameShowsProgress(frame(t, 8), DeviceFamily.WHOOP5)) + } + } + + @Test + fun whoop5EventFrameIsUnchanged() { + assertTrue(WhoopBleClient.offloadFrameShowsProgress(frame(48, 8), DeviceFamily.WHOOP5)) + } + + @Test + fun shortFrameIsNotProgress() { + assertFalse(WhoopBleClient.offloadFrameShowsProgress(byteArrayOf(0xAA.toByte(), 1, 0), DeviceFamily.WHOOP4)) + } +}