Files
workouts/BULLETPROOFING.md
T
rzen abb223daca 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
2026-07-09 22:57:26 -04:00

12 KiB
Raw Blame History

Bulletproofing the Watch/Phone UX — Findings

An adversarial gap analysis of the iPhone ↔ Apple Watch communication layer, aimed at making the cross-device workout UX bulletproof. Architecture reference: DEVICE-COMMUNICATION.md (transports T1T4, exchanges P1P9 / W1W4). Analysis date: 2026-07-09.

Findings are ranked by user impact. Each carries code evidence, the concrete failure scenario, and a fix direction. Statuses: open / fixed (update in place as items land).


What's already solid (verified)

Re-verified against code, not just prior docs — these need no work and should not be re-litigated:

  • Per-log merge (WorkoutMergePlanner) — unordered T3/T4 delivery and concurrent edits to different exercises commute; phone-authored deletedLogIDs tombstones disambiguate absent logs.
  • Tombstone veto (SyncEngine.ingestFromWatch) — a stale watch push can never resurrect a deleted workout; authoritative state is re-pushed instead.
  • Latest-wins context + authoritative prune (WatchCacheApplier) — "absent ⇒ no longer active" reliably ends the watch session on phone-side delete/discard, and a nil-decode can never wipe real rows.
  • Launch-race guard (SessionEndPlanner.decide: !previouslyActiveIDs.isEmpty) — a freshly launched session is never killed just because its run document hasn't synced yet (empty-baseline case).
  • Timer hardening — countdown/haptic logic is wall-clock anchored on both platforms; sleep and Always-On drift self-correct without double-firing.
  • Pop-on-prune (ActiveWorkoutGateView.popIfNavigatedRunUnavailable) — deletion/discard of the navigated run, or a phone edit lock, pops the watch back to the gate.

HIGH — the session lifecycle has a single point of failure

H1 — The HKWorkoutSession can only ever be born via the T1 one-shot · fixed

