From ccd5194c6c6543328d0b2c38be34cebd70ce4d2f Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 9 Jul 2026 23:12:39 -0400 Subject: [PATCH] Surface phone/watch schema mismatch on the watch; log dropped pulls 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 --- BULLETPROOFING.md | 12 ++++++++---- CHANGELOG.md | 2 ++ DEVICE-COMMUNICATION.md | 8 ++++---- .../Connectivity/WatchConnectivityBridge.swift | 14 +++++++++++++- .../Views/ActiveWorkoutGateView.swift | 14 ++++++++++++++ 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/BULLETPROOFING.md b/BULLETPROOFING.md index e64868a..edaedd9 100644 --- a/BULLETPROOFING.md +++ b/BULLETPROOFING.md @@ -99,13 +99,17 @@ The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive disp > **Fix (2026-07-09):** `decide` now picks the most recently started completed survivor, ties broken by id (landed with the H-tier session-lifecycle commit; pinned by `parallelCompletions_pickMostRecentlyStarted`). -### L2 — `requestSync` failures vanish · open +### L2 — `requestSync` failures vanish · **fixed** (logged) -`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. +`sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)` — a failed pull was dropped invisibly. -### L3 — Decode-failure freeze is silent on the watch · open +> **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. -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. +### 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.schemaMismatch` is set when a push fails to decode (cleared on the next good apply), and `ActiveWorkoutGateView` shows 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a97f6fa..ef7d30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Opening a skipped exercise on the watch now shows a gray Skipped badge instead o The mirrored workout view no longer freezes on a spent rest countdown when the other device drives an auto-advancing split. +The watch now shows an update notice when the two apps are on different versions and can't sync, instead of silently displaying stale workouts. + A new Cardio exercise and matching starter split let you log aerobic sessions, tracked on your Apple Watch as a cardio workout. A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them. diff --git a/DEVICE-COMMUNICATION.md b/DEVICE-COMMUNICATION.md index c108c7b..c397eda 100644 --- a/DEVICE-COMMUNICATION.md +++ b/DEVICE-COMMUNICATION.md @@ -77,7 +77,7 @@ Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Document | Event / trigger | Datapoints on the wire | Transport & path | Internal structures updated | Notes | |---|---|---|---|---| -| **P1 — Start a workout** (the split picker is the sole start path — `SplitPickerSheet.start(with:)`, `WorkoutLogsView.swift:283-312`) | `HKWorkoutConfiguration { activityType (from the split), locationType = .indoor }` | **T1** (HealthKit, direct — *not* WatchConnectivity) | **Watch:** `WatchAppDelegate.handle(_:)` → `WorkoutSessionManager.start(with:)` creates `HKWorkoutSession` + `HKLiveWorkoutBuilder`, `session.startActivity`, `builder.beginCollection`, `isRunning = true`. **Phone:** requests workout-share auth inline (`WorkoutLauncher.swift:38`). | Best-effort; no-ops without HealthKit or a reachable watch. The running `HKWorkoutSession` is what makes watchOS re-foreground the app on every wrist raise. Starting also mints a `WorkoutDocument` → triggers **P3** as a separate exchange. | +| **P1 — Start a workout** (the split picker is the sole start path — `SplitPickerSheet.start(with:)`, `WorkoutLogsView.swift:283-312`) | `HKWorkoutConfiguration { activityType (from the split), locationType = .indoor }` | **T1** (HealthKit, direct — *not* WatchConnectivity) | **Watch:** `WatchAppDelegate.handle(_:)` → `WorkoutSessionManager.start(with:)` creates `HKWorkoutSession` + `HKLiveWorkoutBuilder`, `session.startActivity`, `builder.beginCollection`, `isRunning = true`. **Phone:** requests workout-share auth inline (`WorkoutLauncher.swift:38`). | Best-effort; no-ops without HealthKit or a reachable watch. The running `HKWorkoutSession` is what makes watchOS re-foreground the app on every wrist raise. Starting also mints a `WorkoutDocument` → triggers **P3** as a separate exchange. T1 is a *fast-launch optimization*, not the only session source: the watch self-starts a session from any reconcile that finds an active run with none running, and recovers a crash-orphaned one at launch (BULLETPROOFING.md H1). | | **P3 — Any phone cache mutation → full-state push** | `splits[]` (all `SplitDocument`, by `order`; each embeds its `ExerciseDocument`s) · `workouts[]` (every active [`notStarted`/`inProgress`] run **in full**, + ≤25 `completed` within 24 h; each embeds its `WorkoutLogDocument`s) · `restSeconds` · `doneCountdownSeconds` · `weightUnit` · `editingWorkoutID?` · `editingSplitID?` | **T2** (`pushAll` → `updateApplicationContext`), phone → watch | **Watch:** `WatchCacheApplier.apply` upserts every split/workout via `CacheMapper` **and prunes anything absent** → SwiftData cache; sets `lastSyncDate`; writes settings to `UserDefaults`; sets edit-lock state; fires `onWorkoutsChanged` → `WorkoutSessionCoordinator.reconcile()` (may end the `HKWorkoutSession`). `@Query` views refresh. | The workhorse. `onCacheChanged` fires on **every** cache change — local saves/deletes *and* remote iCloud deltas from `MetadataObserver` and `ingestFromWatch` (`SyncEngine.swift:290,301,307,323,378,465,725`). Active runs sent uncapped, so **"absent ⇒ no longer active"** — this is what prunes a discarded/deleted run and lets the coordinator end the session. Decode is **all-or-nothing**: one undecodable element → `nil` → `applyState` skips upsert/prune/reconcile entirely (schema-mismatch stale-cache trap, §5). Guarded on `activated && isPaired && isWatchAppInstalled`. | | **P4 — Phone opens/closes an exercise editor** (`ExerciseView.swift:75,78`) | Same context as P3; salient field is `editingWorkoutID` (the workout's id, or absent = cleared) | **T2** (`setEditingWorkout` → immediate `pushAll`) | **Watch:** `editingWorkoutID` set; `ActiveWorkoutGateView` dims that run's row, blocks entry, and pops out if the user is inside it (`isLockedForEditing`). | Exclusive-edit lock: only one device drives a given run at a time, so the watch can't forward a stale optimistic write over a phone edit. Pushed immediately (doesn't wait for a cache change). | | **P5 — Phone opens/closes a split detail/editor** (`SplitDetailView.swift:54,55`) | Same context; salient field is `editingSplitID` | **T2** (`setEditingSplit` → immediate `pushAll`) | **Watch:** `editingSplitID` set; any run whose `splitID` matches is parked (same UI treatment as P4). | Matches runs by source split rather than by run id. | @@ -104,7 +104,7 @@ Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Document | Structure | Type | Fed by | Notes | |---|---|---|---| | SwiftData cache (`Split`, `Workout`, `WorkoutLog`) | Rebuildable local cache | P3 (authoritative upsert **+ prune**) and W1 (optimistic self-edit) | The watch never originates a split/workout; pruning can't lose local-only data. This is the set `ActiveWorkoutGateView` renders. | -| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1; ended by W2 | `isRunning == true` is what makes watchOS re-foreground the app on wrist raise. Collects HR + active energy, accumulates `hrZoneSeconds`. | +| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1 **or self-started by the coordinator** (active run with no session); recovered at launch after a crash/reboot; ended by W2 (or salvaged into a Health save on a system-driven end) | `isRunning == true` is what makes watchOS re-foreground the app on wrist raise. Collects HR + active energy, accumulates `hrZoneSeconds`. | | `WorkoutSessionCoordinator.previouslyActiveIDs` | Baseline set | Recomputed every `reconcile()` (each P3/W1 mutation + bootstrap) | The non-empty→empty transition of the active set is what ends the session. Empty baseline at launch is what makes the launch race safe. | | `UserDefaults` (`restSeconds`, `doneCountdownSeconds`, `weightUnit`) | Settings | P3/P6 | Drives the watch's rest timer, done countdown, weight units. | | Edit locks (`editingWorkoutID`, `editingSplitID`) | Published lock | P4/P5 | Park a run and block re-entry so the phone owns the edit. | @@ -150,8 +150,8 @@ Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Document ## 6. Failure modes & gotchas -- **Schema-mismatch stale cache (the big one).** If the installed watch binary is older than the phone's, it can't decode the phone's `WorkoutDocument`/`SplitDocument` payloads. `WCPayload.decodeWorkouts` is all-or-nothing → `nil` → `applyState` **skips upsert, prune, and reconcile** (`WatchConnectivityBridge.swift`). The watch's cache freezes at the last successful decode; a running `HKWorkoutSession` can then never be ended (its terminating signal — active set → empty — can't arrive), so the app re-surfaces indefinitely. **Recovery:** rebuild/reinstall the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3). Cannot happen to end users — both apps ship at one version, and the `VersionedDocument` forward-gate covers files. -- **`updateApplicationContext` is single-slot.** Only the *latest* state is delivered; intermediate pushes coalesce. Fine because the context is authoritative full state, not a delta. +- **Schema-mismatch stale cache.** If the installed watch binary is older than the phone's, it can't decode the phone's `WorkoutDocument`/`SplitDocument` payloads. `WCPayload.decodeWorkouts` is all-or-nothing → `nil` → `applyState` **skips upsert, prune, and reconcile** (`WatchConnectivityBridge.swift`) — the watch's cache freezes at the last successful decode. This *is* production-reachable (the phone app can update before the watch app auto-updates), so the state is surfaced: `schemaMismatch` drives an "Update both apps to resume sync" banner in `ActiveWorkoutGateView`. **Recovery:** update/rebuild the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3); the `VersionedDocument` forward-gate covers files. +- **`updateApplicationContext` is single-slot.** Only the *latest* state is delivered; intermediate pushes coalesce. Fine because the context is authoritative full state, not a delta. A push that fails outright (realistically payload-too-large) retries with the recently-completed tail dropped, so an oversized context degrades instead of freezing the watch. - **T3 is reachable-only.** Live frames (P7/W3) and `requestSync` (W4) silently no-op when unreachable; durable state (T2) and forwarded edits (W1's T4 fallback) still get through. - **Unordered edit delivery.** T3 vs T4 can arrive out of order, and both devices may edit at once — handled by the per-log merge (W1), not by whole-document `updatedAt`. - **The launch race is deliberately safe.** A phone-launched session can be `isRunning` before its run document has synced; `SessionEndPlanner.decide` guards with `!previouslyActiveIDs.isEmpty` so a not-yet-synced run is never mistaken for a finished one. diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index b4dab19..b8b5b4e 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -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? + /// True while the phone's pushes fail to decode — the two apps are on different + /// document schemas (the phone updated first; the watch app hasn't yet). The cache is + /// deliberately left untouched in that state, so surface it (`ActiveWorkoutGateView`) + /// instead of silently showing stale workouts until the watch app updates. + private(set) var schemaMismatch = false + /// 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 @@ -115,8 +121,10 @@ final class WatchConnectivityBridge: NSObject { private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) { guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else { Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?") + schemaMismatch = true return } + schemaMismatch = false Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts") lastSyncDate = Date() onWorkoutsChanged?() @@ -127,7 +135,11 @@ final class WatchConnectivityBridge: NSObject { Self.log.info("requestSync skipped: activation=\(self.session?.activationState.rawValue ?? -1) reachable=\(self.session?.isReachable ?? false)") return } - session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil) + session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: { @Sendable error in + // No retry — the activation/reachability edges re-pull, and the durable + // context slot delivers regardless — but a dropped pull must be visible. + Self.log.warning("requestSync send failed: \(error, privacy: .public)") + }) } /// Optimistically applies a workout edit to the local cache and forwards it to diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index 650a2fd..7da6768 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -66,6 +66,20 @@ struct ActiveWorkoutGateView: View { WorkoutLogListView(workout: workout) } } + // The phone's pushes aren't decoding (its app updated first; this one hasn't + // yet) — everything shown is frozen at the last good sync. Say so rather than + // silently presenting stale workouts until the watch app catches up. + .safeAreaInset(edge: .bottom) { + if bridge.schemaMismatch { + Label("Update both apps to resume sync", systemImage: "exclamationmark.arrow.triangle.2.circlepath") + .font(.caption2) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 2) + .background(.ultraThinMaterial) + } + } } .task { // Nothing to run yet — pull fresh state in case the phone just started one.