crossmate

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

PuzzleSession.swift (7504B)


      1 import Foundation
      2 import UIKit
      3 
      4 /// Per-open-game session state machine. Owns every timer for one game — the
      5 /// end-grace timer with its background-execution assertion and the
      6 /// catch-up-banner settle timer — so the leave/resume/grace interleavings live
      7 /// (and are testable) in one place instead of being spread across per-game
      8 /// dictionaries and view lifecycle handlers.
      9 ///
     10 /// `SessionCoordinator` creates one per open game, injects the side effects
     11 /// (the actual pushes and the banner), and prunes the session once it is
     12 /// idle. Tests inject stub effects and a manual sleep to drive the
     13 /// interleavings deterministically.
     14 @MainActor
     15 final class PuzzleSession {
     16     /// Side effects, injected by `SessionCoordinator` and stubbed in tests.
     17     struct Effects {
     18         /// Sender-side pause push, carrying the wall-clock captured when the
     19         /// grace timer was scheduled —
     20         /// `SessionCoordinator.publishSessionEndPush(gameID:pauseStart:)`.
     21         var publishEnd: @MainActor (Date) async -> Void
     22         /// Posts the receiver-side catch-up banner —
     23         /// `SessionCoordinator.postSessionSummaryBanner(gameID:reason:)`.
     24         var postSummaryBanner: @MainActor () -> Void
     25         /// Diagnostics breadcrumb.
     26         var note: @MainActor (String) -> Void
     27         /// Takes a background-execution assertion (UIApplication in
     28         /// production) whose expiration handler fires the pause early when
     29         /// iOS is about to reclaim the app before the grace elapses.
     30         var beginBackgroundAssertion: @MainActor (String, @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier
     31         /// Releases an assertion taken by `beginBackgroundAssertion`.
     32         var endBackgroundAssertion: @MainActor (UIBackgroundTaskIdentifier) -> Void
     33     }
     34 
     35     let gameID: UUID
     36     private let effects: Effects
     37     /// Injected so tests can gate the grace timers manually instead of racing
     38     /// wall-clock timing; production uses `Task.sleep`.
     39     private let sleep: @Sendable (Duration) async throws -> Void
     40 
     41     private var pendingEndTask: Task<Void, Never>?
     42     private var pendingSummaryBannerTask: Task<Void, Never>?
     43     /// Background-execution assertion keeping the end-grace timer alive after
     44     /// the app is backgrounded. iOS grants only a limited budget (often well
     45     /// under the grace), so the assertion's expiration handler fires the
     46     /// pause early rather than letting suspension drop it.
     47     private var endAssertion: UIBackgroundTaskIdentifier = .invalid
     48 
     49     /// True when nothing keeps this session alive: no timer pending and no
     50     /// assertion held. `SessionCoordinator` prunes idle sessions from its
     51     /// per-game map.
     52     var isIdle: Bool {
     53         pendingEndTask == nil
     54             && pendingSummaryBannerTask == nil
     55             && endAssertion == .invalid
     56     }
     57 
     58     init(
     59         gameID: UUID,
     60         effects: Effects,
     61         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
     62     ) {
     63         self.gameID = gameID
     64         self.effects = effects
     65         self.sleep = sleep
     66     }
     67 
     68     // MARK: End grace
     69 
     70     /// Defer the session-end push by `seconds`, replacing any previously
     71     /// scheduled pause. If the user resumes within the grace window, call
     72     /// `cancelPendingEndPush` to drop the timer and skip the matching
     73     /// session-begin push so peers don't get a pause/play pair for a brief
     74     /// absence. The wall-clock at scheduling time is passed through so the
     75     /// fire-time peer-device-active check has a stable reference point for
     76     /// "did anyone other than me write to Player during the grace window."
     77     ///
     78     /// Holds a background-execution assertion so the grace timer keeps
     79     /// running once the app is backgrounded. If iOS is about to reclaim us
     80     /// before the timer elapses, the expiration handler fires the pause
     81     /// early (best effort) instead of letting suspension drop it.
     82     func scheduleEndPush(after seconds: TimeInterval) {
     83         let pauseStart = Date()
     84         cancelPendingEndPush()
     85         endAssertion = effects.beginBackgroundAssertion(
     86             "session-end-\(gameID.uuidString)"
     87         ) { [weak self] in
     88             self?.fireEndPush(pauseStart: pauseStart, expedited: true)
     89         }
     90         let sleep = self.sleep
     91         pendingEndTask = Task { [weak self] in
     92             try? await sleep(.seconds(seconds))
     93             guard !Task.isCancelled else { return }
     94             self?.fireEndPush(pauseStart: pauseStart, expedited: false)
     95         }
     96     }
     97 
     98     /// Cancel any pending scheduled session-end push, releasing the
     99     /// background assertion. Returns `true` if a pending timer was dropped,
    100     /// i.e. the caller is inside the grace window (a resume) and should
    101     /// suppress the matching session-begin push.
    102     @discardableResult
    103     func cancelPendingEndPush() -> Bool {
    104         releaseEndAssertion()
    105         guard let task = pendingEndTask else { return false }
    106         pendingEndTask = nil
    107         task.cancel()
    108         return true
    109     }
    110 
    111     /// A direct publish (e.g. from `onDisappear`) supersedes any pending
    112     /// grace-window timer — drop it so the timer can't fire a second pause
    113     /// once it elapses. Unlike `cancelPendingEndPush` this leaves the
    114     /// background assertion alone; the publish in flight is what it is
    115     /// keeping alive, and its expiration latch releases it.
    116     func supersedePendingEndPush() {
    117         pendingEndTask?.cancel()
    118         pendingEndTask = nil
    119     }
    120 
    121     /// Single fire path for the deferred session-end push, shared by the
    122     /// grace timer and the background-assertion expiration handler. The
    123     /// pending-task slot doubles as a "not yet fired" flag, so this is
    124     /// idempotent: whichever caller wins clears it, and the loser falls
    125     /// through to releasing the assertion only. `expedited` marks the early
    126     /// fire forced by an imminent suspension, purely for diagnostics.
    127     private func fireEndPush(pauseStart: Date, expedited: Bool) {
    128         guard let task = pendingEndTask else {
    129             releaseEndAssertion()
    130             return
    131         }
    132         pendingEndTask = nil
    133         task.cancel()
    134         if expedited {
    135             effects.note("push(pause): firing early (background expiring)")
    136         }
    137         Task { [weak self] in
    138             guard let self else { return }
    139             await self.effects.publishEnd(pauseStart)
    140             self.releaseEndAssertion()
    141         }
    142     }
    143 
    144     /// Releases the background-execution assertion, if one is held. Safe to
    145     /// call repeatedly — a released slot is a no-op.
    146     private func releaseEndAssertion() {
    147         guard endAssertion != .invalid else { return }
    148         let id = endAssertion
    149         endAssertion = .invalid
    150         effects.endBackgroundAssertion(id)
    151     }
    152 
    153     // MARK: Catch-up banner
    154 
    155     /// Defer the catch-up banner by `seconds`, replacing any pending timer.
    156     /// Leaving the puzzle cancels it.
    157     func scheduleSummaryBanner(after seconds: TimeInterval) {
    158         pendingSummaryBannerTask?.cancel()
    159         let sleep = self.sleep
    160         pendingSummaryBannerTask = Task { [weak self] in
    161             try? await sleep(.seconds(seconds))
    162             guard !Task.isCancelled, let self else { return }
    163             self.pendingSummaryBannerTask = nil
    164             self.effects.postSummaryBanner()
    165         }
    166     }
    167 
    168     func cancelPendingSummaryBanner() {
    169         pendingSummaryBannerTask?.cancel()
    170         pendingSummaryBannerTask = nil
    171     }
    172 }