From f29e35e667c57170c0f976493e49d071d4fa2949 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 21 Jun 2026 09:10:16 -0400 Subject: [PATCH] Re-deliver live-run frames across a WatchConnectivity drop Stage the latest live-run frame (and the terminal .ended marker) in a depth-1 latest-wins slot on both bridges, and flush it on reachability/ activation restore. A brief connectivity drop no longer desyncs the iPhone/Watch run mirror; frames carry a wall-clock anchor so a late re-send self-corrects rather than reading as stale. Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7 --- .../WatchConnectivityBridge.swift | 77 +++++++++++++++---- .../PhoneConnectivityBridge.swift | 71 +++++++++++++---- 2 files changed, 122 insertions(+), 26 deletions(-) diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 5f7db34..a7d629f 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -28,6 +28,13 @@ final class WatchConnectivityBridge: NSObject { /// side can drop a stale / out-of-order delivery (see `LiveProgress.version`). private var liveVersion = 0 + /// The latest live-run message we haven't confirmed reached the phone (depth 1, latest-wins). + /// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation + /// returns, so a brief WatchConnectivity drop doesn't desync the mirror. A newer frame — or the + /// terminal `.ended` — replaces it, so we never deliver stale state. Frames carry an absolute + /// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale. + private var pendingLive: PendingLive? + /// 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. @@ -86,24 +93,47 @@ final class WatchConnectivityBridge: NSObject { sendToPhone(doc) } - // MARK: - Live run mirror (ephemeral; reachable-only) + // MARK: - Live run mirror (ephemeral; coalesced redelivery) - /// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Sent - /// over `sendMessage` only when the phone is reachable — this is throwaway presence, so - /// there's no guaranteed-delivery fallback (a queued frame would be stale on arrival). + /// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Staged as + /// the latest pending frame and sent when the phone is reachable; if it's unreachable the frame + /// is held and re-sent on reconnect (`flushLive`). Because frames are full state snapshots with + /// a wall-clock anchor, holding only the newest one (depth 1) and self-correcting its timers on + /// arrival means a re-send is never stale. func sendLiveProgress(_ frame: LiveProgress) { - guard let session, session.activationState == .activated, session.isReachable else { return } + guard let session, session.activationState == .activated else { return } liveVersion += 1 var stamped = frame stamped.version = liveVersion - session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in }) + pendingLive = .progress(stamped) + flushLive() } - /// Tell the phone to stop mirroring this run (the user left the progress flow). + /// Tell the phone to stop mirroring this run (the user left the progress flow). Staged like a + /// frame so a drop at the moment the run ends doesn't strand the phone's follower cover — the + /// terminal marker supersedes any pending progress and is re-sent on reconnect. func sendLiveEnded(workoutID: String, logID: String) { - guard let session, session.activationState == .activated, session.isReachable else { return } - session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID), - replyHandler: nil, errorHandler: { _ in }) + guard let session, session.activationState == .activated else { return } + pendingLive = .ended(workoutID: workoutID, logID: logID) + flushLive() + } + + /// (Re)send the staged live-run message if the phone is reachable. Called on each new frame and + /// whenever reachability/activation is restored. Leaves it staged on failure; a newer frame or + /// `.ended` supersedes it, so we never deliver stale state. The error handler runs on + /// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an + /// inherited-@MainActor closure would trap (swift_task_checkIsolated) there. + private func flushLive() { + guard let session, let pending = pendingLive, + session.activationState == .activated, session.isReachable else { return } + let payload: [String: Any] + switch pending { + case .progress(let frame): + payload = WCPayload.encodeLiveProgress(frame) + case .ended(let workoutID, let logID): + payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID) + } + session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in }) } /// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches @@ -126,8 +156,13 @@ final class WatchConnectivityBridge: NSObject { guard let session, session.activationState == .activated else { return } let payload = WCPayload.encodeWorkoutUpdate(doc) if session.isReachable { - session.sendMessage(payload, replyHandler: nil, errorHandler: { _ in - session.transferUserInfo(payload) // fall back to guaranteed delivery + // The error handler runs on WatchConnectivity's background queue, so it must be + // nonisolated (@Sendable) or it would trap (swift_task_checkIsolated). Hop back to the + // MainActor to fall back to guaranteed delivery; `doc` is Sendable, the payload isn't. + session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in + Task { @MainActor in + _ = self.session?.transferUserInfo(WCPayload.encodeWorkoutUpdate(doc)) + } }) } else { session.transferUserInfo(payload) @@ -168,7 +203,16 @@ final class WatchConnectivityBridge: NSObject { extension WatchConnectivityBridge: WCSessionDelegate { nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - Task { @MainActor in self.requestSync() } + Task { @MainActor in + self.requestSync() + self.flushLive() // deliver any frame staged before the session was ready + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + if session.isReachable { + Task { @MainActor in self.flushLive() } // catch the phone up on the latest run state after a reconnect + } } /// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins @@ -205,3 +249,10 @@ extension WatchConnectivityBridge: WCSessionDelegate { } } } + +/// The single staged live-run message awaiting (re)delivery to the phone — see `pendingLive`. +/// One slot, latest-wins: a newer progress frame or the terminal `.ended` replaces whatever's held. +private enum PendingLive { + case progress(LiveProgress) + case ended(workoutID: String, logID: String) +} diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index 7ec0149..9fdb714 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -28,6 +28,13 @@ final class PhoneConnectivityBridge: NSObject { /// either side can drop a stale / out-of-order delivery (see `LiveProgress.version`). private var liveVersion = 0 + /// The latest live-run message we haven't confirmed reached the watch (depth 1, latest-wins). + /// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation + /// returns, so a brief WatchConnectivity drop doesn't desync the mirror. A newer frame — or the + /// terminal `.ended` — replaces it, so we never deliver stale state. Frames carry an absolute + /// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale. + private var pendingLive: PendingLive? + private var context: ModelContext { container.mainContext } init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) { @@ -87,25 +94,48 @@ final class PhoneConnectivityBridge: NSObject { pushAll() } - // MARK: - Live run mirror (ephemeral; reachable-only) + // MARK: - Live run mirror (ephemeral; coalesced redelivery) - /// Broadcast where the run flow currently is, so the watch (if it has this run open) can - /// follow it live. Sent over `sendMessage` only when reachable — this is throwaway - /// presence, so there's no guaranteed-delivery fallback (a queued frame would be stale on - /// arrival). Mirrors the watch's `sendLiveProgress`; only *human* transitions are sent. + /// Broadcast where the run flow currently is, so the watch (if it has this run open) can follow + /// it live. Staged as the latest pending frame and sent when reachable; if the watch is + /// unreachable it's held and re-sent on reconnect (`flushLive`). Because frames are full state + /// snapshots with a wall-clock anchor, holding only the newest one (depth 1) and self-correcting + /// its timers on arrival means a re-send is never stale. Mirrors the watch's `sendLiveProgress`; + /// only *human* transitions are sent. func sendLiveProgress(_ frame: LiveProgress) { - guard let session, session.activationState == .activated, session.isReachable else { return } + guard let session, session.activationState == .activated else { return } liveVersion += 1 var stamped = frame stamped.version = liveVersion - session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in }) + pendingLive = .progress(stamped) + flushLive() } - /// Tell the watch we left the run flow (the cover closed / the run finished). + /// Tell the watch we left the run flow (the cover closed / the run finished). Staged like a + /// frame so a drop at the moment the run ends doesn't strand the watch's follower cover — the + /// terminal marker supersedes any pending progress and is re-sent on reconnect. func sendLiveEnded(workoutID: String, logID: String) { - guard let session, session.activationState == .activated, session.isReachable else { return } - session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID), - replyHandler: nil, errorHandler: { _ in }) + guard let session, session.activationState == .activated else { return } + pendingLive = .ended(workoutID: workoutID, logID: logID) + flushLive() + } + + /// (Re)send the staged live-run message if the watch is reachable. Called on each new frame and + /// whenever reachability/activation is restored. Leaves it staged on failure; a newer frame or + /// `.ended` supersedes it, so we never deliver stale state. The error handler runs on + /// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an + /// inherited-@MainActor closure would trap (swift_task_checkIsolated) there. + private func flushLive() { + guard let session, let pending = pendingLive, + session.activationState == .activated, session.isReachable else { return } + let payload: [String: Any] + switch pending { + case .progress(let frame): + payload = WCPayload.encodeLiveProgress(frame) + case .ended(let workoutID, let logID): + payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID) + } + session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in }) } /// Apply a frame the watch sent. Catch our send counter up to it first, so the next frame @@ -143,7 +173,10 @@ final class PhoneConnectivityBridge: NSObject { extension PhoneConnectivityBridge: WCSessionDelegate { nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - Task { @MainActor in self.pushAll() } + Task { @MainActor in + self.pushAll() + self.flushLive() // deliver any frame staged before the session was ready + } } nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} @@ -153,7 +186,12 @@ extension PhoneConnectivityBridge: WCSessionDelegate { } nonisolated func sessionReachabilityDidChange(_ session: WCSession) { - if session.isReachable { Task { @MainActor in self.pushAll() } } + if session.isReachable { + Task { @MainActor in + self.pushAll() + self.flushLive() // catch the watch up on the latest run state after a reconnect + } + } } nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { @@ -164,3 +202,10 @@ extension PhoneConnectivityBridge: WCSessionDelegate { route(userInfo) } } + +/// The single staged live-run message awaiting (re)delivery to the watch — see `pendingLive`. +/// One slot, latest-wins: a newer progress frame or the terminal `.ended` replaces whatever's held. +private enum PendingLive { + case progress(LiveProgress) + case ended(workoutID: String, logID: String) +}