Skip to content

[iOS] analyzeRecent's #1538 background-rescore "owed" flag can be cleared by an unrelated pass — a night's sleep score can silently freeze on stale data #1681

Closed

Description

@pipiche38

NOOP version

10.1.0

Platform

iOS

OS version

26.6

WHOOP strap model

Not sure

Where the data is coming from

Live Bluetooth (strap connected)

Test Centre profile

Not a test-mode bug

What happens

Summary

RescoreBackgroundScheduler tracks whether a re-score is "owed" (needs to run, e.g. because an
earlier attempt was killed by iOS backgrounding it, or a trigger was deferred to a BGProcessingTask)
with a single global UserDefaults boolean. Any analyzeRecent() pass that reaches its own natural
completion clears that flag unconditionally — even when a different, later trigger recorded a
fresh "owed" debt for data that arrived after the completing pass had already read its inputs. The
debt is silently erased before the correction it was recorded for ever runs, and because the day-
scoring loop processes "today" first, the day most likely to still be receiving data (last night, if
a sync is still finishing when the pass starts) is exactly the day most likely to be caught mid-write
and then never revisited.

Observed effect: one night's fused sleep session (totalSleepMin/efficiency/restingHr/
avgHrv/respRateBpm/recovery/strain) was scored from under half the actual night (199 min
credited vs ~427-486 min per two independent ground-truth apps and the same device's own raw
hypnogram), and nothing re-scored it before I happened to pull a diagnostics export a few minutes
later. Full capture write-up (private, not attached here) traced this back to source.

