Skip to content

fix(rescore): pace a backgrounded pass under iOS CPU limits (rebase of #2280) - #2296

Merged
ryanbr merged 1 commit into
mainfrom
rescore-pacing-rebased
Sep 17, 2026
Merged

ryanbr merged 1 commit into
mainfrom
rescore-pacing-rebased

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 17, 2026

Copy link
Copy Markdown
Owner

A rebase of #2280 onto current main, opened here so it can land without waiting on a round trip. All the work is @Iskrata's; the commit keeps their authorship and I have changed nothing in it.

Why a new PR rather than a merge of theirs

#2280 was stacked on #2279, which landed as a SQUASH (2eafe4b0b). Its parent commit is therefore not an ancestor of main, and the branch point was also well behind: diffed from main, #2280 read as about 2,110 deletions, removing AGENTS.md, HealthExportMerge.swift, the i18n audit rule, the Coach master switch, the motion-trace hoist and more.

It was never proposing any of that, it is simply what a stale branch point looks like from main's side. But it is the shape that lands as a silent revert if merged on a green tick, and git merge-tree reported three conflicts besides.

Cherry-picking the single pacing commit onto current main gives what the change actually is: 6 files, +142/-134.

The change itself

#1538 read killed background passes as work too slow for one wake, and deferred anything over 20 s to a processing task. The device's own crash reports say otherwise: it is iOS's background CPU limit. Across five nights one iPhone logged 26 cpu_resource_fatal reports, each reading

CPU: 48 seconds cpu time over 52 seconds (93% cpu average), exceeding limit of 80% cpu over 60 seconds

against a cold pass of about 144 s. Deferring only moved where the kill happened; the processing-task attempts were killed on the same limit.

A backgrounded pass now rests after each night for as long as that night's work took, capped at 30 s, holding it near 50% CPU. Once a pass cannot be killed for running long, duration stops being a reason to defer, so that rule goes.

Two details worth noticing, both the contributor's: the 30 s cap exists because uptimeNanoseconds keeps advancing while the process is merely suspended, so an uncapped rest would stall an already-idle pass; and paceIfBackgrounded re-reads isBackgrounded per unit rather than sampling once, so a pass that starts foregrounded and is backgrounded mid-run begins pacing from that point.

What is still open

My review notes on #2280 stand. The one I would most like answered from the overnight run is whether the assertion expiry handler fired: "no cpu_resource_fatal reports" reads the same whether the pass genuinely resumed after suspension or ran on detached past its grant, and only the first means a backgrounded pass reliably finishes.

Also worth watching: removing duration-based deferral takes away the safety net at the same moment the replacement arrives. If pacing does not hold the limit on a slower phone or a denser history, the symptom returns as kills rather than delays, with no fallback.

Verified here with swiftc -parse on the four changed sources; CI covers the compile. Credit and thanks to @Iskrata.

…of deferring it

#1538 read the killed background passes as work that could not finish inside a wake, and deferred any
pass whose last completed run took over 20 s to a processing task. The on-device crash reports say
otherwise: 26 `cpu_resource_fatal` kills on one iPhone in five nights ("48 seconds cpu time over 52
seconds ... exceeding limit of 80% cpu over 60 seconds"), each about 52 s into a pass. A cold pass is
~144 s of near-continuous CPU on that install (`re-score: done — scored 21 night(s) in 144311 ms`,
`dayCache reused=0/21`), so every attempt in the background was killed, including the processing task
it was deferred to. The deferral only moved the kill, and the night's scores reached the phone when the
app was next opened.

A backgrounded pass now rests after each night, in both loops, for as long as the night took (capped at
30 s), holding it near 50% CPU. Suspension between rests does not kill it; it resumes on the next wake,
so a pass longer than any single wake completes. With that, how long a pass takes stops being a reason
to defer an offload, and the measurement rule is gone:

- a real update (an offload) runs in the background, paced;
- a pass owed from a KILLED attempt still defers, as before;
- a pass running in this process is not a killed one: its own started-mark reads as owed, and deferring
  on it recorded a newer debt the running pass could not settle (#1681), so every later offload deferred.
  The trigger now reaches the engine, which re-arms one follow-up pass;
- the backstop tick no longer runs in the background at all, since a paced pass costs minutes and every
  real update runs its own;
- a processing task that finds a pass already running leaves it to settle its own debt.

The measured duration is still banked and logged.
@ryanbr
ryanbr merged commit 65fde52 into main Sep 17, 2026
4 checks passed
@ryanbr
ryanbr deleted the rescore-pacing-rebased branch September 17, 2026 08:41
DX23876 added a commit to DX23876/noop that referenced this pull request Sep 17, 2026
Conflict resolutions:
- android/: kept deleted (the fork dropped the Android tree on 2026-08-14).
- project.yml build number, altstore-source.json, i18n baseline: kept the fork's.
- Rescore: kept the fork's shared background assertion, day reuse and follow-up
  reconciliation, and added upstream's background CPU pacing, uptime-based pass
  timing and the "pass already running" guard (ryanbr#2296).
- WorkoutsView: took upstream's WorkoutStartControl leaf (no 1 Hz re-render of
  the list, ryanbr#2295) and moved the fork's session-controller start flow into it.
- TrainingLoadCard: took upstream's extracted TrainingLoadChart and reapplied
  the fork's "Effort over time" / Baseline / Recent / Balance wording.
- SleepView: kept the fork's non-observing body-clock card.
- iOS app, widgets, intents, Settings: added upstream's Sync Strap shortcut,
  keep-screen-on setting and sync Live Activity (ryanbr#2272) beside the fork's
  Coach brief, Recovery Status intent and searchable settings, without the
  Lift Log activity the fork removed.
- Localizable.xcstrings: three-way merged per key and language.

Follow-ups needed to build and pass the i18n gate:
- BLEManager: pendingManualSyncTTL is nonisolated (Xcode 27 rejects a main
  actor default value in a nonisolated function).
- WorkoutsView: the dash-pattern lookup uses the interval label constants.
- Translate 19 String(localized:) literals the stricter upstream scanner now
  sees (ryanbr#2299), since the fork keeps no i18n baseline.

Analysis migration required: no (upstream's pacing and uptime timing change
when and how fast a pass runs, not what it computes).
DX23876 added a commit to DX23876/noop that referenced this pull request Sep 17, 2026
The fork's DeferredRescorePlanTests still called RescoreBackgroundPolicy.decide
with the pass-duration budget upstream removed, so StrandTests no longer
compiled after the 11.8.0 sync. An outstanding debt is now the only thing that
defers a backgrounded real update; the test pins that instead, and the two doc
comments that named the removed budget now describe it as the earlier behaviour.
ryanbr pushed a commit that referenced this pull request Sep 18, 2026
…per night (#2318)

From @Iskrata. Follow-up to #2296.

A rest is a Task.sleep, and a backgrounded process that is only sleeping is
exactly what iOS suspends, until the next bluetooth wake about ten minutes
later. Each unit of a pass is milliseconds to a few seconds of CPU, so resting
after every night meant a backgrounded pass advanced roughly one night per wake.
The CPU limit was respected; wall-clock progress was not.

On one phone a full-history pass took the re-score lock at 21:34 and still held
it after 11:00 the next day. Every post-offload pass in between returned at
`guard !computing` without a word, 26 dashboard-cache refreshes with no re-score
line among them, and that morning's night was never scored: no daily row, no
session.

A backgrounded pass now does ten seconds of work between rests. The rest is
still as long as the work it follows and still capped at 30 s, so the same ~50%
ceiling holds under iOS's 80%-over-60 s kill, with one suspension opportunity
per ten seconds of work rather than one per night.

The accumulation is what makes that safe: the work mark is no longer reset after
every unit, only after a rest actually happens or in the foreground, so short
units run back to back and the elapsed time builds until it crosses a quantum.
Had it kept resetting per unit the guard would never fire and the pass would run
unpaced into the CPU kill.

Also says, once per running pass rather than once per trigger, when a forced
re-score is queued behind one:

  re-score: queued behind a 4000-day pass running for 812 s

The log previously said nothing between "sync done" and "no score", which is why
this took a database pull to find. The pass start is cleared in the same defer
that clears `computing`, so a throwing pass cannot leave a stale duration
behind.

Nothing to port: Android has no equivalent background suspension and no twin of
the pacing.

Author: Iskrata
ksricharank pushed a commit to ksricharank/noop that referenced this pull request Sep 19, 2026
Records the two fork features RETIRED as redundant against upstream (the coach
settings move, superseded by ryanbr#2243's CoachSettingsView; the isDeviceLocked
re-score rule, superseded by the fork's own sleep-window rule and built on the
measured rule ryanbr#2296 deleted), and the two re-verified as still needed.

The notes carry the 260919 log's two open findings — the rss. counter-namespace
collision that makes two header lines print the same integer, and the
uninstrumented writeBackAfterNewData path that leaves the HealthKit-writes
question unanswerable — as the first two items the next log must address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ryanbr pushed a commit that referenced this pull request Sep 19, 2026
A backgrounded offload that finds a re-score already owed, with nothing running,
now defers only while the last pass STARTED less than 30 minutes ago. After that
it runs, paced as usual.

The owed rule dates from #1538, when a backgrounded pass was killed by the CPU
limit every time and retrying on each offload was the livelock. Pacing (#2296,
#2318) keeps a background pass under that limit now, but the rule still deferred
on any unfinished debt with no end, until a BGProcessingTask or the next
foreground. On one phone that left a pass unfinished at 11:25 deferring every
offload until the app was opened at 09:26 the next morning: 19 hours, and the
night in between was never scored.

The start time is recorded only by a pass that is about to work. The deferral
path re-marks the debt without touching it, so repeated deferrals cannot keep an
old unfinished pass looking recent.

Nothing to port: Android has no equivalent deferral or background suspension.

By @Iskrata.
ksricharank pushed a commit to ksricharank/noop that referenced this pull request Sep 21, 2026
…kipped

The report: Charge alternating between ~36 and ~67 through the day, present
already at 06:30 before the app was opened, and the home-screen widget showing
36 while the app showed 67 at the same moment.

Cause: the 260906 abort gate breaks the day loop the instant the app
backgrounds, leaving the pass with only the newest N of maxDays nights. On a
strap-only install those N nights ARE the HRV/RHR baseline — `hist` reads the
imported device id, which is empty — so pass 2 z-scored the night against a
window it never scored. Being a full pass, it then wrote that value straight
over what a completed pass had computed. The abandonment `return` sits ~1000
lines after the persist; it only skips the watermark and the debt settle.

This is the SAME failure the light-pass guard was written for (the 22 <-> 47
flip, 260903), reaching the store by a second route the `lightPass` flag cannot
see. Measured against the shipped scorer: one night reads Charge 64 folded from
4 nights and 30 folded from 21.

Why it surfaced now: upstream's ryanbr#2296 (v18 uplift) deleted the measured-duration
deferral, so a slow pass is no longer held back — it runs, rests, and meets the
abort gate. Abandonment went from rare to routine (25 in one log). Overnight the
app wakes backgrounded, where `isBackgroundedSnapshot` correctly reports
background on a cold launch, abandons, and leaves a truncated Charge sitting
there until the morning foreground pass corrects it.

The guard is keyed on BASELINE SUFFICIENCY, not on which kind of pass this is:
the invariant is "do not judge a night against a window we did not score", and
`lightPass` was only ever a proxy for it. A partial pass now preserves the
stored recovery via the existing lightPassMerged path. `wasAbandoned` is hoisted
above the fold so the one place that needs it can see it.

Three always-on ledgers, counts-only, because the store keeps only the final
value and none of this was answerable after the fact:

  - chargeWrite: every recovery write — value, hrvNValid, pass kind, trigger.
    nValid is the tell; far below 21 on kind=full means truncation.
  - chargePublish: the widget snapshot's Charge, its anchor day (flagged when
    carried), and whether WidgetKit was actually asked to reload or the request
    was withheld by the background budget. A withheld reload leaves a correct
    snapshot behind a stale face, which from outside looks like a wrong value.
  - chargeShown: what the screen displayed and via which branch, on transitions
    only. A carried 36 and a mis-scored 36 were previously indistinguishable.

Tests: AbandonedPassBaselineTests pins the premise against the shipped scorer
(a 4-night fold must read >20 points above a 21-night fold for the same night),
that a sub-seed fold refuses rather than guesses, and the four guard outcomes.
Mutation-checked: reverting the guard fails two of them. Every pre-existing
recovery test supplies a full baseline and asserts pure-function output — none
constructs a truncated one, which is why a ~30-point error shipped green.

Verified: Strand (macOS) + NOOPiOS build; full 2485-test macOS suite runs with
only the 4 failures already present on a clean worktree (confirmed by stashing
this change and re-running).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants