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:
@@ -1,5 +1,7 @@
|
||||
**July 2026**
|
||||
|
||||
The watch workout view now closes reliably when you end a workout on your iPhone, instead of the app reappearing on every wrist raise.
|
||||
|
||||
Watch and iPhone set timers no longer drift apart when the watch sleeps through the end of a rest.
|
||||
|
||||
Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Plan — Fix the watch workout-session leak (reliable session end)
|
||||
|
||||
Fixes the bug behind "when the split ends, the watch app keeps coming back up even if I
|
||||
close it." Companion to [`WATCH-SYNC-SCENARIOS.md`](WATCH-SYNC-SCENARIOS.md) (scenarios
|
||||
S4 / S6 / S7 and the closing section).
|
||||
|
||||
## Problem
|
||||
|
||||
The watch's `HKWorkoutSession` grants the app persistent foreground runtime — a running
|
||||
session is *exactly* what makes watchOS keep re-foregrounding the app on every wrist
|
||||
raise. The session must be ended (`finishAndSave()` or `discard()`) when the run ends.
|
||||
|
||||
Today the **only** thing that ends it is `ActiveWorkoutGateView`'s
|
||||
`.onChange(of: activeWorkouts)` firing on a non-empty → empty transition. That requires a
|
||||
**mounted, foregrounded gate view** to witness the transition. When the run ends from the
|
||||
**phone** (S4 complete-last-exercise, S6 "Save Workout" / start-new-split, S7 discard) the
|
||||
watch app is typically backgrounded — kept alive only by the session — or gets torn down
|
||||
and rebuilt by watchOS with an already-empty list. The `onChange` never fires, the session
|
||||
stays `running`, and the app leaks. The watch-originated end (S5) works only because that
|
||||
view happens to be alive on the watch at that moment.
|
||||
|
||||
## Root cause
|
||||
|
||||
Ending the session is a **lifecycle/data concern implemented as a view-level side
|
||||
effect.** The trigger is bound to SwiftUI view lifetime instead of the authoritative data.
|
||||
|
||||
## Design principle (already validated in this codebase)
|
||||
|
||||
Commit `8cbe078` fixed the *same class* of fragility in the ephemeral live-run mirror by
|
||||
adding `repairFromDurable` — the run screen stops trusting the transient frame and
|
||||
**reconciles against the authoritative `WorkoutDocument`**. We apply the same move one
|
||||
layer down: drive `session.end()` from the authoritative cache apply, in a **long-lived
|
||||
coordinator** that survives view rebuilds and runs on the WatchConnectivity delegate
|
||||
callback (which fires with or without any mounted view, including in the
|
||||
session's background runtime).
|
||||
|
||||
No new wire message is required — the authoritative push already carries the signal (the
|
||||
run leaves the active set on completion, or is pruned entirely on discard). A dedicated
|
||||
durable "run ended" wire type was considered and rejected for now: it widens the protocol
|
||||
for no gain over reconciling the state we already push.
|
||||
|
||||
## The fix
|
||||
|
||||
### 1. New pure decision seam — `SessionEndPlanner`
|
||||
|
||||
Mirrors the existing `WatchCacheApplier` pattern: a pure, session-free function that is
|
||||
trivially unit-testable, with the impure glue kept thin around it.
|
||||
|
||||
```swift
|
||||
enum SessionEndDecision: Equatable {
|
||||
case none
|
||||
case discard
|
||||
case finish(WorkoutDocument) // save to Health, attach metrics, forward to phone
|
||||
}
|
||||
|
||||
enum SessionEndPlanner {
|
||||
static func activeIDs(in workouts: [WorkoutDocument]) -> Set<String> {
|
||||
Set(workouts
|
||||
.filter { [.notStarted, .inProgress].contains(WorkoutStatus(rawValue: $0.status)) }
|
||||
.map(\.id))
|
||||
}
|
||||
|
||||
/// `previouslyActiveIDs` is the active set captured at the *last* reconcile. The end
|
||||
/// only fires on a genuine non-empty → empty transition, which is what makes the
|
||||
/// launch race safe: at startup the session may be `running` before the run document
|
||||
/// has arrived, but `previouslyActiveIDs` is still empty, so we never discard a run
|
||||
/// we simply haven't heard about yet.
|
||||
static func decide(isRunning: Bool,
|
||||
previouslyActiveIDs: Set<String>,
|
||||
workouts: [WorkoutDocument]) -> SessionEndDecision {
|
||||
guard isRunning,
|
||||
activeIDs(in: workouts).isEmpty,
|
||||
!previouslyActiveIDs.isEmpty else { return .none }
|
||||
let finished = previouslyActiveIDs
|
||||
.compactMap { id in workouts.first { $0.id == id } }
|
||||
.first { WorkoutStatus(rawValue: $0.status) == .completed }
|
||||
return finished.map(SessionEndDecision.finish) ?? .discard
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. New `WorkoutSessionCoordinator` (watch, `@MainActor`, owned by `WatchAppServices`)
|
||||
|
||||
Holds the same `WorkoutSessionManager` instance the delegate starts, plus the cache. Its
|
||||
`reconcile()` is the single owner of session-end; `endAndForward` is lifted verbatim from
|
||||
today's `ActiveWorkoutGateView.endSession`.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class WorkoutSessionCoordinator {
|
||||
private let sessionManager: WorkoutSessionManager
|
||||
private let bridge: WatchConnectivityBridge // to forward captured metrics
|
||||
private let context: ModelContext
|
||||
private var previouslyActiveIDs: Set<String> = []
|
||||
private var isEnding = false // guards the async finish from re-entry
|
||||
|
||||
init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) { … }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The watch cache holds only active + recently-pruned runs, so the fetch-all-map in
|
||||
`reconcile()` is bounded and cheap.
|
||||
|
||||
### 3. Wiring
|
||||
|
||||
- **`WatchConnectivityBridge`** — add `var onWorkoutsChanged: (() -> Void)?` and fire it
|
||||
after every authoritative cache mutation, once the write is committed:
|
||||
- at the end of `applyState(...)` **on the success branch** (after `lastSyncDate = Date()`);
|
||||
- at the end of `update(workout:)` (after `try? context.save()`).
|
||||
These are the two places the watch's workout set changes: phone pushes (S4/S6/S7) and
|
||||
the watch's own optimistic edits (S5, including offline).
|
||||
- **`WatchAppServices`** — hold the coordinator; replace `activate()` with
|
||||
`bootstrap(sessionManager:)` that stores the passed-in manager, builds the coordinator,
|
||||
sets `bridge.onWorkoutsChanged = { [weak coordinator] in coordinator?.reconcile() }`
|
||||
(weak break for the retain cycle), calls `bridge.activate()`, then calls
|
||||
`coordinator.reconcile()` once to seed `previouslyActiveIDs` from whatever is already
|
||||
applied.
|
||||
- **`WorkoutsWatchApp`** — `.task { services.bootstrap(sessionManager: appDelegate.sessionManager) }`.
|
||||
The same `appDelegate.sessionManager` instance stays injected into the view tree via
|
||||
`.environment(...)` (the live HUD still reads it) — ownership is unchanged; the
|
||||
coordinator merely references it.
|
||||
- **`ActiveWorkoutGateView`** — delete `.onChange(of: activeWorkouts.map(\.id))`, delete
|
||||
`endSession(previouslyActive:)`, and drop the now-unused
|
||||
`@Environment(WorkoutSessionManager.self)`. Everything else (list, `requestSync`,
|
||||
`popIfNavigatedRunUnavailable`) is untouched. The session end now has exactly one owner.
|
||||
- **XcodeGen** — run `xcodegen generate` (new source file in the watch target; `project.yml`
|
||||
already globs `Workouts Watch App/`, so no `project.yml` edit).
|
||||
|
||||
### Fixed flow (contrast with S4/S6 in the scenarios doc)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PB as PhoneBridge
|
||||
participant WB as WatchBridge
|
||||
participant Coord as SessionCoordinator
|
||||
participant WS as Watch session
|
||||
|
||||
PB->>WB: appContext (run completed — leaves active set) / (run pruned — discard)
|
||||
WB->>WB: WatchCacheApplier.apply → context.save
|
||||
WB->>Coord: onWorkoutsChanged → reconcile()
|
||||
Coord->>Coord: decide(isRunning, previouslyActive, cache)
|
||||
alt was active, now empty, a completed run survives
|
||||
Coord->>WS: finishAndSave() → session.end() + save HKWorkout
|
||||
Coord->>WB: update(workout + metrics) → forward to phone
|
||||
else was active, now empty, run absent
|
||||
Coord->>WS: discard() → session.end()
|
||||
end
|
||||
Note over WB,Coord: runs on the delegate callback — no mounted view required
|
||||
```
|
||||
|
||||
## Why this covers every scenario
|
||||
|
||||
| Scenario | Trigger for `reconcile()` | Result |
|
||||
|---|---|---|
|
||||
| S1 launch race (session running before run doc lands) | first `applyState` | `previouslyActive` empty → **no** premature discard |
|
||||
| S4 phone completes last exercise (watch backgrounded) | `applyState` on the push | session ends — no view needed |
|
||||
| S6 phone "Save Workout" / start-new-split | `applyState` on the push | session ends |
|
||||
| S7 phone discards | `applyState` on the prune push | `discard()` |
|
||||
| S5 watch completes last exercise | local `update(workout:)` | ends immediately, **works offline** (no phone round-trip) |
|
||||
| Watch relaunched cold | `bootstrap` seed reconcile | `isRunning == false` → no-op |
|
||||
|
||||
## Testing
|
||||
|
||||
- **`SessionEndPlannerTests`** (new, `Workouts Watch AppTests`, pure — no HealthKit / no
|
||||
SwiftData), pinning the decision table:
|
||||
- launch race: `isRunning`, prev `[]`, workouts `[]` → `.none`
|
||||
- complete: prev `["A"]`, workouts `[A.completed]` → `.finish(A)`
|
||||
- discard: prev `["A"]`, workouts `[]` → `.discard`
|
||||
- partial: prev `["A","B"]`, workouts `[A.completed, B.inProgress]` → `.none`
|
||||
- not running: `isRunning == false` → `.none`
|
||||
- already ended: prev `[]`, workouts `[A.completed]` → `.none`
|
||||
- `activeIDs(in:)` classification
|
||||
- The coordinator itself stays a thin fetch-and-delegate shell (the same reason
|
||||
`WatchCacheApplier` is tested but `WatchConnectivityBridge` is not) — `WorkoutSessionManager`
|
||||
wraps `HKHealthStore`, which isn't unit-testable.
|
||||
- **Manual device verification** (the real proof — this is inherently a background /
|
||||
view-lifecycle bug):
|
||||
1. Start a split on the phone → watch launches, session runs.
|
||||
2. With the **watch screen off / app backgrounded**, tap **Save Workout** on the phone →
|
||||
the watch app must stop resurfacing on wrist raise; the workout must appear in Health.
|
||||
3. Repeat with **Discard** → app stops resurfacing; nothing saved to Health.
|
||||
4. Start a split, put the **phone in airplane mode**, finish on the watch → session ends
|
||||
locally; metrics forward once connectivity returns.
|
||||
5. Start a split and immediately background the watch before the doc syncs → confirm the
|
||||
session is **not** discarded (launch race).
|
||||
|
||||
## Out of scope (tracked separately)
|
||||
|
||||
- **Per-log merge in `ingestFromWatch`** — the timestamp-not-content intake gate
|
||||
(documented at `SyncEngine.ingestFromWatch`; memory item "H1 per-log merge"). Independent
|
||||
of session end.
|
||||
- **A durable "run ended" wire signal** — considered and deferred; the authoritative state
|
||||
push is a sufficient signal.
|
||||
- **System-initiated session ends** (`WorkoutSessionManager.workoutSession(_:didChangeTo:)`,
|
||||
the `.ended`/`.stopped` branch) only `clear()` local state — no `finishWorkout()` save, no
|
||||
metrics forwarded. This coordinator handles *app*-driven ends (the leak); a watchOS-driven
|
||||
end (rare — e.g. the OS reclaims the session) still drops the HKWorkout and metrics. Left
|
||||
as a known residual: there may be no run document to attach to at that moment, and the
|
||||
frequency is low.
|
||||
|
||||
## Status
|
||||
|
||||
Implemented (session-end coordinator + `SessionEndPlanner` + `SessionEndPlannerTests`);
|
||||
`ActiveWorkoutGateView`'s view-level trigger removed. The per-log merge and the residual
|
||||
above remain open.
|
||||
|
||||
## Rollout
|
||||
|
||||
Single watch-side change set, no schema or wire-format change, no phone-side edits. Ship
|
||||
behind normal build; verify on device with the checklist above; add a one-line CHANGELOG
|
||||
entry (end-user visible: "The watch workout view now closes reliably when you end a workout
|
||||
on your iPhone").
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts_Watch_App
|
||||
|
||||
/// Pins the pure session-end decision (`SessionEndPlanner.decide`) — the rule that ends the
|
||||
/// watch's `HKWorkoutSession` from the authoritative workout set instead of a view observer.
|
||||
/// The decisive properties: a running session ends only on a genuine non-empty → empty
|
||||
/// transition of the active set; a survivor that's `.completed` is a finish, otherwise a
|
||||
/// discard; and the launch race (session running before the run doc has synced) resolves to
|
||||
/// `.none` so a real run is never discarded before we've heard about it.
|
||||
struct SessionEndPlannerTests {
|
||||
private static let ts = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
private func workout(_ id: String, _ status: WorkoutStatus) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: id, splitID: nil, splitName: nil, start: Self.ts, end: nil,
|
||||
status: status.rawValue, createdAt: Self.ts, updatedAt: Self.ts,
|
||||
logs: [], metrics: nil)
|
||||
}
|
||||
|
||||
// MARK: - decide
|
||||
|
||||
@Test func launchRace_runningButNothingHeardYet_isNone() {
|
||||
// Session running before any run document has synced: previous active set empty →
|
||||
// never discard a run we simply don't know about yet.
|
||||
let d = SessionEndPlanner.decide(isRunning: true, previouslyActiveIDs: [], workouts: [])
|
||||
#expect(d == .none)
|
||||
}
|
||||
|
||||
@Test func completedSurvivor_isFinish() {
|
||||
let done = workout("A", .completed)
|
||||
let d = SessionEndPlanner.decide(
|
||||
isRunning: true, previouslyActiveIDs: ["A"], workouts: [done])
|
||||
#expect(d == .finish(done))
|
||||
}
|
||||
|
||||
@Test func vanishedRun_isDiscard() {
|
||||
// "A" was active, now absent entirely (discarded/deleted on the phone) → discard.
|
||||
let d = SessionEndPlanner.decide(
|
||||
isRunning: true, previouslyActiveIDs: ["A"], workouts: [])
|
||||
#expect(d == .discard)
|
||||
}
|
||||
|
||||
@Test func stillPartlyActive_isNone() {
|
||||
// One of two previously-active runs finished, the other is still in progress → the
|
||||
// active set isn't empty, so the session keeps running.
|
||||
let d = SessionEndPlanner.decide(
|
||||
isRunning: true,
|
||||
previouslyActiveIDs: ["A", "B"],
|
||||
workouts: [workout("A", .completed), workout("B", .inProgress)])
|
||||
#expect(d == .none)
|
||||
}
|
||||
|
||||
@Test func notRunning_isNone() {
|
||||
// A cold-relaunched watch with no session must never try to end one.
|
||||
let d = SessionEndPlanner.decide(
|
||||
isRunning: false, previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)])
|
||||
#expect(d == .none)
|
||||
}
|
||||
|
||||
@Test func alreadyEnded_isNone() {
|
||||
// Baseline already empty (we ended on a prior reconcile) — a redelivered completed
|
||||
// run must not re-trigger the end.
|
||||
let d = SessionEndPlanner.decide(
|
||||
isRunning: true, previouslyActiveIDs: [], workouts: [workout("A", .completed)])
|
||||
#expect(d == .none)
|
||||
}
|
||||
|
||||
@Test func skippedOnlySurvivor_isDiscard() {
|
||||
// A run that resolved entirely to `.skipped` (ended early, nothing completed) is not a
|
||||
// finish — there's no completed run to save, so the session data is discarded.
|
||||
let d = SessionEndPlanner.decide(
|
||||
isRunning: true, previouslyActiveIDs: ["A"], workouts: [workout("A", .skipped)])
|
||||
#expect(d == .discard)
|
||||
}
|
||||
|
||||
// MARK: - activeIDs
|
||||
|
||||
@Test func activeIDs_countsOnlyUnfinished() {
|
||||
let workouts = [
|
||||
workout("A", .notStarted),
|
||||
workout("B", .inProgress),
|
||||
workout("C", .completed),
|
||||
workout("D", .skipped),
|
||||
]
|
||||
#expect(SessionEndPlanner.activeIDs(in: workouts) == ["A", "B"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user