Skip to content

[iOS] Two re-score passes can run at the same time: analyzeRecent checks computing before two awaits and sets it after them #2607

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 #899 · Refs #1681

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

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

  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, 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).

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