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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user