Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,15 @@ struct BackfillContinuation {
guard stillConnected else { return false } // 1
guard consecutiveCount < maxAutoContinues else { return false } // 4 (cap)
guard lastTrimAdvanced else { return false } // 3 (don't spin on a frozen cursor)
// 3b (#1144): an EMPTY session (0 rows persisted) never auto-continues, whatever the reported
// frontier gap. When the strap advertises a `newest` AHEAD of our frontier but the offload for that
// range hands back 0 rows (a PHANTOM gap — a timestamp it won't actually offload), 2a below would
// latch `true` forever: the frontier can't advance without rows, so `newest - frontier` stays > gap
// and it re-fires to the full cap in empty offloads (the storm observed on a real 4.0). Guard 3
// doesn't catch it — the trim u32 climbs on empty ENDs. Stop and let the periodic floor retry;
// healthy backlog sessions persist real rows so this only bites the empty spin. (Makes 2b's row
// check the ONE authority for both cases.)
guard rowsPersistedThisSession > 0 else { return false }
// #928: a strap clock set in the FUTURE makes "newest" read ahead of ANY real frontier, so 2a
// would report backlog forever and drive up to the full cap in EMPTY offloads on every connect.
// A newest more than futureSkewSeconds past the wall clock is implausible: exclude it from 2a.
Expand Down
22 changes: 22 additions & 0 deletions StrandTests/BackfillContinuationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,26 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: 1_800_000_000, // strap newest
ourFrontierTs: 1_800_000_000 - 86_400, // our frontier a full day behind
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: 0))
}

/// #1144 phantom-gap spin: the strap reports a `newest` well AHEAD of our frontier (so guard 2a's gap
/// holds) and the trim advanced — but the offload persisted ZERO rows. Continuing would re-fire 2a to
/// the full cap in empty offloads, since the frontier can't advance without rows. An empty session must
/// stop regardless of the reported gap.
func testStopsWhenGapHoldsButSessionWasEmpty() {
XCTAssertFalse(BackfillContinuation.shouldAutoContinue(
stillConnected: true,
strapNewestTs: 1_800_000_000,
ourFrontierTs: 1_800_000_000 - 400, // 400s behind → 2a's 300s gap holds
wallNowUnix: wallNow,
rowsPersistedThisSession: 0, // …but nothing was actually offloaded
lastTrimAdvanced: true, // trim u32 climbs on empty ENDs — not enough
consecutiveCount: 0))
}

/// A dropped link must NOT auto-continue — the normal reconnect path owns it.
func testStopsWhenDisconnected() {
XCTAssertFalse(BackfillContinuation.shouldAutoContinue(
Expand Down Expand Up @@ -70,6 +86,7 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: 1_800_000_000,
ourFrontierTs: 1_800_000_000 - 301,
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: 0,
behindGapSeconds: 300))
Expand Down Expand Up @@ -97,6 +114,7 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: 1_800_000_000,
ourFrontierTs: 1_800_000_000 - 86_400,
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: cap - 1))
// At the cap, stop.
Expand Down Expand Up @@ -223,6 +241,7 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: strapNewest,
ourFrontierTs: frontier,
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: count) {
// Each pass drains ~a day of the oldest backlog and counts as one auto-continue.
Expand All @@ -246,6 +265,7 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: 1_800_000_000,
ourFrontierTs: 1_800_000_000 - 86_400, // a full day behind
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: 10)) // well past the old cap of 6
}
Expand Down Expand Up @@ -380,6 +400,7 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: wallNow + 48 * 3600,
ourFrontierTs: wallNow - 86_400,
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: 0))
// One second past the cap: excluded, and an empty session must not continue.
Expand All @@ -401,6 +422,7 @@ final class BackfillContinuationTests: XCTestCase {
strapNewestTs: wallNow + 3600, // an hour ahead: plausible skew, not a broken clock
ourFrontierTs: wallNow - 86_400, // a real day of backlog
wallNowUnix: wallNow,
rowsPersistedThisSession: 200,
lastTrimAdvanced: true,
consecutiveCount: 0))
}
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,15 @@ class WhoopBleClient(
if (!stillConnected) return false // 1
if (consecutiveCount >= maxAutoContinues) return false // 4 (cap)
if (!lastTrimAdvanced) return false // 3 (don't spin on a frozen cursor)
// 3b (#1144): an EMPTY session (0 rows persisted) never auto-continues, whatever the reported
// frontier gap. When the strap advertises a `newest` AHEAD of our frontier but the offload for
// that range hands back chunkRows=0 (a PHANTOM gap — a timestamp it won't actually offload), 2a
// below would latch `true` forever: the frontier can't advance without rows, so `newest -
// frontier` stays > gap and it re-fires to the full cap in empty offloads (the storm observed on
// a real 4.0). `lastTrimAdvanced` (3) doesn't catch it — the trim u32 climbs on empty ENDs. Stop
// and let the 15-min floor retry; healthy backlog sessions persist real rows so this only bites
// the empty spin. (Makes 2b's row check the ONE authority for both cases.)
if (rowsPersistedThisSession <= 0) return false
// #928: a strap clock set in the FUTURE makes "newest" read ahead of ANY real frontier, so 2a
// would report backlog forever and drive up to the full cap in EMPTY offloads on every
// connect. A newest more than [futureSkewSeconds] past [wallNowUnix] (the REAL wall clock,
Expand Down
26 changes: 26 additions & 0 deletions android/app/src/test/java/com/noop/ble/BackfillContinuationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ class BackfillContinuationTest {
wallNowUnix = wallNow,
lastTrimAdvanced = true,
consecutiveCount = 0,
rowsPersistedThisSession = 200,
),
)
}

