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:
2026-07-09 22:57:26 -04:00
parent e6cea299dc
commit abb223daca
6 changed files with 254 additions and 37 deletions
@@ -25,6 +25,9 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate {
if ScreenshotSeed.isActive { return }
#endif
sessionManager.requestAuthorization()
// Re-adopt a session that outlived a crash or reboot watchOS relaunches us
// expecting this call; without it the session is orphaned and its workout lost.
sessionManager.recover()
}
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
@@ -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.
+59 -3
View File
@@ -28,6 +28,11 @@ final class WorkoutSessionManager: NSObject {
private(set) var isRunning = false
/// When the running session started, for the coordinator's session-age rule (a
/// too-young `.finish` demotes to `.discard`). `distantPast` for a recovered
/// session it predates this process, so it's never "young".
private(set) var sessionStartDate: Date?
/// Live values for the in-workout HUD. Nil until the first sample arrives (or if
/// the corresponding read authorization was denied).
private(set) var currentHeartRate: Double?
@@ -74,12 +79,45 @@ final class WorkoutSessionManager: NSObject {
let startDate = Date()
session.startActivity(with: startDate)
Task { try? await builder.beginCollection(at: startDate) }
sessionStartDate = startDate
isRunning = true
} catch {
clear()
}
}
/// Re-adopt a session that outlived a crash or reboot. watchOS keeps the
/// `HKWorkoutSession` alive across an app death and relaunches us expecting a
/// recovery call; without it the session is orphaned its `HKWorkout` never saved
/// and the live HUD dead. Called once at launch; a no-op when there's nothing to
/// recover or a fresh start already claimed the slot.
func recover() {
guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
Task {
guard let recovered = try? await healthStore.recoverActiveWorkoutSession(),
self.session == nil else { return }
adopt(recovered)
}
}
private func adopt(_ recovered: HKWorkoutSession) {
let builder = recovered.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore, workoutConfiguration: recovered.workoutConfiguration)
recovered.delegate = self
builder.delegate = self
self.session = recovered
self.builder = builder
// Zone seconds accumulated before the crash are gone (they lived in memory);
// resume accumulation from here rather than faking the missing stretch.
resetMetricAccumulators()
maxHeartRate = computeMaxHeartRate()
sessionStartDate = .distantPast
isRunning = true
}
/// Finish the session and save it to Health, returning the captured metrics so the
/// caller can attach them to the workout and forward to the phone. `totalVolume`
/// is left nil here the caller fills it from the workout's logs.
@@ -178,11 +216,26 @@ final class WorkoutSessionManager: NSObject {
maxHeartRate = nil
}
/// The system ended the session out from under us (resource reclaim, watchOS policy).
/// Salvage what the builder collected into a real Health save instead of dropping it
/// the run documents stay active, so no metrics are forwarded to the phone (there is no
/// completed run to attach them to); the `HKWorkout` itself is what must not be lost.
private func salvageSystemEndedSession(at endDate: Date) {
guard let builder, !isFinishing else { clear(); return }
isFinishing = true
Task {
defer { clear() }
try? await builder.endCollection(at: endDate)
_ = try? await builder.finishWorkout()
}
}
private func clear() {
session = nil
builder = nil
isRunning = false
isFinishing = false
sessionStartDate = nil
resetMetricAccumulators()
}
}
@@ -197,9 +250,12 @@ extension WorkoutSessionManager: HKWorkoutSessionDelegate {
date: Date
) {
guard toState == .ended || toState == .stopped else { return }
// Only reset if we didn't drive the end ourselves (finishAndSave/discard set
// `isFinishing` and clear on their own); this catches system-ended sessions.
Task { @MainActor in if !self.isFinishing { self.clear() } }
// Only act if we didn't drive the end ourselves (finishAndSave/discard set
// `isFinishing` and clear on their own). A system-ended session is salvaged
// into a Health save rather than silently dropped.
Task { @MainActor in
if !self.isFinishing { self.salvageSystemEndedSession(at: date) }
}
}
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {