Make the live-run mirror survive lost phone-to-watch frames

Live frames ride sendMessage, which is reachable-only — and phone→watch
reachability drops exactly when the user is swiping on the phone (wrist
down). A frame that failed to send was staged but never retried until a
reachability edge, and a frame lost outright desynced the run until the
next human transition — sometimes forever, since a reconnect could even
re-send the stale staged frame and yank the peer backwards.

Four fixes, symmetric on both bridges and both run screens:

- A send that fails while nominally reachable now retries with a short
  backoff (a few times per staged message) instead of being swallowed.
- Receiving a frame that outranks the staged outbound one drops the
  staged frame, so a reconnect re-send can't move the run backwards;
  a delivery the staged frame outranks is ignored as stale.
- Every staleness comparison now tie-breaks the shared version sequence
  on the frame's wall-clock anchor (LiveProgress.isNewer) — after a
  lost frame both devices can mint the same version, and the later
  human action must win.
- Durable repair: when the absorbed workout doc shows sets completed
  beyond anything the open run screen recorded or followed, it jumps
  forward to the first unfinished set's work page — a lost frame now
  degrades to a briefly-stale page instead of a stuck one.
This commit is contained in:
2026-07-08 18:52:24 -04:00
parent b53381e8ce
commit 8cbe078ffd
8 changed files with 250 additions and 15 deletions
@@ -38,6 +38,12 @@ final class WatchConnectivityBridge: NSObject {
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
private var pendingLive: PendingLive?
/// In-flight backoff retry for a staged live message whose send *failed* (as opposed to
/// never being attempted because the peer was unreachable that case is re-flushed by the
/// reachability/activation delegates). See `scheduleLiveRetry`.
private var liveRetryTask: Task<Void, Never>?
private var liveRetryAttempt = 0
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
/// this to follow a phone-driven transition; it's never persisted.
@@ -138,6 +144,7 @@ final class WatchConnectivityBridge: NSObject {
var stamped = frame
stamped.version = liveVersion
pendingLive = .progress(stamped)
liveRetryAttempt = 0
flushLive()
}
@@ -147,6 +154,7 @@ final class WatchConnectivityBridge: NSObject {
func sendLiveEnded(workoutID: String, logID: String) {
guard let session, session.activationState == .activated else { return }
pendingLive = .ended(workoutID: workoutID, logID: logID)
liveRetryAttempt = 0
flushLive()
}
@@ -156,6 +164,8 @@ final class WatchConnectivityBridge: NSObject {
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) an
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
private func flushLive() {
liveRetryTask?.cancel()
liveRetryTask = nil
guard let session, let pending = pendingLive,
session.activationState == .activated, session.isReachable else { return }
let payload: [String: Any]
@@ -165,14 +175,43 @@ final class WatchConnectivityBridge: NSObject {
case .ended(let workoutID, let logID):
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
}
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in })
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in
Task { @MainActor in self?.scheduleLiveRetry() }
})
}
/// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches
/// our send counter up so the next frame we send outranks it (shared per-run sequence).
/// A send failed while the phone was nominally reachable. Without this the staged message
/// would sit until the next reachability/activation edge which may never come mid-workout
/// so back off briefly and re-flush, a handful of times per staged message (the edges still
/// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent,
/// so a newer frame staged meanwhile supersedes the failed one here too.
private func scheduleLiveRetry() {
guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return }
liveRetryAttempt += 1
let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1)))
liveRetryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self, !Task.isCancelled else { return }
self.liveRetryTask = nil
self.flushLive()
}
}
/// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run
/// sequence), then arbitrates against our own staged outbound frame for the same run: if the
/// incoming frame outranks it, drop the staged one (so a later reconnect can't re-send stale
/// state and yank the run backwards); if the *staged* frame outranks the delivery a late or
/// version-collided arrival predating our own latest action ignore the incoming frame.
/// Finally drops anything not newer than what we're already showing (redeliveries included).
private func applyIncomingLive(_ frame: LiveProgress) {
liveVersion = max(liveVersion, frame.version)
if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return }
if case .progress(let staged) = pendingLive, staged.logID == frame.logID {
guard !staged.isNewer(than: frame) else { return }
pendingLive = nil
liveRetryTask?.cancel()
liveRetryTask = nil
}
if let current = liveIncoming, current.logID == frame.logID, !frame.isNewer(than: current) { return }
liveIncoming = frame
}