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
This commit is contained in:
2026-07-09 23:04:19 -04:00
parent abb223daca
commit 25a111a6b4
4 changed files with 53 additions and 14 deletions
+6 -2
View File
@@ -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.
+4
View File
@@ -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.
@@ -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.
+4
View File
@@ -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 {