EngagementLifecycle.swift (19433B)
1 import Foundation 2 3 /// Drives the live engagement (websocket) lifecycle for open puzzles, 4 /// extracted from `AppServices`: room reconcile/mint, the scheduled-teardown, 5 /// reconnect-backstop, and lease-expiry timers, and inbound channel 6 /// event/message handling. `AppServices` composes one instance and remains 7 /// the owner of the host/status/store objects it shares with other surfaces 8 /// (`PlayerRoster`, the selection publisher, the grid-freshen debounce). 9 @MainActor 10 final class EngagementLifecycle { 11 static let engagementTeardownDelaySeconds = 120 12 static let engagementTeardownDelay: Duration = .seconds(engagementTeardownDelaySeconds) 13 /// How often a foregrounded shared puzzle re-runs the engagement reconnect 14 /// check. Engagement auto-connect is otherwise edge-triggered (channel 15 /// close, foreground, a peer's cursor move), so a drop whose re-connect 16 /// edge never lands — a failed connect, a `readAt`-only lease refresh, an 17 /// edge lost to suspension — stays disconnected until the user nudges it 18 /// manually. This timer is the backstop. It re-runs `peerPresenceMayHave\ 19 /// Changed`, which is a no-op unless the coordinator is idle *and* a peer 20 /// holds a live lease, so a steady-state live (or peerless) session does 21 /// no work and writes nothing; the coordinator's connecting-state guard 22 /// caps any actual re-hail rate. 23 static let engagementReconnectInterval: Duration = .seconds(30) 24 25 private let preferences: PlayerPreferences 26 private let persistence: PersistenceController 27 private let store: GameStore 28 private let identity: AuthorIdentity 29 private let syncMonitor: SyncMonitor 30 private let engagementHost: EngagementHost 31 private let engagementStatus: EngagementStatus 32 private let engagementStore: EngagementStore 33 /// Whether the app is foreground-active — `AppServices.isAppForeground`, 34 /// the single source of truth a background CKSyncEngine wake can't spoof. 35 private let isAppForeground: () -> Bool 36 /// Renews this device's read lease for the game — 37 /// `AppServices.publishReadCursor(for:mode:.activeLease)`. 38 private let renewReadLease: (UUID) async -> Void 39 private let ensureICloudSyncStarted: () async -> Bool 40 41 private lazy var engagementCoordinator = EngagementCoordinator( 42 host: engagementHost, 43 localAuthorID: { [weak self] in 44 await MainActor.run { self?.identity.currentID } 45 }, 46 log: { [weak self] message in 47 await MainActor.run { self?.syncMonitor.note(message) } 48 } 49 ) 50 51 private var latestLocalSelections: [UUID: PlayerSelection] = [:] 52 private var scheduledEngagementEndTasks: [UUID: Task<Void, Never>] = [:] 53 private var engagementReconnectTasks: [UUID: Task<Void, Never>] = [:] 54 private var engagementLeaseExpiryTasks: [UUID: Task<Void, Never>] = [:] 55 56 init( 57 preferences: PlayerPreferences, 58 persistence: PersistenceController, 59 store: GameStore, 60 identity: AuthorIdentity, 61 syncMonitor: SyncMonitor, 62 engagementHost: EngagementHost, 63 engagementStatus: EngagementStatus, 64 engagementStore: EngagementStore, 65 isAppForeground: @escaping () -> Bool, 66 renewReadLease: @escaping (UUID) async -> Void, 67 ensureICloudSyncStarted: @escaping () async -> Bool 68 ) { 69 self.preferences = preferences 70 self.persistence = persistence 71 self.store = store 72 self.identity = identity 73 self.syncMonitor = syncMonitor 74 self.engagementHost = engagementHost 75 self.engagementStatus = engagementStatus 76 self.engagementStore = engagementStore 77 self.isAppForeground = isAppForeground 78 self.renewReadLease = renewReadLease 79 self.ensureICloudSyncStarted = ensureICloudSyncStarted 80 } 81 82 /// Drives one game's live connection toward the room its Game record 83 /// advertises. Reads the shared `engagement` creds and whether any peer 84 /// holds a live read lease; if a peer is present (or `force`) and no creds 85 /// exist yet, mints a room and writes it to the Game record (any present 86 /// participant may — record-level LWW converges concurrent mints). Then 87 /// hands the desired creds to the coordinator, which connects, migrates, or 88 /// tears down to match. `force` is the manual "start a room now" path, 89 /// which mints and connects without waiting to see a present peer. 90 func reconcileEngagement(gameID: UUID) async { 91 guard preferences.isICloudSyncEnabled else { return } 92 // A completed game is not a live session. Never connect (this is also 93 // the reopen-from-Completed path, which re-runs through 94 // `startEngagementIfPossible`) and tear down anything still up. 95 guard !store.isCompleted(gameID: gameID) else { 96 cancelEngagementLeaseExpiry(gameID: gameID) 97 await engagementCoordinator.reconcile(gameID: gameID, creds: nil, hasPeer: false) 98 return 99 } 100 // A live socket is a foreground-only affair — the same rule 101 // `publishReadCursor` enforces for the read lease. Inbound presence / 102 // engagement changes also arrive on background CKSyncEngine wakes 103 // (`onRemotePlayerPresenceChanged` / `onRemoteEngagementChanged`), and a 104 // background reconcile used to re-dial the socket on every wake. That is 105 // futile: the `.default` URLSession WebSocket can't survive suspension, 106 // so it just storms connect → abort → reconnect until the next push. 107 // When not foreground, leave the current connection (and the 108 // scheduled-end grace that rides out a transient `.inactive`) untouched 109 // and refuse to escalate; the foreground `.active` path re-reconciles 110 // via `startEngagementIfPossible`. Teardown still flows from socket 111 // abort, `scheduleEngagementEnd`, and the completed-game guard above. 112 guard isAppForeground() else { 113 syncMonitor.note("engagement: reconcile skipped for \(gameID.uuidString): backgrounded") 114 return 115 } 116 let soonestLease = await AppServices.soonestPeerLease( 117 persistence: persistence, 118 gameID: gameID, 119 localAuthorID: identity.currentID 120 ) 121 let hasPeer = soonestLease != nil 122 var creds = EngagementRoomCredentials.decode(store.engagement(for: gameID)) 123 if creds == nil, hasPeer { 124 if let minted = try? EngagementRoomCredentials.fresh(), 125 let encoded = try? minted.encoded(), 126 store.setEngagement(encoded, for: gameID) { 127 creds = minted 128 syncMonitor.note( 129 "engagement: minted room \(minted.roomID.uuidString) for \(gameID.uuidString)" 130 ) 131 } 132 } 133 let outcome = await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer) 134 if outcome == .roomRejected, let rejected = creds { 135 // The worker holds a different secret for this room ID (e.g. the 136 // room was squatted after idle expiry). Reconnecting with these 137 // creds can never succeed, so drop them from the Game record; a 138 // later reconcile mints a fresh room and LWW converges the peers 139 // onto it. 140 if store.setEngagement(nil, for: gameID) { 141 syncMonitor.note( 142 "engagement: cleared rejected room \(rejected.roomID.uuidString) for \(gameID.uuidString)" 143 ) 144 } 145 } 146 // When the soonest present peer drops out of presence, re-reconcile: 147 // tear down (dropping the bolt) if no renewal arrived, or reschedule 148 // onto the new horizon if one did. That instant is the lease plus the 149 // presence grace, not the bare lease — a peer stays present through the 150 // grace, so tearing down at the raw lease would race it. The 30s 151 // reconnect tick remains the coarse backstop. A no-peer reconcile 152 // has no lease to watch, so this just clears any prior wake. 153 scheduleEngagementLeaseExpiry( 154 gameID: gameID, 155 at: soonestLease?.addingTimeInterval(PeerPresence.presenceGrace) 156 ) 157 } 158 159 func startEngagementIfPossible(gameID: UUID) async { 160 cancelScheduledEngagementEnd(gameID: gameID) 161 guard preferences.isICloudSyncEnabled else { return } 162 guard await ensureICloudSyncStarted() else { return } 163 await reconcileEngagement(gameID: gameID) 164 startEngagementReconnectRetry(gameID: gameID) 165 } 166 167 func endEngagement(gameID: UUID) async { 168 cancelScheduledEngagementEnd(gameID: gameID) 169 cancelEngagementReconnectRetry(gameID: gameID) 170 cancelEngagementLeaseExpiry(gameID: gameID) 171 syncMonitor.note("engagement: ending for \(gameID.uuidString)") 172 engagementStatus.setLive(false, gameID: gameID) 173 latestLocalSelections[gameID] = nil 174 engagementStore.clear(gameID: gameID) 175 await engagementCoordinator.teardown(gameID: gameID) 176 } 177 178 func scheduleEngagementEnd(gameID: UUID) { 179 cancelScheduledEngagementEnd(gameID: gameID) 180 // Leaving the puzzle stops the reconnect backstop regardless of 181 // whether a channel ever went live — otherwise a puzzle that was 182 // opened but never connected would tick forever. A quick return 183 // re-arms it via `startEngagementIfPossible`. 184 cancelEngagementReconnectRetry(gameID: gameID) 185 cancelEngagementLeaseExpiry(gameID: gameID) 186 guard engagementStatus.isLive(gameID: gameID) else { return } 187 syncMonitor.note( 188 "engagement: scheduled ending for \(gameID.uuidString) " + 189 "in \(Self.engagementTeardownDelaySeconds)s" 190 ) 191 scheduledEngagementEndTasks[gameID] = Task { [weak self] in 192 do { 193 try await Task.sleep(for: Self.engagementTeardownDelay) 194 } catch { 195 return 196 } 197 await self?.finishScheduledEngagementEnd(gameID: gameID) 198 } 199 } 200 201 func cancelScheduledEngagementEnd(gameID: UUID) { 202 guard let task = scheduledEngagementEndTasks.removeValue(forKey: gameID) else { return } 203 task.cancel() 204 syncMonitor.note("engagement: cancelled scheduled ending for \(gameID.uuidString)") 205 } 206 207 private func finishScheduledEngagementEnd(gameID: UUID) async { 208 guard scheduledEngagementEndTasks.removeValue(forKey: gameID) != nil else { return } 209 syncMonitor.note("engagement: scheduled ending fired for \(gameID.uuidString)") 210 await endEngagement(gameID: gameID) 211 } 212 213 /// Arms the periodic engagement-presence tick for `gameID`, replacing any 214 /// prior timer for the same game (so a re-arm on foreground doesn't stack). 215 /// Each tick does two things: 216 /// 217 /// 1. Renews our own read lease (`publishReadCursor(.activeLease)`, which 218 /// is floor-gated so it writes at most ~once per 5 min). This is what 219 /// makes `readAt` a true "foregrounded on this puzzle" heartbeat: a 220 /// peer's own typing never advances their own lease, so without this an 221 /// active solo driver would lapse mid-session and look absent. 222 /// 2. Re-runs the coordinator's presence check, which both reconnects a 223 /// dropped channel and tears down a live channel whose peer's lease has 224 /// expired — neither of which is safe until (1) keeps present peers 225 /// from falsely lapsing. 226 /// 227 /// Both are no-ops in steady state (lease has >5 min left; coordinator is 228 /// live with a present peer). Cancelled on leave via `scheduleEngagement 229 /// End`/`endEngagement`; goes dormant under suspension (the sleep can't 230 /// advance) and is re-armed by the foreground `startEngagementIfPossible`. 231 private func startEngagementReconnectRetry(gameID: UUID) { 232 engagementReconnectTasks[gameID]?.cancel() 233 engagementReconnectTasks[gameID] = Task { [weak self] in 234 while !Task.isCancelled { 235 try? await Task.sleep(for: Self.engagementReconnectInterval) 236 guard !Task.isCancelled, let self else { return } 237 await self.renewReadLease(gameID) 238 await self.reconcileEngagement(gameID: gameID) 239 } 240 } 241 } 242 243 func cancelEngagementReconnectRetry(gameID: UUID) { 244 guard let task = engagementReconnectTasks.removeValue(forKey: gameID) else { return } 245 task.cancel() 246 syncMonitor.note("engagement: reconnect backstop cancelled for \(gameID.uuidString)") 247 } 248 249 /// Arms a one-shot reconcile at `expiry` — the soonest moment a present 250 /// peer's lease lapses — replacing any prior wake for this game. When it 251 /// fires, `reconcileEngagement` tears the channel down if no renewal landed 252 /// (so the bolt drops at expiry, not up to a tick later) or reschedules 253 /// onto the renewed horizon. `nil` (no-peer) just clears the wake. 254 private func scheduleEngagementLeaseExpiry(gameID: UUID, at expiry: Date?) { 255 engagementLeaseExpiryTasks[gameID]?.cancel() 256 engagementLeaseExpiryTasks[gameID] = nil 257 guard let expiry else { return } 258 let interval = max(0, expiry.timeIntervalSinceNow) 259 engagementLeaseExpiryTasks[gameID] = Task { [weak self] in 260 try? await Task.sleep(for: .seconds(interval)) 261 guard !Task.isCancelled, let self else { return } 262 await self.reconcileEngagement(gameID: gameID) 263 } 264 } 265 266 private func cancelEngagementLeaseExpiry(gameID: UUID) { 267 guard let task = engagementLeaseExpiryTasks.removeValue(forKey: gameID) else { return } 268 task.cancel() 269 } 270 271 func noteLocalSelection(_ selection: PlayerSelection, gameID: UUID) async { 272 latestLocalSelections[gameID] = selection 273 guard engagementStatus.isLive(gameID: gameID), 274 let localAuthorID = identity.currentID, 275 !localAuthorID.isEmpty 276 else { return } 277 let update = EngagementSelectionUpdate( 278 gameID: gameID, 279 authorID: localAuthorID, 280 deviceID: RecordSerializer.localDeviceID, 281 selection: selection 282 ) 283 await engagementCoordinator.sendSelection(update) 284 } 285 286 /// Forwards a locally-applied cell edit over the live channel. No-op while 287 /// no channel is live for the game — the durable Moves sync covers it. 288 func sendLocalCellEdit(_ edit: RealtimeCellEdit) { 289 guard engagementStatus.isLive(gameID: edit.gameID) else { return } 290 Task { await engagementCoordinator.sendCellEdit(edit) } 291 } 292 293 /// Batch companion to `sendLocalCellEdit`. 294 func sendLocalCellEdits(_ edits: [RealtimeCellEdit]) { 295 guard let gameID = edits.first?.gameID else { return } 296 guard engagementStatus.isLive(gameID: gameID) else { return } 297 Task { await engagementCoordinator.sendCellEdits(edits) } 298 } 299 300 func handleEngagementEvent(_ event: EngagementHost.Event) { 301 switch event { 302 case .channelOpen(let engagementID): 303 syncMonitor.note("engagement: channel open \(engagementID.uuidString)") 304 Task { [weak self] in 305 guard let self, 306 let gameID = await self.engagementCoordinator.channelOpened(engagementID: engagementID) 307 else { return } 308 self.engagementStatus.setLive(true, gameID: gameID) 309 if let selection = self.latestLocalSelections[gameID] { 310 await self.noteLocalSelection(selection, gameID: gameID) 311 } 312 } 313 case .channelMessage(let engagementID, let message): 314 if let envelope = EngagementMessage.decode(message) { 315 handleEngagementMessage(envelope, engagementID: engagementID) 316 } else { 317 syncMonitor.note("engagement: channel message \(engagementID.uuidString) bytes=\(message.count)") 318 } 319 case .channelClose(let engagementID): 320 syncMonitor.note("engagement: channel close \(engagementID.uuidString)") 321 Task { [weak self] in 322 guard let self, 323 let gameID = await self.engagementCoordinator.channelClosed(engagementID: engagementID) 324 else { return } 325 self.engagementStatus.setLive(false, gameID: gameID) 326 self.engagementStore.clear(gameID: gameID) 327 await self.startEngagementIfPossible(gameID: gameID) 328 } 329 case .diagnostic(let engagementID, let message): 330 syncMonitor.note( 331 "engagement: diagnostic \(engagementID?.uuidString ?? "unknown"): \(message)" 332 ) 333 case .error(let engagementID, let message): 334 syncMonitor.note("engagement: error \(engagementID?.uuidString ?? "unknown"): \(message)") 335 } 336 } 337 338 private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID) { 339 let latencyMs = max(0, Int(Date().timeIntervalSince(envelope.sentAt) * 1000)) 340 switch envelope.kind { 341 case .debugText: 342 syncMonitor.note( 343 "engagement: received \(envelope.kind.rawValue) \(engagementID.uuidString) " + 344 "latency=\(latencyMs)ms: \(envelope.text)" 345 ) 346 case .cellEdit: 347 guard let edit = envelope.cellEdit else { 348 syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)") 349 return 350 } 351 let applied = store.applyRealtimeCellEdit(edit) 352 if applied { 353 syncMonitor.note( 354 "engagement: applied cellEdit \(engagementID.uuidString) " + 355 "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " + 356 "latency=\(latencyMs)ms" 357 ) 358 } else { 359 syncMonitor.note( 360 "engagement: rejected cellEdit \(engagementID.uuidString) " + 361 "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " + 362 "latency=\(latencyMs)ms" 363 ) 364 } 365 case .cellEditBatch: 366 guard let edits = envelope.cellEdits, !edits.isEmpty else { 367 syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)") 368 return 369 } 370 let applied = store.applyRealtimeCellEdits(edits) 371 syncMonitor.note( 372 "engagement: applied cellEditBatch \(engagementID.uuidString) " + 373 "applied=\(applied)/\(edits.count) device=\(edits[0].deviceID.prefix(8)) " + 374 "latency=\(latencyMs)ms" 375 ) 376 case .selection: 377 guard let selection = envelope.selection else { 378 syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)") 379 return 380 } 381 engagementStore.set(selection) 382 syncMonitor.note( 383 "engagement: received selection \(engagementID.uuidString) " + 384 "r=\(selection.row) c=\(selection.col) device=\(selection.deviceID.prefix(8)) " + 385 "latency=\(latencyMs)ms" 386 ) 387 } 388 } 389 }