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
@@ -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.