From 25a111a6b4b49dbb13407140cbd0900d5006bba6 Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 9 Jul 2026 23:04:19 -0400 Subject: [PATCH] Suspend watch edit locks on background; degrade oversized pushes Two watch-stranding fixes (BULLETPROOFING.md M1, M4): The exclusive-edit lock rode in the latest-wins context and was cleared only by onDisappear, so an editor left open in a pocketed (or force-quit) phone parked the watch's run indefinitely ("Editing on iPhone..."). The scene-phase hook now publishes the locks as cleared while the app is backgrounded - without forgetting them locally - and re-asserts them on return to the foreground. pushAll treated a failed updateApplicationContext as log-only, so a payload past WatchConnectivity's size ceiling silently froze the watch out of all future state. A failed push now retries with the recently-completed tail dropped (display-only on the watch); only a failure of the slim push too remains an error. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT --- BULLETPROOFING.md | 8 ++- CHANGELOG.md | 4 ++ .../PhoneConnectivityBridge.swift | 51 ++++++++++++++----- Workouts/WorkoutsApp.swift | 4 ++ 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/BULLETPROOFING.md b/BULLETPROOFING.md index a2a557b..2bab141 100644 --- a/BULLETPROOFING.md +++ b/BULLETPROOFING.md @@ -57,7 +57,9 @@ Timing variant: if B's session *does* start (old one already ended) but the tran ## MEDIUM — stranding and stale-UI traps -### M1 — Edit locks have no expiry and outlive their editor · open +### 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* (viewing `SplitDetailView` parks 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`). @@ -75,7 +77,9 @@ The live-mirror cover (`LiveRunCoverView`, both platforms) is not a passive disp **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 +### M4 — `updateApplicationContext` size ceiling; failure is log-only · **fixed** + +> **Fix (2026-07-09):** `pushAll` now 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index ce25a70..a0452e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ A recording cut short by watchOS itself is now still saved to Apple Health inste Ending a workout and immediately starting a new one no longer risks a stray seconds-long workout appearing in Apple Health. +Putting the iPhone away with an editor still open no longer leaves the workout stuck showing Editing on iPhone on the watch. + +Syncing to the watch no longer silently stops when a large workout history makes the update too big to send. + 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/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index c343345..1bd5c10 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -26,6 +26,13 @@ final class PhoneConnectivityBridge: NSObject { private(set) var editingWorkoutID: String? private(set) var editingSplitID: String? + /// While true (scene backgrounded), the edit locks are *published* to the watch as + /// cleared without being forgotten locally. An editor left open in a pocketed — or + /// force-quit — phone never fires `onDisappear`, and a lock that outlives its editor + /// parks the watch's run indefinitely ("Editing on iPhone…"). Restored on re-activate, + /// so returning to the still-open editor re-asserts the lock. + private var locksSuspended = false + /// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of /// any frame we *receive*, so the two devices share one increasing sequence per run and /// either side can drop a stale / out-of-order delivery (see `LiveProgress.version`). @@ -100,27 +107,47 @@ final class PhoneConnectivityBridge: NSObject { let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45 let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5 let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue - let payload = WCPayload.encodeState( - splits: splits.map(SplitDocument.init(from:)), - workouts: workouts.map(WorkoutDocument.init(from:)), - restSeconds: restSeconds, - doneCountdownSeconds: doneCountdownSeconds, - weightUnit: weightUnit, - editingWorkoutID: editingWorkoutID, - editingSplitID: editingSplitID - ) + let splitDocs = splits.map(SplitDocument.init(from:)) + func encode(_ included: [Workout]) -> [String: Any] { + WCPayload.encodeState( + splits: splitDocs, + workouts: included.map(WorkoutDocument.init(from:)), + restSeconds: restSeconds, + doneCountdownSeconds: doneCountdownSeconds, + weightUnit: weightUnit, + editingWorkoutID: locksSuspended ? nil : editingWorkoutID, + editingSplitID: locksSuspended ? nil : editingSplitID + ) + } do { + let payload = encode(workouts) try session.updateApplicationContext(payload) let bytes = ((payload[WCPayload.splitsKey] as? Data)?.count ?? 0) + ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0) Self.log.info("pushAll: \(splits.count) splits, \(workouts.count) workouts (\(bytes) bytes)") } catch { - // Payload-too-large / not-activated etc. — must be visible, or the watch - // just silently never hears about this state. - Self.log.error("updateApplicationContext failed: \(error, privacy: .public)") + // Realistically payload-too-large. The context is the watch's lifeline — a + // dropped push means it silently never hears about this state again — so + // degrade rather than freeze: the recently-completed tail is display-only + // on the watch, drop it and retry with just the active runs. + do { + try session.updateApplicationContext(encode(active)) + Self.log.warning("pushAll degraded to \(active.count) active workouts (dropped \(recentCompleted.count) completed): \(error, privacy: .public)") + } catch { + Self.log.error("updateApplicationContext failed: \(error, privacy: .public)") + } } } + /// Scene-phase hook: suspend the published edit locks while the app is backgrounded, + /// restore them when it returns to the foreground (see `locksSuspended`). Pushes only + /// when a lock is actually set — otherwise the flip changes nothing on the wire. + func setLocksSuspended(_ suspended: Bool) { + guard locksSuspended != suspended else { return } + locksSuspended = suspended + if editingWorkoutID != nil || editingSplitID != nil { pushAll() } + } + /// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor. /// The watch parks that run and blocks re-entry until it clears. Pushes immediately so /// the lock takes effect without waiting on a cache change. diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index 94a4a3c..be45b3a 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -31,6 +31,10 @@ struct WorkoutsApp: App { // the app is backgrounded, so re-assert it each time the scene becomes active. .onChange(of: scenePhase, initial: true) { _, phase in UIApplication.shared.isIdleTimerDisabled = (phase == .active) + // An editor left open when the app leaves the foreground must not keep the + // watch's run parked ("Editing on iPhone…") — suspend the published edit + // locks while backgrounded; returning to the editor re-asserts them. + services.watchBridge.setLocksSuspended(phase != .active) // Last chance before suspension: push any queued document writes out // now rather than waiting out a retry backoff we may not live to see. if phase == .background {