crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }