Make the watch own its workout-session lifecycle
The HKWorkoutSession could only ever be born via the phone's one-shot startWatchApp handoff (BULLETPROOFING.md H1-H3): a dropped handoff, a watch crash/reboot, or a run engaged manually on the wrist left the whole workout sessionless - no heart rate, no Health save, app suspending wrist-down - and "End Current & Start New" swallowed the handoff against the old session's idempotency guard. Now the coordinator self-starts a session on any reconcile that finds an active run with none running (SessionEndPlanner.shouldStart / runToStart, activity type from the run's split), recover() re-adopts a crash-orphaned session at launch, and a system-ended session salvages its Health save instead of dropping it. A .finish decided for a session younger than 30s demotes to .discard so the stale-context races can't save junk workouts attributed to the wrong run; parallel completions now pick the survivor deterministically. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import SwiftData
|
||||
|
||||
/// What the watch should do with its `HKWorkoutSession` given the latest authoritative
|
||||
@@ -16,17 +17,33 @@ enum SessionEndDecision: Equatable {
|
||||
case finish(WorkoutDocument)
|
||||
}
|
||||
|
||||
/// Pure decision seam for ending the watch's workout session — the session-free core the
|
||||
/// Pure decision seam for the watch's workout-session lifecycle — the session-free core the
|
||||
/// `WorkoutSessionCoordinator` delegates to, mirroring the `WatchCacheApplier` pattern so the
|
||||
/// end-of-run rule is unit-testable without HealthKit or a live cache.
|
||||
/// start/end rules are unit-testable without HealthKit or a live cache.
|
||||
///
|
||||
/// The rule reconciles against the *authoritative* workout set (the same set the phone pushes
|
||||
/// and the watch prunes to), not against any view's lifetime — a running session ends on a
|
||||
/// genuine non-empty → empty transition of the active set. Comparing against the active set
|
||||
/// captured at the *previous* reconcile is what makes the launch race safe: at startup the
|
||||
/// The end rule reconciles against the *authoritative* workout set (the same set the phone
|
||||
/// pushes and the watch prunes to), not against any view's lifetime — a running session ends
|
||||
/// on a genuine non-empty → empty transition of the active set. Comparing against the active
|
||||
/// set captured at the *previous* reconcile is what makes the launch race safe: at startup the
|
||||
/// session can be `running` before the run document has synced, but the previous active set is
|
||||
/// still empty, so we never discard a run we simply haven't heard about yet.
|
||||
///
|
||||
/// The start rule makes the watch own its session: whenever the authoritative set holds an
|
||||
/// active run and no session is running, one should be started. The phone's
|
||||
/// `startWatchApp(toHandle:)` handoff then becomes a fast-launch optimization rather than the
|
||||
/// only way a session is ever born — a dropped handoff (watch unreachable at start, a handoff
|
||||
/// swallowed by `start`'s idempotency guard while the old run's session was still running),
|
||||
/// a crash/reboot, or a run engaged manually on the wrist all heal at the next reconcile.
|
||||
enum SessionEndPlanner {
|
||||
/// A `.finish` for a session younger than this is demoted to `.discard`: a seconds-old
|
||||
/// session has no meaningful samples, and the demotion is what keeps the two stale-data
|
||||
/// races from polluting Apple Health with junk workouts attributed to the wrong run —
|
||||
/// (a) a transient "old run completed, new run not yet pushed" context arriving right
|
||||
/// after the new run's session started, and (b) a self-start off stale cache at launch,
|
||||
/// corrected seconds later by the fresh context. In both cases the active set refills
|
||||
/// and `shouldStart` brings the session right back.
|
||||
static let minimumFinishAge: TimeInterval = 30
|
||||
|
||||
/// Ids of the runs that keep the session alive — not-yet-finished (`notStarted` /
|
||||
/// `inProgress`). A `completed` / `skipped` run has dropped off the active set.
|
||||
static func activeIDs(in workouts: [WorkoutDocument]) -> Set<String> {
|
||||
@@ -38,7 +55,25 @@ enum SessionEndPlanner {
|
||||
.map(\.id))
|
||||
}
|
||||
|
||||
/// True when a session should be running but isn't — the watch self-starts one rather
|
||||
/// than depending on the phone's one-shot launch handoff having arrived.
|
||||
static func shouldStart(isRunning: Bool, workouts: [WorkoutDocument]) -> Bool {
|
||||
!isRunning && !activeIDs(in: workouts).isEmpty
|
||||
}
|
||||
|
||||
/// The run whose split configures a self-started session: the most recently started
|
||||
/// active one (ties broken by id for determinism).
|
||||
static func runToStart(in workouts: [WorkoutDocument]) -> WorkoutDocument? {
|
||||
let ids = activeIDs(in: workouts)
|
||||
return workouts
|
||||
.filter { ids.contains($0.id) }
|
||||
.max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start }
|
||||
}
|
||||
|
||||
/// `sessionAge` is seconds since the running session started (nil when unknown — e.g. a
|
||||
/// recovered session, which is by definition old enough to finish normally).
|
||||
static func decide(isRunning: Bool,
|
||||
sessionAge: TimeInterval?,
|
||||
previouslyActiveIDs: Set<String>,
|
||||
workouts: [WorkoutDocument]) -> SessionEndDecision {
|
||||
guard isRunning,
|
||||
@@ -46,19 +81,29 @@ enum SessionEndPlanner {
|
||||
!previouslyActiveIDs.isEmpty else { return .none }
|
||||
// A previously-active run that survived as `.completed` is a finish (save + forward
|
||||
// metrics); if none survived, every active run vanished (discarded/deleted) → discard.
|
||||
// Among several completed survivors (parallel runs), take the most recently started —
|
||||
// deterministic, and the one whose lifetime best matches the session's.
|
||||
let finished = previouslyActiveIDs
|
||||
.compactMap { id in workouts.first { $0.id == id } }
|
||||
.first { WorkoutStatus(rawValue: $0.status) == .completed }
|
||||
return finished.map(SessionEndDecision.finish) ?? .discard
|
||||
.filter { WorkoutStatus(rawValue: $0.status) == .completed }
|
||||
.max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start }
|
||||
guard let finished else { return .discard }
|
||||
// Too young to be a real finish — see `minimumFinishAge`.
|
||||
if let sessionAge, sessionAge < minimumFinishAge { return .discard }
|
||||
return .finish(finished)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single owner of watch workout-session termination. Long-lived (held by
|
||||
/// `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate callback —
|
||||
/// with or without a mounted view, including in the session's own background runtime. This is
|
||||
/// the fix for the leak where ending a run *from the phone* while the watch app was
|
||||
/// backgrounded left the `HKWorkoutSession` running (watchOS then re-foregrounded the app on
|
||||
/// every wrist raise) and skipped the metrics capture entirely.
|
||||
/// The single owner of the watch's workout-session lifecycle — both ends of it. Long-lived
|
||||
/// (held by `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate
|
||||
/// callback — with or without a mounted view, including in the session's own background
|
||||
/// runtime. Termination here is the fix for the leak where ending a run *from the phone*
|
||||
/// while the watch app was backgrounded left the `HKWorkoutSession` running (watchOS then
|
||||
/// re-foregrounded the app on every wrist raise) and skipped the metrics capture entirely.
|
||||
/// Self-starting here is the counterpart fix: a session is started whenever the authoritative
|
||||
/// set holds an active run and none is running, so a dropped phone launch handoff (or a
|
||||
/// crash, or a run engaged manually on the wrist) can't leave a workout sessionless — no HR,
|
||||
/// no Health save, app suspending wrist-down.
|
||||
///
|
||||
/// Same move as the live-mirror's `repairFromDurable`, one layer down: drive the terminal
|
||||
/// action from the authoritative cache apply rather than a transient UI observer.
|
||||
@@ -91,16 +136,33 @@ final class WorkoutSessionCoordinator {
|
||||
.map(WorkoutDocument.init(from:))
|
||||
let decision = SessionEndPlanner.decide(
|
||||
isRunning: sessionManager.isRunning,
|
||||
sessionAge: sessionManager.sessionStartDate.map { -$0.timeIntervalSinceNow },
|
||||
previouslyActiveIDs: previouslyActiveIDs,
|
||||
workouts: workouts)
|
||||
previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts)
|
||||
switch decision {
|
||||
case .none: return
|
||||
case .none: startIfNeeded(workouts: workouts)
|
||||
case .discard: sessionManager.discard()
|
||||
case .finish(let doc): endAndForward(doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-start a session when an active run has none (see `SessionEndPlanner.shouldStart`).
|
||||
/// The activity type comes from the run's split so the saved Health workout is categorized
|
||||
/// the same way a phone-launched one would be; a run whose split is gone (deleted, or not
|
||||
/// yet synced) falls back to traditional strength training.
|
||||
private func startIfNeeded(workouts: [WorkoutDocument]) {
|
||||
guard SessionEndPlanner.shouldStart(isRunning: sessionManager.isRunning, workouts: workouts),
|
||||
let run = SessionEndPlanner.runToStart(in: workouts) else { return }
|
||||
let split = run.splitID.flatMap { id in
|
||||
(try? context.fetch(FetchDescriptor<Split>())).flatMap { $0.first { $0.id == id } }
|
||||
}
|
||||
let configuration = HKWorkoutConfiguration()
|
||||
configuration.activityType = split?.activityTypeEnum.hkActivityType ?? .traditionalStrengthTraining
|
||||
configuration.locationType = .indoor
|
||||
sessionManager.start(with: configuration)
|
||||
}
|
||||
|
||||
/// Finish the session (saving the `HKWorkout` to Health), attach the captured metrics, and
|
||||
/// forward the run back to the phone. Lifted from the old `ActiveWorkoutGateView.endSession`
|
||||
/// so the behavior is unchanged — only its trigger moved off the view.
|
||||
|
||||
Reference in New Issue
Block a user