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:
2026-07-09 08:12:06 -04:00
parent 9ea5e54b15
commit 04523c875a
8 changed files with 480 additions and 39 deletions
@@ -19,6 +19,12 @@ final class WatchConnectivityBridge: NSObject {
/// Last time state was received from the phone (for a sync indicator).
private(set) var lastSyncDate: Date?
/// Fired after every authoritative cache mutation (a phone push applied, or the watch's
/// own optimistic `update(workout:)`), once the write is committed. The
/// `WorkoutSessionCoordinator` hangs off this to end the `HKWorkoutSession` from the
/// authoritative data rather than a view observer see `WorkoutSessionCoordinator`.
var onWorkoutsChanged: (() -> Void)?
/// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching
/// run (popping out of its progress view) and blocks re-entry, so the phone owns the
/// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID`
@@ -113,6 +119,7 @@ final class WatchConnectivityBridge: NSObject {
}
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
lastSyncDate = Date()
onWorkoutsChanged?()
}
func requestSync() {
@@ -128,6 +135,7 @@ final class WatchConnectivityBridge: NSObject {
func update(workout doc: WorkoutDocument) {
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
try? context.save()
onWorkoutsChanged?()
sendToPhone(doc)
}
@@ -18,7 +18,6 @@ import SwiftData
/// exercise list.
struct ActiveWorkoutGateView: View {
@Environment(WatchConnectivityBridge.self) private var bridge
@Environment(WorkoutSessionManager.self) private var sessionManager
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
@Query private var splits: [Split]
@@ -72,13 +71,6 @@ struct ActiveWorkoutGateView: View {
// Nothing to run yet pull fresh state in case the phone just started one.
if activeWorkouts.isEmpty { bridge.requestSync() }
}
.onChange(of: activeWorkouts.map(\.id)) { previouslyActive, nowActive in
// The active list just emptied the run either completed or was discarded.
// Route the HealthKit session accordingly.
if nowActive.isEmpty, !previouslyActive.isEmpty {
endSession(previouslyActive: previouslyActive)
}
}
// The phone just entered (or left) an editor if we're inside the now-locked run,
// pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run
// we're inside was pruned (discarded/deleted on the phone, or aged out of the push).
@@ -98,35 +90,6 @@ struct ActiveWorkoutGateView: View {
}
}
/// The active list emptied: decide whether the run completed (save it to Health,
/// attach the captured metrics, and forward them to the phone) or was discarded
/// (throw the session data away). A completed run stays in the cache as `.completed`;
/// a discarded one is tombstoned on the phone and pruned from the cache, so its
/// absence from `workouts` is the discard signal.
private func endSession(previouslyActive ids: [String]) {
let finished = ids
.compactMap { id in workouts.first { $0.id == id } }
.first { $0.status == .completed }
guard let finished else {
// Nothing survived as completed the run was discarded. Don't save it.
sessionManager.discard()
return
}
let doc = WorkoutDocument(from: finished)
Task {
guard var metrics = await sessionManager.finishAndSave() else { return }
metrics.totalVolume = WorkoutVolume.total(doc.logs)
var updated = doc
updated.metrics = metrics
updated.updatedAt = Date()
// Carry the captured metrics back to the phone (and thus iCloud) over the
// existing workout-update path.
bridge.update(workout: updated)
}
}
/// If the run we're currently navigated into is no longer available pruned from the
/// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked
/// because the phone took over editing it pop back to the gate.
+20 -1
View File
@@ -11,6 +11,10 @@ final class WatchAppServices {
let container: ModelContainer
let bridge: WatchConnectivityBridge
/// The single owner of workout-session termination. Built in `bootstrap` once we have the
/// delegate's session manager to reference; nil until then.
private var sessionCoordinator: WorkoutSessionCoordinator?
init() {
let container = WorkoutsModelContainer.make()
self.container = container
@@ -20,7 +24,22 @@ final class WatchAppServices {
#endif
}
func activate() {
/// Wire the session coordinator to the (delegate-owned) session manager, start the bridge,
/// and seed the coordinator's baseline from whatever state is already applied. The manager
/// is passed in rather than owned here so the delegate's `handle(_:)` and the view tree's
/// `.environment(...)` keep referencing the same instance.
func bootstrap(sessionManager: WorkoutSessionManager) {
let coordinator = WorkoutSessionCoordinator(
sessionManager: sessionManager,
bridge: bridge,
context: container.mainContext)
self.sessionCoordinator = coordinator
// weak break: the coordinator is held here; the bridge only borrows it.
bridge.onWorkoutsChanged = { [weak coordinator] in coordinator?.reconcile() }
bridge.activate()
// Seed `previouslyActiveIDs` from whatever the bridge already applied at launch, so the
// first real change is measured against a correct baseline (and the launch race a
// running session before the run doc arrives resolves to `.none`, never a discard).
coordinator.reconcile()
}
}
@@ -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)
}
}
}
+1 -1
View File
@@ -32,6 +32,6 @@ struct WorkoutsWatchApp: App {
.environment(services.bridge)
.environment(appDelegate.sessionManager)
.modelContainer(services.container)
.task { services.activate() }
.task { services.bootstrap(sessionManager: appDelegate.sessionManager) }
}
}