Repository navigation
Description
NOOP version
11.8.0
Platform
iOS
OS version
27.0
WHOOP strap / Oura ring model
Oura Ring 3 (Gen3)
Where the data is coming from
Live Bluetooth (Oura ring connected)
Test Centre profile
Not a test-mode bug
What happens
Refs #2585 · Refs #1538 · Refs #1681
Summary
The one-shot history repair added with #2585 (runHistoryRepairIfNeeded, trigger=sleep-wear-history-repair, maxDays: 4000) sets its two "done" flags only when a full-history pass completes with no forced re-score queued behind it. On a device whose strap syncs every few minutes (here an Oura ring, drained every 300 s), a pass that takes the better part of an hour practically always has one queued. When it does, the flags stay unset and the pass is re-launched at the same 4000-day width, outside the guard that keeps the repair from starting in the background. On the first night after flashing, the repair never completed: one attempt died with the process at launch, and the second was still running about 52 minutes later, backgrounded, when iOS terminated the process.
Expected: a one-shot repair completes once, even with a strap syncing, and never runs as an unbounded full-history pass in the background.
Observed (iOS 27.0, NOOP 11.8.0 build 428, Oura ring active; strap log)
[21:43:01] re-score: trigger=sleep-wear-history-repair newData=yes (process 1)
[21:43:03] re-score: queued behind a 4000-day pass running for 1 s
(process 1 ends here; a second launch starts at 21:43:12)
~21:51:26 re-score: trigger=sleep-wear-history-repair newData=yes (process 2, foreground)
[21:52:09] Oura: offload complete - marking synced so the new history is scored now
re-score: queued behind a 4000-day pass running for 45 s ← pendingForcedRescore is now set
[22:09:44] (screen off; app backgrounded)
… no `re-score: done`, no `re-score: cost`, no link epitaph …
[22:43:11] last line of process 2
[22:44:04] Central state: 5 — Connecting to last strap … (app not on screen) ← CoreBluetooth relaunch
- The pass ran at least 52 minutes, the last ~39 of them backgrounded (screen off ~22:04), and the process was terminated inside it. The CoreBluetooth relaunch shows it was the system, not the user. Whether the termination was a CPU-resource kill or memory pressure is not yet known: the
.ipshas not been pulled, and the MetricKit day report for that date arrives with the next export. The previous MetricKit day on the same device readsexits: CPU limit 1, memory pressure 1, peak memory 2.4 GB. - The relaunched process (background) did not restart the repair, because of the
!RescoreBackgroundScheduler.isBackgroundedguard. The flags are still unset, so the repair starts again from day one at the next foreground launch. - While it ran, every post-offload re-score queued behind it (
queued behind a 4000-day pass), so no newly synced night was scored for the duration.
Cause (Strand/Data/IntelligenceEngine.swift, upstream/main)
runHistoryRepairIfNeeded(line 687) passes anonPersistedclosure that setseffortRescoreFlagKeyandsleepWearRescoreFlagKey.analyzeRecentcalls it only at the very end, and only when nothing queued behind the pass:if !Task.isCancelled && !pendingForcedRescore { onPersisted?() }(line 3047).- A forced re-score arriving while the pass holds
computingsetspendingForcedRescore(line 768 and following). Every completed offload triggers one, and with a ring that is every 300 s. - The
deferthen re-launchesanalyzeRecent(maxDays: maxDays, …, onPersisted: onPersisted)in a freshTask: the same 4000-day width, the same completion rule. This re-pass does not go throughrunHistoryRepairIfNeeded, so its!isBackgroundedguard does not apply. A re-pass can start and run in the background. - The repair is launched directly from
AppModel(line 500,runEffortRescoreIfNeeded), not throughRescoreBackgroundScheduler.run. So it holds no background execution assertion, and it cannot leave thebackground time expired mid-passline the 21-day passes leave. It is paced bypaceIfBackgrounded, but nothing bounds its length once the app leaves the foreground, and a kill leaves no trace in the strap log. - There is no checkpoint. An interrupted pass restarts from the full 4000-day window.
The Android twin looks different: AppViewModel sets both flags once the repair's persistence returns (flagSet, around line 1163), and its passes are serialized by a coroutine Mutex, so a trigger arriving mid-repair waits rather than voiding it. That is from reading the code, not a device test.
So on a syncing device the repair converges only if a whole full-history pass happens to complete with no offload in between. Each failed attempt costs another full-history pass, foreground or background.
Impact
- The repair (the Effort 0–100 axis fix, and the unmatched off-wrist sleep recovery from fix(sleep): recover unmatched off-wrist tails from sustained HR #2585) may never be recorded as done, and re-runs at every foreground launch.
- Long full-history passes in the background, which is the shape of the CPU-resource terminations tracked in [ios] Background CPU kill (cpu_resource_fatal): the Live strap-log card re-renders every buffered log line on each new line, even while the app is backgrounded #2521 and the Re-score needs 10s of work but takes 25-70min backgrounded, so the score lands ~an hour after the data (#1005) #1538 family.
- New nights go unscored while the pass holds the lock.
Suggested direction (maintainer's call)
- Let the repair's completion be judged on its own work. A forced re-score that queued behind it is new-data debt for a normal 21-day pass, not a reason to redo the full history: set the repair flags when the 4000-day pass persists, and run the queued re-pass at the default width.
- Do not start or continue the full-history pass in the background. Either route it through
RescoreBackgroundScheduler.run, like the other passes, or stop at the next day boundary when the app is backgrounded. - Checkpoint progress (e.g. the oldest day already rewritten), so an interrupted repair resumes instead of starting over.
What you expected instead
see above
Steps to reproduce
1.see above
2.
3.
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, or — if I'm on an Oura ring — the Oura ring support table, and this isn't an already-known limit (e.g. SpO₂, recovery/sleep on the 5/MG, or no Recovery / HRV on a ring-only day).
- I've left out anything personal (strap serial, account email, raw export).
Activity
pipiche38 commentedon Sep 30, 2026
Follow-up on the termination described under "Observed": the system reports for it have turned up, and it was not NOOP's own resource use. The phone as a whole ran out of memory.
Two reports, iOS 27.0:
JetsamEvent-2026-09-29-224320.ips(22:43:20). The only process killed is SpringBoard,reason: per-process-limit(257 MB). NOOP is listed as suspended, with no kill reason, 89 MB resident.SystemMemoryReset-2026-09-29-224327.ips(22:43:25.7).eventReason: "User reclaimable memory dropped below the limit. User reclaimable current: 57%. User reclaimable minimum: 62%". SpringBoard has already restarted (new pid, 6 s old). NOOP is still alive: the same pid, 94 MB resident, 280 MB lifetime peak, CPU time up from 894.17 s to 894.55 s since the first report. Itsage(≈ 3,613 s) matches the process that started at 21:43:12 and was running the repair.So NOOP survived the SpringBoard kill and ended with the system memory reset that followed. That is iOS restarting userspace phone-wide, which takes every app down. The CoreBluetooth relaunch came at 22:44:04. Consistent with a full userspace restart: on that relaunch the OS-level link to the WHOOP that other launches find already connected was gone. NOOP's share was small: 94 MB of about 6.4 GB resident across all processes, with the largest at 465 MB. There is no
cpu_resource_fatalor crash report for NOOP on that date.This does not change the report itself. Here the interruption came from outside, but any interruption restarts the repair from the full 4000-day window. The
pendingForcedRescorecompletion rule and the background re-pass described above apply however a pass ends.pipiche38 commentedon Oct 1, 2026
Measured on the next evening (same phone, same build, Oura ring syncing every ~5 min). The repair started at a foreground launch at 20:36:39. Each pass was followed by another one at the same width, and none of them set the flags:
That is 83 minutes of wall time and 44 minutes of CPU, with no completion that counts. Each pass takes 15–26 minutes, and a ring offload with a forced re-score arrives every few minutes. So every pass sees
pendingForcedRescore, andonPersistedis skipped every time. With a strap that syncs, this cannot converge however long the process lives.At the next launch the repair did not start at all. That launch was a background relaunch, and at the morning foreground activation a 21-day pass held
computing. It is still pending and will start again from the full window.fix(apple): complete history repair before queued recent rescore
fix(apple): complete history repair before queued recent rescore
fix(apple): finish wide dedup reconciliation before marking history r…