Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,9 @@ final class AppModel: ObservableObject {
/// so that it cannot mark unscored data as scored — so gating on the fingerprint here would be asking
/// a question whose answer is already known to be "yes, there is work".
func runDeferredRescoreIfOwed() async {
guard RescoreBackgroundScheduler.isRescoreOwed else { return }
// A pass already running here holds the owed mark itself and settles it when it finishes; forcing
// another would only queue a second full pass behind it.
guard RescoreBackgroundScheduler.isRescoreOwed, !intelligence.computing else { return }
// #2238: force only when the debt is UNPROVEN — an interrupted pass, whose watermark was
// deliberately never advanced. A pass that COMPLETED and was merely outvoted by a token recorded
// mid-pass did advance it, so asking the fingerprint is a real question with a real answer, and a
Expand All @@ -688,6 +690,11 @@ final class AppModel: ObservableObject {
// The deferred pass is the one that finally produces today's score, and it runs with no UI
// attached — so publish the snapshot here too, for the same reason the post-offload path does.
await WidgetSnapshot.publish(from: self)
// Apple Health too. The post-offload write-back ran BEFORE this pass (the offload deferred its
// re-score here), so it published the store as it stood then: last night's sleep and vitals were
// not scored yet and only reached Health on some later foreground. This is the first moment they
// exist. The bridge coalesces a call that lands during an in-flight write-back.
await healthWriteBack?()
#endif
}

Expand All @@ -710,7 +717,8 @@ final class AppModel: ObservableObject {
// the #1538 report while never producing a score. Decide first whether this pass can finish here,
// and hand it to a background-processing task when it cannot. A no-op on macOS, and on iOS a
// foreground pass is never deferred.
await RescoreBackgroundScheduler.run(log: { [live] line in live.append(log: line) }) {
await RescoreBackgroundScheduler.run(passInProgress: intelligence.computing,
log: { [live] line in live.append(log: line) }) {
await intelligence.analyzeRecent(skipIfUnchanged: true)
}
await refreshV5Signals()
Expand Down
11 changes: 9 additions & 2 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,10 @@ final class IntelligenceEngine: ObservableObject {

// #1005: time the whole pass — the trigger line above records WHY; this records how many nights
// and how long (the CPU cost per run), so a re-score STORM is visible in the strap log.
let reScoreStart = Date()
// Uptime, not `Date()`: the elapsed figure below is banked as what a pass COSTS, and a wall clock
// also counts every minute the process spent suspended mid-pass. One overnight pass suspended by a
// sleeping phone banked 19 003 s, which then deferred every background re-score after it.
let reScoreStart = DispatchTime.now().uptimeNanoseconds
computing = true
// #1538: the pass is now past every gate and will do real work. Mark it started durably, so that a
// process killed mid-pass leaves evidence a LATER process can read — the killed process itself gets
Expand Down Expand Up @@ -1059,7 +1062,9 @@ final class IntelligenceEngine: ObservableObject {
try? await store.rrIntervals(deviceId: o, from: f, to: t, limit: StreamReadCap.rr,
unlabelledAliasOfWhoop5: activeWhoop5RR && o == Repository.whoopSource)
}
var paceMark = DispatchTime.now().uptimeNanoseconds
for offset in 0..<maxDays {
if offset > 0 { await RescoreBackgroundScheduler.paceIfBackgrounded(since: &paceMark) }
let dayStart = nowLocalMidnight - offset * 86_400
let day = AnalyticsEngine.dayString(dayStart, offsetSec: tzOffset)
// Read a generous window around the night that ends on `day`; the stager finds the span.
Expand Down Expand Up @@ -2049,7 +2054,9 @@ final class IntelligenceEngine: ObservableObject {
nowLocalMidnight: nowLocalMidnight, now: now, offsetSec: tzOffset,
maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias)
var appliedLegacySnapshots: [String: LegacyScoreSnapshot] = [:]
var paceMark = DispatchTime.now().uptimeNanoseconds
for night in scoredNights {
await RescoreBackgroundScheduler.paceIfBackgrounded(since: &paceMark)
// #299: scope the edits to THIS day before folding. A userEdited row / hand-logged nap belongs
// to exactly ONE day — the day its night ENDS on, matching the daily's end-day bucket. `endTs`
// is stable under a bedtime edit (only the onset/`startTsAdjusted` moves), so end-day is the
Expand Down Expand Up @@ -2841,7 +2848,7 @@ final class IntelligenceEngine: ObservableObject {
// measurement is what lets `RescoreBackgroundPolicy` tell an install that finishes comfortably in a
// background wake from one that never could, instead of guessing from a constant — the cost varies
// by more than an order of magnitude with history size.
let elapsed = Date().timeIntervalSince(reScoreStart)
let elapsed = Double(DispatchTime.now().uptimeNanoseconds &- reScoreStart) / 1_000_000_000
let settled = RescoreBackgroundScheduler.markRescoreCompleted(seconds: elapsed, owedToken: owedToken)
diagnosticSink?("re-score: done — scored \(scoredNights.count) night(s) in \(Int(elapsed * 1000)) ms (#1005)", nil)
// #1681: a pass that completes while leaving the mark SET looks identical in a capture to one that
Expand Down
70 changes: 37 additions & 33 deletions Strand/Data/RescoreBackgroundPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
/// backgrounded — it stays alive as a `bluetooth-central` to receive the offload in the first place. But
/// `analyzeRecent` is all-or-nothing: pass 1 writes nothing, every store write happens after both loops,
/// and the watermark advances only at the very end so an interrupted run can never mark unscored data as
/// scored. On a heavy install the pass measured **474,778 ms** — nearly eight minutes. iOS suspends the
/// scored. On a heavy install the pass measured **474,778 ms** — nearly eight minutes. iOS ends the
/// process long before that, so the work is lost in full.
///
/// The lost work is not the worst of it. Because the watermark never advanced, the NEXT trigger still saw
Expand All @@ -17,7 +17,9 @@ import Foundation
/// guarantees it will be attempted again. The score appeared 1 h 57 m after the data was complete, and only
/// because the app happened to stay foregrounded for eight unbroken minutes.
///
/// So this decides, before spending anything: is this a pass that can plausibly finish here?
/// What ends those passes is the background CPU limit, not suspension (see `backgroundRestPerWorkSecond`):
/// a suspended pass resumes on the next wake, a killed one does not. A backgrounded pass therefore paces
/// itself under that limit, and this decides only whether one should start here at all.
///
/// Deliberately NOT a fix for how long the pass takes. A cold process still re-scores every night in the
/// window, because the per-day reuse cache is in-memory and starts empty (`IntelligenceEngine.dayScanCache`).
Expand All @@ -35,54 +37,56 @@ enum RescoreBackgroundPolicy {
case deferToBackgroundTask(reason: String)
}

/// What a background execution assertion is worth relying on, in seconds.
/// How long a backgrounded pass rests per second of work it just did.
///
/// `beginBackgroundTask` buys roughly 30 s on current iOS, and that figure is a courtesy rather than a
/// contract — it shrinks under memory pressure and in Low Power Mode. 20 s leaves headroom for the
/// assertion to be granted late and for the pass's own store writes to land, since being killed
/// mid-write is the one outcome worth spending real caution to avoid.
static let backgroundBudgetSeconds: Double = 20
/// What actually killed the background passes was CPU, not time: iOS terminates a background process
/// that holds more than 80% CPU over 60 s (`cpu_resource_fatal`). A cold pass is roughly 144 s of
/// near-continuous CPU on a large install, so every overnight attempt was killed about 52 s in — 26 kills
/// on one phone in five nights, each leaving the debt for the next attempt to be killed on. Resting as
/// long as it worked holds the pass near 50%. A suspension between rests is harmless: the pass is not
/// killed by it, it resumes on the next wake, so a pass longer than any single wake still completes.
static let backgroundRestPerWorkSecond: Double = 1.0

/// The longest single rest. Work measured on the uptime clock can include a suspension the process
/// spent mid-unit; resting for all of it would stall a pass that has already been idle.
static let maxBackgroundRestSeconds: Double = 30

/// Seconds to rest after `workSeconds` of re-score work. Zero in the foreground, where no CPU limit
/// applies and the user is waiting on the result. A non-finite or non-positive measurement rests zero.
static func restSeconds(afterWorkSeconds workSeconds: Double, isBackground: Bool) -> Double {
guard isBackground, workSeconds.isFinite, workSeconds > 0 else { return 0 }
return min(workSeconds * backgroundRestPerWorkSecond, maxBackgroundRestSeconds)
}

/// - Parameters:
/// - isBackground: whether the app is currently backgrounded. A foregrounded app is never deferred:
/// the user is looking at the screen, there is no suspension deadline, and the existing behaviour
/// is correct.
/// - isRealUpdate: the trigger carries new data that must be scored (an offload), as opposed to the
/// steady-state backstop tick. A backgrounded backstop does not run: a paced pass costs minutes,
/// the tick cannot tell live HR from a real change, and every real update already runs its own.
/// - rescoreAlreadyOwed: a re-score is outstanding — either a pass marked itself started and never
/// marked itself finished (it was killed; the mark survives process death, which is the point,
/// because the killed process gets no chance to record anything) or an earlier trigger already
/// deferred one. Both mean the same operationally: the work is spoken for, and starting it here
/// would duplicate a pass that something better placed is going to run. This is the
/// self-correcting part — the FIRST background attempt on an install we know nothing about is
/// allowed to run, and from then on the work escalates instead of being re-killed on every
/// offload.
/// - lastCompletedPassSeconds: how long the last pass that ran to completion took, or nil if none
/// has. Measured rather than assumed — the cost varies by more than an order of magnitude with
/// history size, and a fixed guess would either defer installs that finish comfortably or wave
/// through ones that never could.
/// - budgetSeconds: see `backgroundBudgetSeconds`; a parameter so the tests can state the boundary
/// rather than inherit it.
/// deferred one. The work is spoken for by the processing task this escalated to.
/// - passInProgress: a pass is running in THIS process. Its own started-mark is what reads as owed, so
/// it is not evidence of a killed pass; the engine re-arms one follow-up pass for a trigger that
/// lands mid-run. Deferring instead recorded a newer debt, the running pass then finished without
/// settling it (#1681), and every offload after that deferred on it.
static func decide(isBackground: Bool,
isRealUpdate: Bool = true,
rescoreAlreadyOwed: Bool,
lastCompletedPassSeconds: Double?,
budgetSeconds: Double = backgroundBudgetSeconds) -> Decision {
passInProgress: Bool = false) -> Decision {
guard isBackground else { return .run }

if rescoreAlreadyOwed {
guard isRealUpdate else {
return .deferToBackgroundTask(
reason: "a re-score is already outstanding from an earlier trigger")
reason: "the backstop tick does not re-score while backgrounded; offloads run their own")
}

// Only a FINITE, positive measurement can justify deferring. A nil (nothing has ever completed),
// a zero, or a NaN/infinity from a corrupted default all mean "unknown", and unknown must fall
// through to running: refusing to score on the strength of a value we cannot read would be a far
// worse failure than one wasted pass.
if budgetSeconds > 0,
let measured = lastCompletedPassSeconds,
measured.isFinite, measured > 0,
measured > budgetSeconds {
if rescoreAlreadyOwed, !passInProgress {
return .deferToBackgroundTask(
reason: "last completed pass took \(Int(measured.rounded()))s, over the "
+ "\(Int(budgetSeconds.rounded()))s a background wake can be relied on for")
reason: "a re-score is already outstanding from an earlier trigger")
}

return .run
Expand Down
25 changes: 22 additions & 3 deletions Strand/System/RescoreBackgroundScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,18 @@ enum RescoreBackgroundScheduler {
/// would conjure a forced full pass for a processing task to run when very likely nothing changed,
/// which is the churn #1146 exists to avoid. A debt an earlier real pass already recorded is
/// untouched either way.
/// - Parameter passInProgress: a pass is already running in this process; see
/// `RescoreBackgroundPolicy.decide`.
static func run(isBackground: Bool? = nil,
owesOnDefer: Bool = true,
passInProgress: Bool = false,
log: @escaping (String) -> Void,
work: () async -> Void) async {
let decision = RescoreBackgroundPolicy.decide(
isBackground: isBackground ?? isBackgrounded,
isRealUpdate: owesOnDefer,
rescoreAlreadyOwed: isRescoreOwed,
lastCompletedPassSeconds: lastCompletedPassSeconds)
passInProgress: passInProgress)

switch decision {
case .deferToBackgroundTask(let reason):
Expand All @@ -193,6 +197,17 @@ enum RescoreBackgroundScheduler {
}
}

/// Rest after a unit of re-score work when backgrounded, so the pass stays under iOS's background CPU
/// limit instead of being killed by it (`RescoreBackgroundPolicy.backgroundRestPerWorkSecond`). `mark` is
/// the uptime the unit started at, in nanoseconds; it is reset to the end of the rest for the next unit.
nonisolated static func paceIfBackgrounded(since mark: inout UInt64) async {
let workSeconds = Double(DispatchTime.now().uptimeNanoseconds &- mark) / 1_000_000_000
let background = await MainActor.run { isBackgrounded }
let rest = RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: workSeconds, isBackground: background)
if rest > 0 { try? await Task.sleep(nanoseconds: UInt64(rest * 1_000_000_000)) }
mark = DispatchTime.now().uptimeNanoseconds
}

/// Hold an execution assertion for the duration of `work` so a SHORT pass is not suspended halfway.
/// A long one still outlives the grant; the assertion's expiry handler is where that becomes visible
/// in the log and where the work is escalated, rather than the process simply vanishing.
Expand All @@ -206,7 +221,7 @@ enum RescoreBackgroundScheduler {
// the owed mark is still set (only a completed pass clears it) and that is what the next
// decision reads.
MainActor.assumeIsolated {
log("re-score: background time expired before the pass finished — escalating (#1538)")
log("re-score: background time expired mid-pass — it resumes on the next wake (#1538)")
schedule()
assertion.end()
}
Expand All @@ -229,7 +244,10 @@ enum RescoreBackgroundScheduler {
/// Register the handler. MUST be called from `StrandiOSApp.init()` before launch finishes, and the
/// identifier MUST be listed in `BGTaskSchedulerPermittedIdentifiers`, or iOS never delivers the task.
/// Safe to leave uncalled: `schedule()` fails gracefully and the foreground path still scores.
static func register(perform operation: @escaping @MainActor () async -> Void) {
/// `onExpire` reports iOS reclaiming the processing time before the pass finished. The pass keeps no
/// record of it otherwise, so a strap log that simply stops mid-night cannot say why.
static func register(perform operation: @escaping @MainActor () async -> Void,
onExpire: @escaping @MainActor () -> Void = {}) {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
let completion = TaskCompletionGuard(task: task)
let worker = Task { @MainActor in
Expand All @@ -243,6 +261,7 @@ enum RescoreBackgroundScheduler {
}
task.expirationHandler = {
worker.cancel()
Task { @MainActor in onExpire() }
// The pass did not finish inside the processing budget either. Ask for another rather
// than dropping the work, and report the failure so iOS's own scheduling heuristics see
// it honestly instead of being told this succeeded.
Expand Down
Loading