/** #1144 phantom-gap spin: the strap reports a `newest` well AHEAD of our frontier (so guard 2a's gap
* holds) and the trim advanced — but the offload persisted ZERO rows. Continuing would re-fire 2a to
* the full cap in empty offloads, because the frontier can't advance without rows. An empty session
* must stop regardless of the reported gap. */
@Test
fun stops_whenGapHoldsButSessionWasEmpty() {
assertFalse(
WhoopBleClient.shouldAutoContinue(
stillConnected = true,
strapNewestTs = 1_800_000_000L,
ourFrontierTs = 1_800_000_000L - 400L, // 400s behind → 2a's 300s gap holds
wallNowUnix = wallNow,
lastTrimAdvanced = true, // trim u32 climbs on empty ENDs — not enough
consecutiveCount = 0,
rowsPersistedThisSession = 0, // …but nothing was actually offloaded
),
)
}
Expand Down Expand Up @@ -94,6 +114,7 @@ class BackfillContinuationTest {
lastTrimAdvanced = true,
consecutiveCount = 0,
behindGapSeconds = 300L,
rowsPersistedThisSession = 200,
),
)
}
Expand Down Expand Up @@ -125,6 +146,7 @@ class BackfillContinuationTest {
wallNowUnix = wallNow,
lastTrimAdvanced = true,
consecutiveCount = cap - 1,
rowsPersistedThisSession = 200,
),
)
assertFalse(
Expand Down Expand Up @@ -153,6 +175,7 @@ class BackfillContinuationTest {
wallNowUnix = wallNow,
lastTrimAdvanced = true,
consecutiveCount = 10, // well past the old cap of 6
rowsPersistedThisSession = 200,
),
)
}
Expand Down Expand Up @@ -288,6 +311,7 @@ class BackfillContinuationTest {
wallNowUnix = wallNow,
lastTrimAdvanced = true,
consecutiveCount = count,
rowsPersistedThisSession = 200, // each pass lands real rows (that's why the frontier advances)
)
) {
frontier += 86_400L
Expand Down Expand Up @@ -455,6 +479,7 @@ class BackfillContinuationTest {
wallNowUnix = wallNow,
lastTrimAdvanced = true,
consecutiveCount = 0,
rowsPersistedThisSession = 200,
),
)
// One second past the cap: excluded, and an empty session must not continue.
Expand Down Expand Up @@ -483,6 +508,7 @@ class BackfillContinuationTest {
wallNowUnix = wallNow,
lastTrimAdvanced = true,
consecutiveCount = 0,
rowsPersistedThisSession = 200,
),
)
}
Expand Down
Loading