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:
@@ -38,6 +38,12 @@ final class PhoneConnectivityBridge: 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
|
||||
|
||||
private var context: ModelContext { container.mainContext }
|
||||
|
||||
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
||||
@@ -146,6 +152,7 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
var stamped = frame
|
||||
stamped.version = liveVersion
|
||||
pendingLive = .progress(stamped)
|
||||
liveRetryAttempt = 0
|
||||
flushLive()
|
||||
}
|
||||
|
||||
@@ -155,6 +162,7 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
func sendLiveEnded(workoutID: String, logID: String) {
|
||||
guard let session, session.activationState == .activated else { return }
|
||||
pendingLive = .ended(workoutID: workoutID, logID: logID)
|
||||
liveRetryAttempt = 0
|
||||
flushLive()
|
||||
}
|
||||
|
||||
@@ -164,6 +172,8 @@ final class PhoneConnectivityBridge: 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]
|
||||
@@ -173,13 +183,42 @@ final class PhoneConnectivityBridge: 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() }
|
||||
})
|
||||
}
|
||||
|
||||
/// A send failed while the watch 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 frame the watch sent. Catch our send counter up to it first, so the next frame
|
||||
/// we send outranks it and the shared per-run sequence keeps increasing across devices.
|
||||
/// Then arbitrate 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.
|
||||
private func applyIncomingLive(_ frame: LiveProgress) {
|
||||
liveVersion = max(liveVersion, frame.version)
|
||||
if case .progress(let staged) = pendingLive, staged.logID == frame.logID {
|
||||
guard !staged.isNewer(than: frame) else { return }
|
||||
pendingLive = nil
|
||||
liveRetryTask?.cancel()
|
||||
liveRetryTask = nil
|
||||
}
|
||||
liveRunState.apply(frame)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user