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:
+6
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user