Why it matters

  • Silent and wrong-looking-plausible, not a crash or an obvious "–". The truncated night scored a
    calm-looking 87.5% Recovery — a user has no reason to suspect anything is off.
  • Not a one-metric bug. Every daily metric keyed to the sleep-session window inherits the
    truncation: sleep stages, in-window resting HR/HRV/resp, Recovery, Strain, sleep_performance.
  • General, not device-specific. The trigger here was an Oura ring's hypnogram still being synced
    in pieces across several reconnects, but the underlying bug is in analyzeRecent/
    RescoreBackgroundScheduler, which every strap's post-offload path shares. Any device whose sync
    can complete in stages while the app backgrounds between them is exposed.
  • The very mechanism meant to guarantee a retry is what fails. Re-score needs 10s of work but takes 25-70min backgrounded, so the score lands ~an hour after the data (#1005) #1538 exists specifically so a
    killed/deferred pass is never silently dropped — this is that guarantee's own bookkeeping failing.

Root cause (confirmed by reading the code + reconstructing exact log timestamps, not inferred)

1. The day most likely to still be arriving is scored first

IntelligenceEngine.swift:2287-2289 (comment, verbatim):

out is NEWEST-FIRST, because the scoring loop counts backwards from today (for offset in 0..<maxDays with dayStart = nowLocalMidnight - offset * 86_400), so out[0] is today.

Each day's read of its data-provider input — for a device with no usable gravity (a ring), that's
store.sleepSessions(deviceId: owner, ...) at :1100, feeding analyzeDay's providedSleep
parameter (#804 Fix A, :1089-1105) — happens inline in that same per-day iteration
(:1106-1108: dayPrepSeconds/dayScoreSeconds accumulate PER DAY, confirmed not a separate
prep-then-score phase across the whole window). So "today" — the night most likely to still have a
sync in progress — is the FIRST thing any pass reads, within moments of starting.

2. A global "owed" flag gets cleared by whichever pass finishes, not by the pass that actually paid the debt

  • RescoreBackgroundScheduler.owedKey (RescoreBackgroundScheduler.swift:35) is a single
    UserDefaults bool.
  • markRescoreOwed() (:50-52) is called both (a) unconditionally inside analyzeRecent once it's
    past its own guards and about to do real work (IntelligenceEngine.swift:595), and (b) whenever
    RescoreBackgroundPolicy.decide returns .deferToBackgroundTask (RescoreBackgroundScheduler. swift:111-116).
  • markRescoreCompleted(seconds:) (:56-61) is called exactly once, at the very end of
    IntelligenceEngine.swift's analyzeRecent, at :2314 — unconditionally, with no check for
    whether some other trigger recorded a newer debt while this pass was still running.

Reconstructed timeline for the capture that exposed this (all times local, from the strap log):

time event
~05:27 A forced re-score starts (markRescoreOwed() fires); the process is suspended by iOS ~2h51m almost immediately after
08:18:54-57 BLE reconnect wakes the process; the suspended pass resumes execution
08:20:45 That pass finishes, reading day="today"'s sleep session as it stood at that moment (an intermediate, partial hypnogram) — markRescoreCompleted() clears owed
08:20:46 A new pass triggers (newData=yes, fresh HR had streamed in). Per point 1, it reads day="today" in its first iteration — still using the same intermediate hypnogram, because the sync that will extend it hasn't landed yet
08:21:43-44 The ring's sync delivers the actual complete sleep window
08:21:44 The completed-offload trigger for this batch calls into RescoreBackgroundScheduler.run → RescoreBackgroundPolicy.decide(isBackground: true, rescoreAlreadyOwed: true, ...) → .deferToBackgroundTask (confirmed isBackground==true at this instant: the log line this decision produces is unreachable in foreground, per RescoreBackgroundPolicy.swift:68's guard isBackground else { return .run }). This correctly records a NEW debt (markRescoreOwed()) and schedules a BGProcessingTaskRequest
08:23:59 A second, smaller sync completes; same deferral, same reasoning
~08:25:23 The pass that started at 08:20:46 finishes — and calls markRescoreCompleted(), clearing the SAME global flag the 08:21:44/08:23:59 deferrals had just set for data that pass never read

Even if iOS had granted the already-schedule()d BGProcessingTask a wake before I pulled the
export a few minutes later, its handler's first action
(runDeferredRescoreIfOwed-equivalent, AppModel.swift:619-620: guard RescoreBackgroundScheduler. isRescoreOwed else { return }) would now see false and do nothing. The debt is gone, not deferred.

Confirmed this has zero test coverage today: StrandTests/RescoreBackgroundSchedulerTests.swift
exercises markRescoreOwed / run / markRescoreCompleted strictly sequentially in every test —
none simulates a deferral being recorded while an earlier pass triggered by a different caller is
still executing
. That interleaving — which is exactly what a multi-stage sync produces — isn't
covered.

Ruled out

  • Not a [oura] Three residuals after #1249 / #1251, measured on a second night #1284 (sleep-session dedup) regression. The ring's own persisted row ended up correct in
    the DB (single row, fully extended, onset-keying intact) — the bug is entirely in what the scoring
    pass had already read and written elsewhere by the time that row finished growing.
  • Not a database race in the SQL sense — no corrupted read, no lock contention. The data read was
    valid at the instant it was read; the problem is that nothing re-read it after it changed, because
    the mechanism meant to guarantee that re-read had its own signal erased.

Possibly self-heals on its own, unconfirmed

The watermark analyzeRecent persists at completion (:2308) is the HR-stream fingerprint captured
at that pass's start (:542-543), not re-read at completion. Since HR kept accruing after that
snapshot, a later normal trigger (next app foreground, or the routine 15-minute idle tick) comparing
against this now-stale watermark should still see "new data" and attempt a correct pass on its own —
just without the fast, guaranteed-retry path the owed-debt mechanism exists to provide. I haven't been
able to confirm whether this actually happened on the affected install before or after my export.

Proposed fix

Scope the debt to when it was recorded relative to the pass that might clear it, instead of a bare
bool:

// RescoreBackgroundScheduler
private static let owedSinceKey = "noop.rescoreOwedSince"   // epoch seconds of the OLDEST unpaid debt

static func markRescoreOwed() {
    UserDefaults.standard.set(true, forKey: owedKey)
    if UserDefaults.standard.object(forKey: owedSinceKey) == nil {
        UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: owedSinceKey)
    }
}

// Only clear the debt if nothing was marked owed AFTER this pass started reading its inputs.
static func markRescoreCompleted(seconds: Double, passStartedAt: Date) {
    let owedSince = UserDefaults.standard.object(forKey: owedSinceKey) as? Double
    if owedSince == nil || owedSince! <= passStartedAt.timeIntervalSince1970 {
        UserDefaults.standard.set(false, forKey: owedKey)
        UserDefaults.standard.removeObject(forKey: owedSinceKey)
    }
    // else: a newer debt was recorded while this pass was in flight — leave it owed so the next
    // background wake / foreground entry retries.
    if seconds.isFinite, seconds > 0 {
        UserDefaults.standard.set(seconds, forKey: lastPassSecondsKey)
    }
}

analyzeRecent already has reScoreStart in scope at its call to markRescoreCompleted
(IntelligenceEngine.swift:2313-2314), so threading it through is a small, local change. Happy to
open a PR for this if a maintainer confirms the direction — didn't want to guess at API shape you'd
prefer without a nod first, since this touches shared background-execution plumbing.

Tests needed before landing

A new case in RescoreBackgroundSchedulerTests.swift for the exact overlapping scenario:

  1. Pass A marks owed and starts (captures passStartedAt).
  2. While A is "in flight" (before A calls markRescoreCompleted), a separate markRescoreOwed()
    call happens (simulating the 08:21:44 deferral).
  3. A finishes and calls markRescoreCompleted(seconds:, passStartedAt:).
  4. Assert isRescoreOwed is still true — the newer debt must survive A's completion.
  5. A companion test that the normal case (no overlapping debt) still clears the flag, so the fix
    doesn't regress the Re-score needs 10s of work but takes 25-70min backgrounded, so the score lands ~an hour after the data (#1005) #1538 happy path.

Verification

⚠️ This touches app-target Swift (Strand/Data/IntelligenceEngine.swift, Strand/System/ RescoreBackgroundScheduler.swift) — swift-packages.yml doesn't cover it and app-build.yml is
disabled by default, so a compile error here passes every green check. Build locally: xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build, plus the NOOPiOS scheme.

The underlying scenario (a multi-stage sync completing while the app backgrounds between stages) is
awkward to reproduce on demand — it needs either a real overnight capture that happens to land the
same way again, or driving RescoreBackgroundScheduler/IntelligenceEngine directly from a test with
injected timing, which is what the proposed test above does. I don't think this needs hardware
validation beyond that — the bug is in in-process bookkeeping, not the BLE path itself.

Android

Not affected. android/…/analytics/IntelligenceEngine.kt's analyzeGate (a Mutex, see the doc
comment at the top of the object) deliberately serialises every analyzeRecent trigger instead of
coalescing/deferring — every caller's request eventually runs its own full pass, queued, nothing is
ever silently dropped. There's no "owed" flag to clear incorrectly because nothing is ever marked
owed-and-then-erased in the first place. (Android doesn't have iOS's ~20-30s background-execution
cliff that motivated #1538's defer-to-BGTask escape hatch on this platform.)

What you expected instead

see above

Steps to reproduce

  1. see above

Strap log

see above

Before you submit

  • I attached my exported NOOP .zip (not a screenshot of the Live screen). If my bug is a clearly reproducible UI issue I described exactly how to see it instead.
  • I attached or pasted a strap log above — or my bug is a clearly reproducible UI issue that doesn't need one (e.g. a typo, a button that does nothing) and I've described exactly how to see it.
  • I checked the Troubleshooting guide — especially "history syncs but no data" (most often the strap isn't banking history to flash; charge it to 100% and reconnect).
  • I checked the FAQ and — if I'm on a WHOOP 5.0 / MG — the 5.0/MG status page, and this isn't an already-known limit (e.g. SpO₂, or recovery/sleep on the 5/MG).
  • I've left out anything personal (strap serial, account email, raw export).

Activity

  1. added a commit that references this issue on Aug 27, 2026
  2. pipiche38 commented on Aug 29, 2026

    @pipiche38
    Author

    Thanks for the fix

  3. added a commit that references this issue on Sep 12, 2026
  4. added a commit that references this issue on Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions