End the watch workout session from durable state, not a view
The HKWorkoutSession that keeps the watch app foregrounded was ended only by ActiveWorkoutGateView's onChange(of: activeWorkouts) — a view-level side effect. When a run ended from the phone while the watch app was backgrounded (kept alive only by the session) or torn down and rebuilt with an already-empty list, that onChange never fired: the session leaked (the app kept re-foregrounding on every wrist raise) and finishAndSave() never ran, so the HR/energy summary was neither saved to Health nor forwarded. Move session-end off the view into a long-lived WorkoutSessionCoordinator owned by WatchAppServices, driven by a new bridge.onWorkoutsChanged callback fired after every authoritative cache mutation (phone push or the watch's own optimistic edit). The decision is a pure SessionEndPlanner seam (mirrors WatchCacheApplier): a running session ends only on a genuine non-empty -> empty transition of the active set, so the launch race (session running before the run doc syncs) resolves to .none and never discards a run we haven't heard about yet. Same move as the live-mirror's repairFromDurable, one layer down. Watch-only; no schema or wire change. SessionEndPlannerTests pins the decision table; the OS-initiated-end path (system ends the session itself) stays a documented residual in PLAN-watch-session-end.md.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// WorkoutSessionCoordinator.swift
|
||||
// Workouts Watch App
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// What the watch should do with its `HKWorkoutSession` given the latest authoritative
|
||||
/// workout set. `finish` carries the completed run whose metrics we save + forward.
|
||||
enum SessionEndDecision: Equatable {
|
||||
case none
|
||||
case discard
|
||||
case finish(WorkoutDocument)
|
||||
}
|
||||
|
||||
/// Pure decision seam for ending the watch's workout session — 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.
|
||||
///
|
||||
/// 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
|
||||
/// 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.
|
||||
enum SessionEndPlanner {
|
||||
/// 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> {
|
||||
Set(workouts
|
||||
.filter {
|
||||
let status = WorkoutStatus(rawValue: $0.status)
|
||||
return status == .notStarted || status == .inProgress
|
||||
}
|
||||
.map(\.id))
|
||||
}
|
||||
|
||||
static func decide(isRunning: Bool,
|
||||
previouslyActiveIDs: Set<String>,
|
||||
workouts: [WorkoutDocument]) -> SessionEndDecision {
|
||||
guard isRunning,
|
||||
activeIDs(in: workouts).isEmpty,
|
||||
!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.
|
||||
let finished = previouslyActiveIDs
|
||||
.compactMap { id in workouts.first { $0.id == id } }
|
||||
.first { WorkoutStatus(rawValue: $0.status) == .completed }
|
||||
return finished.map(SessionEndDecision.finish) ?? .discard
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
@MainActor
|
||||
final class WorkoutSessionCoordinator {
|
||||
private let sessionManager: WorkoutSessionManager
|
||||
private let bridge: WatchConnectivityBridge
|
||||
private let context: ModelContext
|
||||
|
||||
/// The active set captured at the previous reconcile — the baseline the non-empty → empty
|
||||
/// transition is measured against (see `SessionEndPlanner`).
|
||||
private var previouslyActiveIDs: Set<String> = []
|
||||
|
||||
/// Guards the async finish so a burst of reconciles can't double-end the session.
|
||||
private var isEnding = false
|
||||
|
||||
init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) {
|
||||
self.sessionManager = sessionManager
|
||||
self.bridge = bridge
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Re-evaluate the session against the current authoritative cache. Called after every
|
||||
/// cache mutation (phone push or the watch's own optimistic edit) and once at bootstrap to
|
||||
/// seed the baseline. The watch cache holds only active + recently-pruned runs, so the
|
||||
/// fetch-and-map is bounded and cheap.
|
||||
func reconcile() {
|
||||
guard !isEnding else { return }
|
||||
let workouts = ((try? context.fetch(FetchDescriptor<Workout>())) ?? [])
|
||||
.map(WorkoutDocument.init(from:))
|
||||
let decision = SessionEndPlanner.decide(
|
||||
isRunning: sessionManager.isRunning,
|
||||
previouslyActiveIDs: previouslyActiveIDs,
|
||||
workouts: workouts)
|
||||
previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts)
|
||||
switch decision {
|
||||
case .none: return
|
||||
case .discard: sessionManager.discard()
|
||||
case .finish(let doc): endAndForward(doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private func endAndForward(_ doc: WorkoutDocument) {
|
||||
isEnding = true
|
||||
Task {
|
||||
defer { isEnding = false }
|
||||
guard var metrics = await sessionManager.finishAndSave() else { return }
|
||||
metrics.totalVolume = WorkoutVolume.total(doc.logs)
|
||||
var updated = doc
|
||||
updated.metrics = metrics
|
||||
updated.updatedAt = Date()
|
||||
bridge.update(workout: updated) // optimistic upsert + forward (queued if offline)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user