Repository navigation
Description
NOOP version
10.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 the app is killed in the background by the CPU monitor (cpu_resource_fatal, 80 % of a core over 60 s). The kills before the re-score pacing work (#2296 / #2318 / #2334) had the re-score thread in almost every sample. The newest kill, on a build that has the pacing, has the main thread in 5 of 6 samples (4 with a full main-thread stack, 1 truncated inside SwiftUI), doing SwiftUI view updates while the app is not frontmost. One of those samples lands in LiveLogCard. From the code, LiveLogCard rebuilds a non-lazy list of up to ~5,000 log lines on every appended line, and it does this in the background too.
The kill (2026-09-27 03:13, iPhone18,1, iOS 27.0, 11.8.0 (419) debug build, Oura Ring 3)
Event: cpu usage
Action taken: Process killed
CPU: 48 seconds cpu time over 57 seconds (85% cpu average), exceeding limit of 80% cpu over 60 seconds
Primary state: 5 samples Non-Frontmost App ... Effective Thread QoS Background, Requested Thread QoS User Interactive
Power Source: 6 samples on AC
Symbolicated app frames (the stack roots are StrandiOSApp.$main → SwiftUI/UIKit run loop → AttributeGraph):
main thread: 4 samples with the full stack (StrandiOSApp.$main → run loop), plus 1 truncated SwiftUICore stack:
...SwiftUI / AttributeGraph update...
closure #1 in View.onChangeCompat<A>(of:perform:) StrandDesign.swift:28
closure #2 in closure #2 in closure #1 in LiveLogCard.body LiveView.swift:1285 (1 sample)
concurrency thread, 1 sample:
IntelligenceEngine.analyzeRecent(...) closure #9 IntelligenceEngine.swift:1528
AnalyticsEngine.analyzeDay(...) AnalyticsEngine.swift:544/552
SleepStager.sessionAvgHRV → sessionHrvWindows → Array.filter SleepStager.swift:2959/3010
In the minute before the kill the app logged about 350 lines (a history drain on reconnect plus the re-score's per-day diagnostic lines), about 6 lines a second.
Earlier kills on the same phone, for the change in shape:
| report | CPU | samples by root |
|---|---|---|
| 2026-09-16 21:18 | 48 s / 52 s (92 %) | 5 × Swift concurrency (re-score) |
| 2026-09-17 08:30 | 48 s / 59 s (81 %) | 4 × Swift concurrency |
| 2026-09-17 08:50 | 48 s / 52 s (92 %) | 5 × Swift concurrency |
| 2026-09-17 13:42 | 48 s / 56 s (85 %) | 5 × Swift concurrency, 2 × main |
| 2026-09-27 03:13 | 48 s / 57 s (85 %) | 1 × Swift concurrency, 5 × main (SwiftUI) |
Why the log card is expensive (from the code)
LiveState.logis a@Publishedarray capped atmaxLogLines = 5_000(plus a 256-line trim slack).append(log:)appends one line at a time on the main actor, so every line publishes a change.LiveLogCard(Strand/Screens/LiveView.swift) renders it withScrollView { VStack { ForEach(Array(live.log.enumerated()), id: \.offset) { Text(...) } } }. That is a non-lazyVStackover the whole buffer: every row is built and diffed on each change, andonChangeCompat(of: live.log.count)then callsproxy.scrollTo(last).- Nothing gates this on
scenePhase. A backgrounded app whose Live tab has been visited keeps the card alive and keeps paying the cost for every line. LiveStateis oneObservableObject, so each appended line also invalidates every other view that observes it. That is likely part of the unattributed SwiftUI samples, but it is not shown by this report.
So the cost of one log line grows with the size of the buffer, which is usually full, and the drain / re-score bursts produce hundreds of lines a minute. This comes on top of the paced re-score's own CPU, and the 80 % budget is per process, not per thread.
Suggested fix (small, app-target only)
- Render only a tail in the card, e.g. the last 200 lines, in a
LazyVStack. The full buffer is still what Copy / Save / the export read, so nothing is lost. - Skip the update while
scenePhase != .active, and catch up on return. The log is still written to disk byappend(log:), so nothing is lost there either. - Optionally, move
logout of the bigLiveStateobject (or coalesce appends, e.g. per 250 ms) so a log line doesn't invalidate the rest of the UI.
Limits of this report
- The main-thread shape is one kill with 6 samples, and only 1 of the 5 main-thread samples is symbolicated into NOOP code (
LiveLogCard). The other 4 are SwiftUI / AttributeGraph frames without an app frame, so which views they belong to is not shown. - The mechanism above comes from reading the code, not from a profile. An Instruments Time Profiler run over a backgrounded drain would confirm the split.
How to reproduce
Visit the Live tab once, background the app, and let a large history drain plus a forced re-score run (for example, reconnect after a long gap). Watch for cpu_resource_fatal in Settings → Privacy & Security → Analytics Data.
What you expected instead
no crash
Steps to reproduce
- see the general description
NOOP Staging.cpu_resource_fatal-2026-09-27-031359.ips.zip
Strap log
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
perf(ios): coalesce strap-log publishes instead of one per line
pipiche38 commentedon Sep 28, 2026
A second kill on the same phone and the same build, 16 hours after the one above. Same shape.
2026-09-27 19:10, iPhone18,1, iOS 27.0, 11.8.0 (419) debug build, Oura Ring 3
This report has the same binary image UUID as the 03:13 one (
A8D6084D-…), so its app frames map onto the symbols above offset for offset:StrandiOSApp.$main→ run loop)+28074092→+28052056), then SwiftUI / AttributeGraph updates only. No app frame below them, so these three samples cannot be tied to a specific view.IntelligenceEngine.analyzeRecent→AnalyticsEngine.analyzeDay→SleepStager.sessionAvgHRV→sessionHrvWindows), identical offsets down to+40239388. Below it, one sample is at+40387464, the exactArray.filterframe of 03:13; the other is 264 bytes away at+40387728, not symbolicated.What the app was doing: the kill lands at the end of a background 21-night re-score. The last line before the kill is
re-score: cost cpu=572.7s elapsed=669.6s cpuShare=86% assertionExpired=0 backgrounded=true. In the last ~60 s the strap log gained 264 lines. Only 8 were timestamped BLE lines; the other 256 were the re-score's per-day diagnostics (effort calib,effort score,hrv diag,rhr bins,workout detect: 21 each, one per scored day), all appended in a burst while the app was not frontmost.That fits the mechanism in the report above better than the first kill did: the main-thread SwiftUI work coincides with a burst of several hundred log appends. As before, this is still inferred from the code; the samples do not show
LiveLogCarditself this time.Tally on this phone with an
.ips: 09-16 21:18, 09-17 08:30 / 08:50 / 13:42 (re-score thread dominant, before the pacing work), 09-27 03:13 (main 5/6), 09-27 19:10 (main 3/5, re-score 2/5). The same process died twice more without a teardown the following night (09-28 ~01:21 and ~08:05); no crash report for those yet.NOOP Staging.cpu_resource_fatal-2026-09-27-191042.ips.zip
Merge upstream/main (ryanbr/noop) into reNOOP
pipiche38 commentedon Oct 1, 2026
Follow-up from the same phone (iPhone18,1, iOS 27.0, Oura Ring 3). The builds since 2026-09-28 include #2546 and #2548, and since the evening of 09-29 also #2560. On those builds the background CPU kill has not come back.
MetricKit's daily report for 2026-09-29 (a full day on those builds, with background re-scores running for hours):
No
CPU limitexit. The single memory-pressure exit is the phone-wide system memory reset at 22:43 that night, already described in #2606: every app was restarted, and there is nocpu_resource_fatalreport for NOOP on that date. For comparison, the report for the day before (09-28) showedexits: CPU limit 1, memory pressure 1.From the reporter's side this issue is ready to close. The remaining background-cost questions (the full-width history repair, overlapping re-score passes) are tracked separately in #2606 and #2607.