Repository navigation
Description
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 inanalyzeRecent/
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):
outis NEWEST-FIRST, because the scoring loop counts backwards from today (for offset in 0..<maxDayswithdayStart = nowLocalMidnight - offset * 86_400), soout[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
UserDefaultsbool.markRescoreOwed()(:50-52) is called both (a) unconditionally insideanalyzeRecentonce it's
past its own guards and about to do real work (IntelligenceEngine.swift:595), and (b) whenever
RescoreBackgroundPolicy.decidereturns.deferToBackgroundTask(RescoreBackgroundScheduler. swift:111-116).markRescoreCompleted(seconds:)(:56-61) is called exactly once, at the very end of
IntelligenceEngine.swift'sanalyzeRecent, 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:
- Pass A marks owed and starts (captures
passStartedAt). - While A is "in flight" (before A calls
markRescoreCompleted), a separatemarkRescoreOwed()
call happens (simulating the 08:21:44 deferral). - A finishes and calls
markRescoreCompleted(seconds:, passStartedAt:). - Assert
isRescoreOwedis stilltrue— the newer debt must survive A's completion. - 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
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
- 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
rescore: record why the owed flag was the one that broke
Notes 10.6.0.5: build is 255; record the ryanbr#1681 structural merge
pipiche38 commentedon Aug 29, 2026
Thanks for the fix
Notes 10.6.0.5: build is 255; record the ryanbr#1681 structural merge
Add syncJob and syncJournalEntry store tables (v44-sync-jobs).
fix(rescore): let a resumed pass stand down when nothing changed (#2251)
Notes 10.6.0.5: build is 255; record the ryanbr#1681 structural merge
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart
rescore: space backgrounded offload re-scores at least 30 minutes apart