The decode-failure freeze (BULLETPROOFING.md L3) is production-reachable after all - a phone app can update days before the watch app auto-updates, and every push in between fails to decode, silently freezing the watch at its last good sync. The bridge now tracks schemaMismatch (set on a failed decode, cleared on the next good apply) and ActiveWorkoutGateView shows an "Update both apps to resume sync" banner while it's set. Also give requestSync an error handler that logs the dropped pull (L2; deliberately no retry - the activation/reachability edges re-pull), and sync DEVICE-COMMUNICATION.md to the post-bulletproofing reality (T1 as optimization, session recovery, degraded pushes, surfaced mismatch). Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
15 KiB
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 T1–T4, exchanges P1–P9 / W1–W4). 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-authoreddeletedLogIDstombstones 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), andWorkoutSessionManager.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):
- 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.
- 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. - 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
.finishdecided for a session younger thanminimumFinishAge(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/.stoppednow ends collection and finishes the builder's workout (saving theHKWorkoutto Health) instead of clearing everything. Residual: the salvaged workout isn't linked to a run document (healthKitWorkoutUUIDstays 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 · fixed (expiry) / open (split-lock scope)
Fix (2026-07-09):
PhoneConnectivityBridge.setLocksSuspended— the scene-phase hook publishes the locks as cleared while the app is backgrounded (without forgetting them locally) and re-asserts them on return to foreground, so a pocketed or force-quit phone can no longer park the watch's run indefinitely. The split-lock scope (viewingSplitDetailViewparks runs) was deliberately left as-is: that screen hosts inline edits (reorder, swipe-delete, add), so narrowing the lock to its sheets would reopen the clobber risk it exists to prevent.
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 · fixed
Fix (2026-07-09): both platforms'
ExerciseProgressViewnow observelog?.statusand dismiss when the log resolves to.completed/.skippedremotely (alocallyResolvedflag keeps their own Done / flow hand-off from re-dismissing) — a stale open flow can no longer write a resolved log back to in-progress. The watch also gained the phone'sstartsSkippedterminal page (SkippedPhaseView), so opening a skipped exercise shows a static badge instead of a live, resurrectable flow. Gate-level popping was deliberately not added: with the screen-level dismissal the resurrection path is closed, and reviewing a just-completed run's list is legitimate.
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 · fixed (freeze) / open (mid-set staleness)
Fix (2026-07-09): the terminal between-exercise rest now falls back to
dismiss()when there's noonAdvancehost (the live-mirror cover on both platforms) — the exercise still completes durably, the cover closes instead of freezing at 0:00, and the driver's next-exercise frame re-presents it. Residual: a cover sitting on a count-up work page when the driver dies still has no staleness timeout — acceptable because that surface is a take-over driver by design and dismissible by hand.
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 · fixed
Fix (2026-07-09):
pushAllnow degrades on failure instead of freezing the watch: a failed push retries with the recently-completed tail dropped (display-only on the watch — active runs, splits, settings, and locks all still go through), logged as a warning; only a failure of the slim push too remains an error. Deeper tiers (trimming embedded logs) weren't needed — active runs are a handful by construction.
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 · fixed
SessionEndPlanner.decide picked the completed survivor by iterating previouslyActiveIDs — a Set, so iteration order was undefined. With two parallel runs finishing together, the HKWorkout's metrics attached to an arbitrary one.
Fix (2026-07-09):
decidenow picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned byparallelCompletions_pickMostRecentlyStarted).
L2 — requestSync failures vanish · fixed (logged)
sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil) — a failed pull was dropped invisibly.
Fix (2026-07-09): the send now logs its error. Deliberately no retry — the activation/reachability edges re-pull and the durable T2 slot delivers regardless.
L3 — Decode-failure freeze is silent on the watch · fixed
The schema-mismatch trap (see DEVICE-COMMUNICATION.md §6) is production-reachable after all: a user's phone app can update days before the watch app auto-updates, and every push in between fails to decode — silently.
Fix (2026-07-09):
WatchConnectivityBridge.schemaMismatchis set when a push fails to decode (cleared on the next good apply), andActiveWorkoutGateViewshows an "Update both apps to resume sync" banner while it's set — the freeze is now labeled instead of silent.
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.
Recommended order
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:
- H1 — watch self-starts + recovers its session (one coherent change in
WorkoutSessionManager/ coordinator / run-flow entry; collapses most of H2, part of M-tier). - H2 — pending-config replay and/or post-start grace window in
SessionEndPlanner. - H3 — salvage the Health save on system-ended sessions.
- M1 — lock expiry on scene background; narrow the split lock.
- M4 — tiered payload degradation + Diagnostics surfacing.
- Then M2, M3, and the L-tier as opportunity allows.