Fix (2026-07-09): the coordinator now self-starts a session on any reconcile that finds an active run with none running (SessionEndPlanner.shouldStart / runToStart; activity type resolved from the run's split), and WorkoutSessionManager.recover() re-adopts a crash/reboot-orphaned session at launch (recoverActiveWorkoutSession). T1 is now a fast-launch optimization, not the sole session source.

WorkoutSessionManager.start(with:) has exactly one caller: WatchAppDelegate.handle(_:) (WatchAppDelegate.swift:31), i.e. the phone's startWatchApp(toHandle:) handoff. That handoff is best-effort try? with no retry (WorkoutLauncher.swift:37-40). There is no HKHealthStore.recoverActiveWorkoutSession anywhere, no WKExtendedRuntimeSession, and nothing gates run-flow entry on sessionManager.isRunning.

Failure scenarios (all real):

  1. T1 fails silently (watch briefly unreachable, Bluetooth blip, watch on charger at start) → the entire workout runs sessionless: no HR/energy, no Health save, no wrist-raise resurfacing, and the watch app fully suspends wrist-down — rest haptics and done-countdowns don't fire until the user manually reopens the app. The user gets no hint anything is wrong.
  2. Watch app crash or reboot mid-run → watchOS expects the app to reclaim the live session via recoverActiveWorkoutSession; unimplemented, so the session is orphaned — metrics lost, zombie session may linger.
  3. User manually opens an active run on the watch (run arrived via T2; T1 never fired for this device state) → same sessionless degradation.

Fix direction: make the watch own its session lifecycle. Self-start a session when entering (or reconciling into) an active run with isRunning == false; implement session recovery at launch. T1 then becomes a fast-launch optimization rather than the sole source of sessions. This also absorbs most of H2 and much of the M-tier degradation.

H2 — "End Current & Start New" swallows the new run's session · fixed

Fix (2026-07-09): absorbed by H1's self-start (a swallowed T1 heals at the next reconcile), plus a session-age rule in the planner: a .finish decided for a session younger than minimumFinishAge (30 s) demotes to .discard, so the transient "old run completed, new run not yet pushed" context can no longer save a junk seconds-long HKWorkout attributed to the old run — the session is discarded and self-start brings it back when the new run's push lands.

endActiveThenStart (WorkoutLogsView.swift:271-281) runs the end-saves in a Task but calls start(with:) synchronously — so T1 goes out while the old run's session is still running on the watch. handle(_:)start(with:) hits the session == nil idempotency guard (WorkoutSessionManager.swift:61) and is silently dropped. Moments later the "A completed" push arrives and the coordinator finishes A's session — leaving run B sessionless (all of H1's fallout). The one-shot was consumed while blocked; nothing replays it.

Timing variant: if B's session does start (old one already ended) but the transient "A completed, B not yet present" push applies next, SessionEndPlanner.decide sees isRunning, empty active set, non-empty baseline {A}.finish(A)ending B's seconds-old session and attributing its metrics to A. The empty-baseline launch guard doesn't cover a non-empty stale baseline.

Fix direction: on the watch, when handle(_:) arrives while a session is running, stash the configuration and start it immediately after the current session finishes; and/or a short grace window after session.startActivity(...) during which decide refuses .finish/.discard. H1's self-start largely absorbs this too.

H3 — A system-ended session silently drops the Health save · fixed

Fix (2026-07-09): salvageSystemEndedSession — a system-driven .ended/.stopped now ends collection and finishes the builder's workout (saving the HKWorkout to Health) instead of clearing everything. Residual: the salvaged workout isn't linked to a run document (healthKitWorkoutUUID stays nil) and no metrics are forwarded — the runs are still active, so there's no completed run to attach them to.

workoutSession(_:didChangeTo:) for .ended/.stopped only calls clear() (WorkoutSessionManager.swift:199-202) — no endCollection/finishWorkout, no metrics capture, no forward to the phone. PLAN-watch-session-end.md lists this as a known residual. Rare (OS reclaim under resource pressure), but it is a "my workout never made it to Health" support report waiting to happen.

Fix direction: on a system-driven .ended, attempt finishAndSave() (or at minimum salvage the builder's collected data) before clearing, and forward whatever metrics exist.


MEDIUM — stranding and stale-UI traps

M1 — Edit locks have no expiry and outlive their editor · open

editingWorkoutID / editingSplitID ride in the latest-wins context and are cleared only by onDisappear (ExerciseView.swift:78, SplitDetailView.swift:55). Force-quit the phone with an editor open — or just leave it open in a pocket — and the last-pushed context says "editing" indefinitely; the watch parks the run ("Editing on iPhone…") until the phone app next runs pushAll. Compounding it, SplitDetailView is a mostly-read screen: merely viewing a split in Settings parks any active watch run sourced from it (SplitDetailView.swift:54).

Fix direction: clear the locks on phone scenePhase → background/inactive; scope the split lock to the actual edit sheets rather than the detail screen.

M2 — Remote completion doesn't pop the watch's open exercise screen · open

popIfNavigatedRunUnavailable pops on prune and on lock — but a run flipping to .completed/.skipped remotely stays in the cache (recently-completed runs are still pushed for ~24 h), so no pop fires. repairFromDurable's safety net is gated on status == .inProgress, going silent exactly when needed. And the watch's ExerciseProgressView lacks the phone's .skipped handling, so a user sitting on a stale open exercise can keep ticking and write it back to a non-terminal status — resurrecting a skip through the per-log merge, since their edit is "newer".

Fix direction: pop (or overlay a terminal state) when the navigated run leaves the active set, not only when it leaves the cache; port the phone's .skipped terminal handling to the watch's progress view.

M3 — Follower mirror cover freezes in auto-advance splits · open

The live-mirror cover (LiveRunCoverView, both platforms) is not a passive display — it runs the same local phase engine as a real driver, seeded from the last frame's anchors, and keeps advancing if the driver goes silent. Single-exercise runs self-heal (Finish auto-Done dismisses). But the cover is wired with onAdvance: nil, so in a flow-mode (auto-advance) split, when its local chain reaches the terminal between-exercise rest it completes the exercise (durable data stays correct) then freezes at a 0:00 countdown — the exercise hand-off exists only in RunFlowView, which the cover doesn't use. Related: if the driver dies without sendLiveEnded, the follower has no staleness timeout at all.

Fix direction: give the cover a terminal-rest hand-off (advance or dismiss), and add a staleness timeout that dismisses the cover when no frame arrives past phaseEnd + grace.

M4 — updateApplicationContext size ceiling; failure is log-only · open

pushAll sends all splits plus all active and ≤25 recent-completed runs with their full logs. A heavy user (many splits × many exercises; long runs with setEntries) can plausibly exceed WatchConnectivity's ~65 KB context limit. On throw, the error is logged (PhoneConnectivityBridge.swift:121-124) and the watch silently never hears about anything again — functionally the same total-freeze as the schema-mismatch trap, but reachable in production.

Fix direction: on payload-too-large, degrade in tiers (drop completed runs, then trim embedded logs to what the watch renders) and re-push; surface the condition in Diagnostics.


LOW

L1 — Nondeterministic metrics attribution with parallel runs · open

SessionEndPlanner.decide picks the completed survivor by iterating previouslyActiveIDs — a Set, so iteration order is undefined (WorkoutSessionCoordinator.swift:49-51). With two parallel runs finishing together, the HKWorkout's metrics attach to an arbitrary one. Deterministic tie-break (e.g. most-recent end) would fix it.

L2 — requestSync failures vanish · open

sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil) (WatchConnectivityBridge.swift) — a failed pull is dropped with no retry. Low because P8 (activation/reachability edges) and the durable T2 slot cover the common cases.

L3 — Decode-failure freeze is silent on the watch · open

The schema-mismatch trap (see DEVICE-COMMUNICATION.md §6) is dev-only today, but the watch could surface "can't read phone data — update both apps" instead of freezing silently; it already tracks lastSyncDate and logs the decode failure.

L4 — Possible log-progress rollback from a late follower write · open · needs device testing

Both the driver and a follower cover persist auto-advance progress via whole-log newest-updatedAt-wins merge (not a monotonic max on progress). A follower resuming late from suspend could in theory regress a log if its delayed write's timestamp outranks the driver's more-advanced state before the durable echo catches it up. Unconfirmed on device.


The common theme of every HIGH: the watch doesn't own its session lifecycle — it's a passive recipient of a fire-and-forget launch. Fix in this order:

  1. H1 — watch self-starts + recovers its session (one coherent change in WorkoutSessionManager / coordinator / run-flow entry; collapses most of H2, part of M-tier).
  2. H2 — pending-config replay and/or post-start grace window in SessionEndPlanner.
  3. H3 — salvage the Health save on system-ended sessions.
  4. M1 — lock expiry on scene background; narrow the split lock.
  5. M4 — tiered payload degradation + Diagnostics surfacing.
  6. Then M2, M3, and the L-tier as opportunity allows.