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 }