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
Summary
On iOS, IntelligenceEngine.analyzeRecent tests its lock (guard !computing) and takes it (computing = true) about 90 lines apart, with two awaits in between. The engine is @MainActor, so each await is a point where another call can enter. Two forced re-scores arriving together both pass the guard, and two full passes run concurrently over the same window, both writing the same rows.
Expected: one pass at a time. A second forced trigger queues behind the first (re-score: queued behind a … pass), as the #899-A re-arm intends.
Observed (iOS 27.0, NOOP 11.8.0 build 428; strap log, background relaunch)
[22:45:15] Oura: offload complete - marking synced so the new history is scored now (not at the next periodic tick)
re-score: trigger=forced newData=yes
re-score: trigger=post-offload newData=yes ← no "queued behind" between the two
re-score: background time expired mid-pass — it resumes on the next wake (#1538)
… six hours later, two interleaved post-loop blocks …
analyzeRecent postLoop total=197980ms … score2=196967ms …
re-score: done — scored 21 night(s) in 22777890 ms (#1005)
re-score: cost cpu=537.5s elapsed=22777.9s cpuShare=2% assertionExpired=1 backgrounded=true
analyzeRecent postLoop total=195573ms … score2=194374ms …
re-score: done — scored 21 night(s) in 22779584 ms (#1005)
re-score: cost cpu=538.0s elapsed=22779.6s cpuShare=2% assertionExpired=1 backgrounded=true
re-score: debt NOT settled — a newer re-score was recorded while this pass was running, so the mark stays and another pass will run (#1681)
Two trigger lines, two storeProbes, two Dedup(#899) sweeps, two postLoop blocks with different timings, and two done/cost pairs whose elapsed times differ by 1.7 s: two passes, not one pass logged twice. Seen once so far (n=1), but the window below is structural.
Cause (Strand/Data/IntelligenceEngine.swift, upstream/main)
768 guard !computing else { … pendingForcedRescore = true; return }
780 guard let store = await repo.storeHandle() else { … } ← suspension point
801 let wmKey = (try? await store.analysisFingerprint()) ?? "" ← suspension point
860 computing = true
Call A passes 768 and suspends at 780 or 801. Call B runs, also sees computing == false at 768, and suspends too. Both then reach 860 and run. The #899-A re-arm, the runningPassStart bookkeeping and the #1681 owed-token logic all assume one pass holds the lock.
The Android twin does not have this window: analyzeRecent there runs under analyzeGate.withLock { } (a coroutine Mutex), so a second caller waits instead of entering.
Impact
- Double CPU and memory for the full pass duration. That matters most in the background, where re-score cost is already the main suspect in the CPU-resource terminations ([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, Re-score needs 10s of work but takes 25-70min backgrounded, so the score lands ~an hour after the data (#1005) #1538).
- Two writers on the same computed rows. The last writer wins, so the stored result depends on timing, and the lock's bookkeeping (
runningPassStart,pendingForcedRescore, the owed token) is shared between two passes that each assume they own it.
Suggested direction (maintainer's call)
Take the lock before the first suspension point: set computing = true (and runningPassStart / runningPassDays) right after the guard at 768, and release it on every early return between there and 860. Alternatively, serialize callers the way the Kotlin twin does. A regression test can start two forced analyzeRecent calls concurrently and assert one re-score: trigger= line plus one queued behind line.
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, 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
fix: serialize iOS re-score admission (refs #2607)
fix: serialize iOS re-score admission (refs ryanbr#2607)
fix: drop the upstream re-pass arguments this fork's analyzeRecent ha…
Merge upstream/main through 6ce6573 (29 commits)
Merge upstream main (v12.0.0 and later) into the fork
Claim sleep rescore admission before awaiting storage