From e5b207fad607b22d39bbad67af2601bed31074eb Mon Sep 17 00:00:00 2001 From: ryanbr Date: Sun, 12 Jul 2026 15:33:01 +1200 Subject: [PATCH] oura: extract the history-drain/resume-cursor decisions into a pure, tested core (#291 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #91 drain guards + resume-cursor logic lived untested inline in OuraLiveSource (app-target BLE) — which is exactly why it silently regressed when the Oura BLE stack was refactored (#291: "lost when refactoring"). Move the DECISIONS into a pure OuraHistoryDrain in OuraProtocol so they're pinned by unit tests, not just on-device observation: - drain-to-zero + stall guard (bytes_left must shrink; stop after 3 flat summaries) + 300s deadline guard, keeping banked progress on a forced stop - stored-ring-time high-water mark + reboot flag (a real sample older than the seek) - resume-cursor commit (advance only if forward AND resolves; reboot -> 0 full pull) - loaded-cursor plausibility-ceiling sanitize OuraLiveSource keeps ALL I/O — anchor resolution, persistence, logging, the historyCursorAdvanced emit — and now delegates every decision to the drain core. Behaviour-preserving: each site maps 1:1 to the extracted logic (verified by diff). Also de-versions the [cursor-fix v1] log marker to [cursor-fix]. 14 new OuraHistoryDrainTests + the existing 91 pass under swift test (105/105, run on Linux — OuraProtocol is pure). OuraLiveSource compile: app-build. --- .../OuraProtocol/OuraHistoryDrain.swift | 86 ++++++++++++ .../OuraHistoryDrainTests.swift | 127 ++++++++++++++++++ Strand/BLE/OuraLiveSource.swift | 104 +++++--------- 3 files changed, 249 insertions(+), 68 deletions(-) create mode 100644 Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift create mode 100644 Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift new file mode 100644 index 0000000000..350d0a6908 --- /dev/null +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Pure, testable decision core for the Oura history drain + durable resume cursor (#91 / #291). +/// +/// Extracted from `OuraLiveSource` so the drain guards and cursor logic — which silently regressed once +/// already when the Oura BLE stack was refactored (#291: "lost when refactoring") — are pinned by unit +/// tests instead of only on-device observation. This owns ONLY the per-drain counters and the decisions; +/// the caller (`OuraLiveSource`) keeps all I/O — anchor resolution, persistence, logging, and the actual +/// `historyCursorAdvanced` emit. +/// +/// The `0x11` history summary carries no cursor — only `bytes_left` (a remaining-byte count) and a +/// `moreData` flag. A healthy drain runs until `bytes_left == 0`; the resume cursor commits from the +/// newest STORED sample's ring-time at drain end. Byte counts are never persisted. +public struct OuraHistoryDrain: Sendable, Equatable { + /// A ring-time above this is corrupt (~1.6 years of ticks) and must not set the resume cursor, or the + /// next session would seek into nonsense. Bounds the cursor at the source. + public static let maxPlausibleResumeTicks: UInt32 = 500_000_000 + /// `bytes_left` not shrinking for this many straight summaries = the ring is looping; stop. + public static let maxStallSummaries = 3 + /// A healthy full pull finishes in ~1-2 min; past this something upstream is wrong — stop, keep the + /// banked progress, and let the periodic re-fetch try again later. + public static let maxDrainSeconds: TimeInterval = 300 + + private var minBytesLeftSeen: UInt32 = .max + private var stallCount = 0 + /// Newest STORED ring-time seen this drain — the value the resume cursor commits to at drain end. + public private(set) var maxStoredRingTime: UInt32 = 0 + /// A real stored sample older than where we sought this fetch: the ring's clock reset (or it ignored + /// the seek), so the persisted cursor is stale → full pull next connect. + public private(set) var sawPreResumeData = false + + public init() {} + + /// Reset the per-drain state at the start of a fetch. Mirrors `OuraLiveSource`'s fetch-start reset. + public mutating func reset() { + minBytesLeftSeen = .max + stallCount = 0 + maxStoredRingTime = 0 + sawPreResumeData = false + } + + /// Fold in one history summary; returns whether the drain should CONTINUE. + /// + /// `moreData == false` (`bytes_left == 0`) always completes. Otherwise two backstops force-stop a + /// misbehaving ring while keeping banked progress: the STALL guard (`bytes_left` must shrink across + /// summaries — [maxStallSummaries] flat reads means it's looping) and the DEADLINE guard + /// (`elapsedSeconds` past [maxDrainSeconds]). `elapsedSeconds` is the caller's wall clock since the + /// drain started, or `0` when no drain-start is set (matching the old `let started = …` guard). + public mutating func onSummary(bytesLeft: UInt32, moreData: Bool, elapsedSeconds: TimeInterval) -> Bool { + guard moreData else { return false } + if bytesLeft < minBytesLeftSeen { + minBytesLeftSeen = bytesLeft + stallCount = 0 + } else { + stallCount += 1 + if stallCount >= Self.maxStallSummaries { return false } + } + if elapsedSeconds > Self.maxDrainSeconds { return false } + return true + } + + /// Record a STORED history sample's ring-time toward the resume cursor. Call ONLY where a sample + /// resolved a REAL anchored time (never a wall-clock fallback). Ignores corrupt (over-ceiling) times, + /// and flags a reboot when a real sample predates `resumeCursorAtFetchStart` (0 = full pull, no floor). + public mutating func noteStoredRingTime(_ rt: UInt32, resumeCursorAtFetchStart: UInt32) { + guard rt <= Self.maxPlausibleResumeTicks else { return } + if rt > maxStoredRingTime { maxStoredRingTime = rt } + if resumeCursorAtFetchStart > 0, rt < resumeCursorAtFetchStart { sawPreResumeData = true } + } + + /// The cursor to persist at drain end, given the current cursor and whether `maxStoredRingTime` + /// resolves under the CURRENT anchor. A reboot (`sawPreResumeData`) resets to 0 (honest full pull next + /// connect); otherwise the cursor advances to `maxStoredRingTime` only if it moved forward AND + /// resolves. Unchanged in every other case. + public func resumeCursorAtDrainEnd(currentCursor: UInt32, resolvesUnderAnchor: Bool) -> UInt32 { + if sawPreResumeData { return 0 } + if maxStoredRingTime > currentCursor, resolvesUnderAnchor { return maxStoredRingTime } + return currentCursor + } + + /// Sanitize a cursor loaded from persistence: a value above the plausibility ceiling is pre-fix + /// garbage and must reset to a full pull. + public static func sanitizeLoadedCursor(_ persisted: UInt32) -> UInt32 { + persisted <= maxPlausibleResumeTicks ? persisted : 0 + } +} diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift new file mode 100644 index 0000000000..04c75498b9 --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import OuraProtocol + +/// Pins the Oura history drain + resume-cursor decisions (#91 / #291) that used to live untested inside +/// OuraLiveSource and silently regressed in a BLE refactor. Pure decisions — no ring, no BLE. +final class OuraHistoryDrainTests: XCTestCase { + + // MARK: drain continuation + + func testDrainCompletesWhenBytesLeftZero() { + var d = OuraHistoryDrain() + // moreData == false is the healthy end, regardless of the counters. + XCTAssertFalse(d.onSummary(bytesLeft: 0, moreData: false, elapsedSeconds: 0)) + } + + func testDrainContinuesWhileBytesLeftShrinks() { + var d = OuraHistoryDrain() + XCTAssertTrue(d.onSummary(bytesLeft: 400_873, moreData: true, elapsedSeconds: 1)) + XCTAssertTrue(d.onSummary(bytesLeft: 200_000, moreData: true, elapsedSeconds: 2)) + XCTAssertTrue(d.onSummary(bytesLeft: 1, moreData: true, elapsedSeconds: 3)) + } + + func testStallGuardStopsAfterMaxFlatSummaries() { + var d = OuraHistoryDrain() + XCTAssertTrue(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 1)) // sets the floor + // Same bytes_left repeated — no progress. Stops on the maxStallSummaries-th flat read. + XCTAssertTrue(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 2)) // stall 1 + XCTAssertTrue(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 3)) // stall 2 + XCTAssertFalse(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 4)) // stall 3 -> stop + } + + func testStallCounterResetsOnFreshProgress() { + var d = OuraHistoryDrain() + XCTAssertTrue(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 1)) + XCTAssertTrue(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 2)) // stall 1 + XCTAssertTrue(d.onSummary(bytesLeft: 900, moreData: true, elapsedSeconds: 3)) // progress -> reset + XCTAssertTrue(d.onSummary(bytesLeft: 900, moreData: true, elapsedSeconds: 4)) // stall 1 again + XCTAssertTrue(d.onSummary(bytesLeft: 900, moreData: true, elapsedSeconds: 5)) // stall 2 (not stopped) + } + + func testDeadlineGuardStopsPastMaxDrainSeconds() { + var d = OuraHistoryDrain() + XCTAssertTrue(d.onSummary(bytesLeft: 500, moreData: true, elapsedSeconds: 299)) + XCTAssertFalse(d.onSummary(bytesLeft: 400, moreData: true, + elapsedSeconds: OuraHistoryDrain.maxDrainSeconds + 0.1)) + } + + // MARK: stored ring-time → cursor + + func testNoteStoredRingTimeTracksMaxAndIgnoresCorrupt() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(100, resumeCursorAtFetchStart: 0) + d.noteStoredRingTime(3_453_828, resumeCursorAtFetchStart: 0) + d.noteStoredRingTime(50, resumeCursorAtFetchStart: 0) // older, doesn't lower the max + XCTAssertEqual(d.maxStoredRingTime, 3_453_828) + d.noteStoredRingTime(OuraHistoryDrain.maxPlausibleResumeTicks + 1, resumeCursorAtFetchStart: 0) + XCTAssertEqual(d.maxStoredRingTime, 3_453_828, "over-ceiling ring-time must be ignored") + XCTAssertFalse(d.sawPreResumeData) + } + + func testPreResumeDataFlagsReboot() { + var d = OuraHistoryDrain() + // We sought from cursor 1000; a real sample at 500 is OLDER than the seek → ring reset / seek ignored. + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 1000) + XCTAssertTrue(d.sawPreResumeData) + } + + func testFullPullSeekNeverFlagsReboot() { + var d = OuraHistoryDrain() + // A full pull seeks from 0 (no floor); early samples must NOT be treated as pre-resume. + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 0) + XCTAssertFalse(d.sawPreResumeData) + } + + // MARK: cursor commit + + func testResumeCursorAdvancesWhenForwardAndResolves() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(3_453_828, resumeCursorAtFetchStart: 1000) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: true), 3_453_828) + } + + func testResumeCursorUnchangedWhenNotResolving() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(3_453_828, resumeCursorAtFetchStart: 1000) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: false), 1000) + } + + func testResumeCursorUnchangedWhenNotForward() { + var d = OuraHistoryDrain() + // Newest stored sample EQUALS the cursor (no new data past it) — not forward, and not a reboot + // (a sample strictly BELOW the seek would flag `sawPreResumeData`, tested separately). + d.noteStoredRingTime(1000, resumeCursorAtFetchStart: 1000) + XCTAssertFalse(d.sawPreResumeData) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: true), 1000) + } + + func testRebootResetsCursorToFullPull() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(3_453_828, resumeCursorAtFetchStart: 1000) // forward... + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 1000) // ...but also a pre-resume sample + XCTAssertTrue(d.sawPreResumeData) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: true), 0, + "a reboot forces 0 (full pull) even if a forward sample also arrived") + } + + // MARK: loaded-cursor sanitize + reset + + func testSanitizeLoadedCursor() { + XCTAssertEqual(OuraHistoryDrain.sanitizeLoadedCursor(3_453_828), 3_453_828) + XCTAssertEqual(OuraHistoryDrain.sanitizeLoadedCursor(0), 0) + XCTAssertEqual(OuraHistoryDrain.sanitizeLoadedCursor(OuraHistoryDrain.maxPlausibleResumeTicks + 1), 0, + "a persisted cursor above the ceiling is pre-fix garbage → full pull") + } + + func testResetClearsState() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(3_453_828, resumeCursorAtFetchStart: 1000) + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 1000) + _ = d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 1) + d.reset() + XCTAssertEqual(d.maxStoredRingTime, 0) + XCTAssertFalse(d.sawPreResumeData) + // Stall floor reset: a fresh flat sequence starts counting from scratch. + XCTAssertTrue(d.onSummary(bytesLeft: 1000, moreData: true, elapsedSeconds: 1)) + } +} diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index 8f5fcab0be..d1eae75574 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -260,27 +260,17 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// previously persisted that byte-count as a "cursor" and compared it across sessions as a clock, /// minting a phantom "ring-time regression" → reset-to-0 → full re-dump on every connect. private var historyCursor: UInt32 = 0 - /// Absolute ceiling for a plausible resume ring-time: ~1.6 years of ticks. Observed real envelope - /// ring-times are ~1.7M (days of uptime); anything above this is a corrupt value that must never be - /// seeked to or persisted. Bounds the cursor at the source. - private static let maxPlausibleResumeTicks: UInt32 = 500_000_000 - /// The cursor we resumed FROM at the start of the current fetch, kept to detect a genuine ring - /// reboot: a real stored sample OLDER than where we sought means the ring's clock reset (or it - /// ignored the seek), so the persisted cursor is stale → full pull next connect. + /// The pure, unit-tested drain + resume-cursor decision core (#291): the stall/deadline guards, the + /// stored-ring-time high-water mark, the reboot flag, the cursor-commit and loaded-cursor-sanitize + /// decisions, and the plausibility ceiling. OuraLiveSource keeps only the I/O — anchor resolution, + /// persistence, logging, and the `historyCursorAdvanced` emit — and delegates every decision here so + /// they can't silently regress in a refactor again (they did once: #91 → #291). See OuraHistoryDrainTests. + private var drain = OuraHistoryDrain() + /// The cursor we resumed FROM at the start of the current fetch — passed into `drain.noteStoredRingTime` + /// so a real stored sample OLDER than it flags a genuine ring reboot (clock reset / seek ignored). private var resumeCursorAtFetchStart: UInt32 = 0 - private var sawPreResumeData = false - /// Newest STORED history sample's ring-time this drain — the value the resume cursor commits to at - /// completion. Advanced only by samples that resolved a REAL anchored time (never wall-clock guesses). - private var maxStoredRingTime: UInt32 = 0 - // Drain safety guards (backstops only — a healthy drain ends at bytes_left == 0): - /// Wall-clock start of the current drain; a drain running past `maxDrainSeconds` is force-stopped. + /// Wall-clock start of the current drain; `drain`'s deadline guard force-stops one running too long. private var drainStartedAt: Date? - /// Smallest bytes_left seen this drain + how many consecutive summaries made no progress against it. - /// `bytes_left` not shrinking for `maxStallSummaries` straight summaries = the ring is looping, stop. - private var minBytesLeftSeen: UInt32 = .max - private var drainStallCount = 0 - private let maxDrainSeconds: TimeInterval = 300 - private let maxStallSummaries = 3 /// Periodic re-fetch while connected, so an overnight-connected session (or one left open after a nap) /// picks up freshly-banked sleep data without needing a reconnect. Mirrors BLEManager's ~15 min /// periodic WHOOP history-offload floor. @@ -295,12 +285,9 @@ public final class OuraLiveSource: NSObject, ObservableObject { // Arm the per-drain state: where we sought from (reboot detection), the stored-sample high-water // mark the cursor will commit from, and the stall/deadline guards. resumeCursorAtFetchStart = historyCursor - sawPreResumeData = false - maxStoredRingTime = 0 drainStartedAt = Date() - minBytesLeftSeen = .max - drainStallCount = 0 - log("Oura: fetching history from cursor \(historyCursor) (\(describeCursor(historyCursor))) [cursor-fix v1]") + drain.reset() + log("Oura: fetching history from cursor \(historyCursor) (\(describeCursor(historyCursor))) [cursor-fix]") advance(.startHistoryFetch(cursor: historyCursor)) } @@ -328,26 +315,15 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// now-stale ring-time. Stall/deadline guards are backstops only; they force-stop the drain but keep /// whatever forward progress was banked (never reset to 0 — that re-arms the loop). private func handleHistorySummary(_ summary: (eventsReceived: UInt8, bytesLeft: UInt32, moreData: Bool)) { - var continueDrain = summary.moreData - if continueDrain { - // Stall guard: bytes_left must shrink across summaries. A ring that keeps answering with no - // progress is looping; stop after maxStallSummaries flat reads instead of draining forever. - if summary.bytesLeft < minBytesLeftSeen { - minBytesLeftSeen = summary.bytesLeft - drainStallCount = 0 - } else { - drainStallCount += 1 - if drainStallCount >= maxStallSummaries { - log("Oura: history drain force-stopped - bytes_left stalled at \(summary.bytesLeft) for \(drainStallCount) summaries (guard)") - continueDrain = false - } - } - // Deadline guard: a healthy full pull completes in ~1-2 min; past maxDrainSeconds something - // is wrong upstream - stop, keep progress, let the periodic re-fetch try again later. - if continueDrain, let started = drainStartedAt, Date().timeIntervalSince(started) > maxDrainSeconds { - log("Oura: history drain force-stopped - exceeded \(Int(maxDrainSeconds))s deadline with bytes_left \(summary.bytesLeft) (guard)") - continueDrain = false - } + // Stall + deadline backstops (a healthy drain ends at bytes_left 0, where moreData is false). + let elapsed = drainStartedAt.map { Date().timeIntervalSince($0) } ?? 0 + let continueDrain = drain.onSummary(bytesLeft: summary.bytesLeft, moreData: summary.moreData, + elapsedSeconds: elapsed) + if summary.moreData, !continueDrain { + let reason = elapsed > OuraHistoryDrain.maxDrainSeconds + ? "exceeded \(Int(OuraHistoryDrain.maxDrainSeconds))s deadline" + : "bytes_left stalled" + log("Oura: history drain force-stopped - \(reason) at bytes_left \(summary.bytesLeft) (guard)") } if !continueDrain { commitResumeCursor(drainCompleted: !summary.moreData) @@ -362,19 +338,19 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// a reboot (`sawPreResumeData`) resets to 0 so next connect does an honest full pull. private func commitResumeCursor(drainCompleted: Bool) { let how = drainCompleted ? "caught up (bytes_left 0)" : "stopped early" - if sawPreResumeData { + let resolves = drain.maxStoredRingTime > 0 + && (driver?.unixSeconds(forRingTimestamp: drain.maxStoredRingTime) != nil) + let newCursor = drain.resumeCursorAtDrainEnd(currentCursor: historyCursor, resolvesUnderAnchor: resolves) + if drain.sawPreResumeData { log("Oura: history \(how) but the ring served data older than cursor \(resumeCursorAtFetchStart) - clock reset/seek ignored; next connect does a full pull") historyCursor = 0 OuraHistoryCursorStore.save(0, deviceId: deviceId) - return - } - let resolves = maxStoredRingTime > 0 && (driver?.unixSeconds(forRingTimestamp: maxStoredRingTime) != nil) - if maxStoredRingTime > historyCursor, resolves { - historyCursor = maxStoredRingTime - OuraHistoryCursorStore.save(maxStoredRingTime, deviceId: deviceId) + } else if newCursor != historyCursor { + historyCursor = newCursor + OuraHistoryCursorStore.save(newCursor, deviceId: deviceId) log("Oura: history \(how) - resume cursor advanced to \(historyCursor) [\(describeCursor(historyCursor))]") - } else if maxStoredRingTime > historyCursor { - log("Oura: history \(how) but resume candidate \(maxStoredRingTime) does not resolve under the current anchor - keeping cursor \(historyCursor)") + } else if drain.maxStoredRingTime > historyCursor { + log("Oura: history \(how) but resume candidate \(drain.maxStoredRingTime) does not resolve under the current anchor - keeping cursor \(historyCursor)") } else { log("Oura: history \(how) (resume cursor unchanged \(historyCursor) [\(describeCursor(historyCursor))])") } @@ -386,9 +362,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { private func noteStoredHistoryRingTime(_ rt: UInt32) { // A ring-time above the plausibility ceiling is corrupt; letting it set the resume cursor would // seek the next session into nonsense. Bounds the cursor at the source. - guard rt <= Self.maxPlausibleResumeTicks else { return } - if rt > maxStoredRingTime { maxStoredRingTime = rt } - if resumeCursorAtFetchStart > 0, rt < resumeCursorAtFetchStart { sawPreResumeData = true } + drain.noteStoredRingTime(rt, resumeCursorAtFetchStart: resumeCursorAtFetchStart) } /// Log the per-day MET-derived activity estimate + the empirical cadence cross-check at drain-end. @@ -555,12 +529,9 @@ public final class OuraLiveSource: NSObject, ObservableObject { activityCadenceObs.removeAll() lastActivityUtc = nil lastActivitySampleCount = 0 - maxStoredRingTime = 0 + drain.reset() resumeCursorAtFetchStart = 0 - sawPreResumeData = false drainStartedAt = nil - minBytesLeftSeen = .max - drainStallCount = 0 reachedStreaming = false pendingInstallKey = nil adoptPhase = .idle @@ -1027,20 +998,17 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { adoptPhase = .idle reassembler.reset() // Per-drain cursor state starts clean each session (fetchHistoryIfIdle re-arms it per drain). - maxStoredRingTime = 0 + drain.reset() resumeCursorAtFetchStart = 0 - sawPreResumeData = false drainStartedAt = nil - minBytesLeftSeen = .max - drainStallCount = 0 // Resume the GetEvents cursor from where the LAST connection to this ring left off (s5.1/5.3), so // a routine reconnect doesn't re-fetch the ring's entire banked history every time. A persisted // value above the plausibility ceiling is garbage banked by a pre-fix build (a bytes_left count or // a misframe-era ring-time) - seeking to it would starve the fetch; reset to a full pull instead. - historyCursor = OuraHistoryCursorStore.read(deviceId: deviceId) - if historyCursor > Self.maxPlausibleResumeTicks { - log("Oura: persisted resume cursor \(historyCursor) exceeds the plausibility ceiling (pre-fix garbage) - full pull") - historyCursor = 0 + let loadedCursor = OuraHistoryCursorStore.read(deviceId: deviceId) + historyCursor = OuraHistoryDrain.sanitizeLoadedCursor(loadedCursor) + if historyCursor != loadedCursor { + log("Oura: persisted resume cursor \(loadedCursor) exceeds the plausibility ceiling (pre-fix garbage) - full pull") OuraHistoryCursorStore.save(0, deviceId: deviceId) } peripheral.discoverServices([Self.service])