Skip to content

[iOS] The one-shot 4000-day history repair can never mark itself done while a strap syncs, re-runs at full width (also in the background), and was in flight when the process was terminated #2606

Open

Description

@pipiche38

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 .ips has not been pulled, and the MetricKit day report for that date arrives with the next export. The previous MetricKit day on the same device reads exits: CPU limit 1, memory pressure 1, peak memory 2.4 GB.
  • The relaunched process (background) did not restart the repair, because of the !RescoreBackgroundScheduler.isBackgrounded guard. 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)

  1. runHistoryRepairIfNeeded (line 687) passes an onPersisted closure that sets effortRescoreFlagKey and sleepWearRescoreFlagKey.
  2. analyzeRecent calls it only at the very end, and only when nothing queued behind the pass: if !Task.isCancelled && !pendingForcedRescore { onPersisted?() } (line 3047).
  3. A forced re-score arriving while the pass holds computing sets pendingForcedRescore (line 768 and following). Every completed offload triggers one, and with a ring that is every 300 s.
  4. The defer then re-launches analyzeRecent(maxDays: maxDays, …, onPersisted: onPersisted) in a fresh Task: the same 4000-day width, the same completion rule. This re-pass does not go through runHistoryRepairIfNeeded, so its !isBackgrounded guard does not apply. A re-pass can start and run in the background.
  5. The repair is launched directly from AppModel (line 500, runEffortRescoreIfNeeded), not through RescoreBackgroundScheduler.run. So it holds no background execution assertion, and it cannot leave the background time expired mid-pass line the 21-day passes leave. It is paced by paceIfBackgrounded, but nothing bounds its length once the app leaves the foreground, and a kill leaves no trace in the strap log.
  6. 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

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

  1. pipiche38 commented on Sep 30, 2026

    @pipiche38
    Author

    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:

    1. 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.
    2. 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. Its age (≈ 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_fatal or 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 pendingForcedRescore completion rule and the background re-pass described above apply however a pass ends.

  2. pipiche38 commented on Oct 1, 2026

    @pipiche38
    Author

    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:

    pass ended nights elapsed cpu cpuShare backgrounded forced trigger queued during the pass
    1 20:55:33 65 1135.5 s 695.5 s 61 % true at 1 s
    2 21:18:35 65 1384.1 s 783.0 s 57 % true at 32 s
    3 21:50:30 65 1552.7 s 736.4 s 47 % true at 147 s
    4 22:05:45 65 915.1 s 449.9 s 49 % true at 20 s
    5 — — — — — — at 8 s; the process ended about 9 min into this pass

    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, and onPersisted is 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.

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