commit 35e9512562f55c9f2748b6c74344de57585d49aa
parent 86db1e47efd45af205e2d3a872b92da3cf8a8120
Author: Michael Camilleri <[email protected]>
Date: Wed, 10 Jun 2026 22:12:24 +0900
Collapse session timers into a per-open-game PuzzleSession controller
The begin/end/grace choreography was spread across SessionCoordinator's
four per-game timer dictionaries plus SessionAnnouncementLog, and the
decisions (resume-inside-grace, never-announced-skip-pause) lived in
CrossmateApp's scene-phase handler and onDisappear.
PuzzleSession is now the per-game state machine: it owns the
begin-grace timer, the end-grace timer with its background-execution
assertion (and the expedited-fire latch), the catch-up-banner timer,
and the announced state that SessionAnnouncementLog used to track.
Effects (the actual pushes, the banner, the assertion) are injected as
closures, and the sleep is injectable, so the interleavings are
testable in isolation — ten cases in PuzzleSessionTests cover
cancel-inside-grace, resume, replace, expedited expiration fire, and
the supersede-on-direct-publish latch.
SessionCoordinator holds one PuzzleSession per open game, pruned once
idle, and gains the choreography as three lifecycle entry points —
notePuzzleActive / notePuzzleBackgrounded / notePuzzleClosed — which
also own the NotificationState active-puzzle ID writes that previously
happened in lockstep in the view. CrossmateApp's scene-phase handler
reduces to a phase switch and onDisappear to a single event call; the
flush-before-pause ordering stays in the view because the pause counts
read the journal after the moves flush.
The engagement timers stay in EngagementLifecycle deliberately: they're
channel lifecycle, not session choreography.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
7 files changed, 620 insertions(+), 265 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -16,6 +16,7 @@
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; };
07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */; };
+ 0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */; };
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; };
128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; };
@@ -83,6 +84,7 @@
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; };
85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */; };
886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3412F437AABD2988B6976D /* FriendPickerView.swift */; };
+ 88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710BCB6A647A820B106CE666 /* PuzzleSession.swift */; };
88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; };
8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; };
@@ -230,6 +232,7 @@
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; };
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
+ 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSessionTests.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayLoader.swift; sourceTree = "<group>"; };
400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; };
@@ -267,6 +270,7 @@
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; };
6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; };
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; };
+ 710BCB6A647A820B106CE666 /* PuzzleSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSession.swift; sourceTree = "<group>"; };
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -438,6 +442,7 @@
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */,
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */,
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */,
+ 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */,
443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */,
@@ -624,6 +629,7 @@
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
+ 710BCB6A647A820B106CE666 /* PuzzleSession.swift */,
3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */,
7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */,
CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */,
@@ -801,6 +807,7 @@
A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */,
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */,
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
+ 0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */,
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */,
@@ -897,6 +904,7 @@
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */,
+ 88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */,
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -561,7 +561,7 @@ private struct PuzzleDisplayView: View {
roster = nil
loadError = nil
loadingMessage = "Loading puzzle..."
- updateActiveNotificationPuzzleID(for: scenePhase)
+ noteSessionPhase(scenePhase)
Task { await services.badge.dismissDeliveredNotifications(for: gameID) }
// Tapping an `.invite` ping notification navigates here at once,
@@ -658,7 +658,7 @@ private struct PuzzleDisplayView: View {
Task { await activateSharing(for: session) }
}
.onChange(of: scenePhase) { _, newPhase in
- updateActiveNotificationPuzzleID(for: newPhase)
+ noteSessionPhase(newPhase)
// Only act on settled transitions. `.inactive` is transient (lock
// animation, app switcher, Control Center, banners), so a write
// there would thrash the Player record on every lock/unlock.
@@ -694,17 +694,17 @@ private struct PuzzleDisplayView: View {
.onDisappear {
openPuzzleFollowUpTask?.cancel()
openPuzzleFollowUpTask = nil
- NotificationState.clearActivePuzzleID(if: gameID)
let selectionPublisher = services.playerSelectionPublisher
let movesUpdater = services.movesUpdater
let id = gameID
- // Navigating away is a leave: commit the catch-up baseline and drop
- // any pending banner timer (idempotent with the .background path).
- services.sessions.handlePuzzleLeft(gameID: id)
+ // Navigating away is a leave: clear the active-puzzle ID, commit
+ // the catch-up baseline, and drop a begin push still inside its
+ // grace (idempotent with the .background path). `owesEndPush` is
+ // false when the visit never outlasted the begin grace — the
+ // session was never announced to peers, so there's no play
+ // session to pause on the way out.
+ let owesEndPush = services.sessions.notePuzzleClosed(gameID: id)
services.engagement.scheduleEngagementEnd(gameID: id)
- // A visit that never outlasted the begin grace was never announced
- // to peers, so there's no play session to pause on the way out.
- let neverAnnounced = services.sessions.cancelPendingSessionBeginPush(gameID: id)
Task {
await movesUpdater.flush()
// The clear-cursor and close-lease writes both enqueue without
@@ -716,7 +716,7 @@ private struct PuzzleDisplayView: View {
// Backgrounding may have already drained the tracker; the
// skip-if-zero guard inside publishSessionEndPush keeps the
// close-after-background case from firing a second push.
- if !neverAnnounced {
+ if owesEndPush {
await services.sessions.publishSessionEndPush(gameID: id)
}
}
@@ -758,43 +758,15 @@ private struct PuzzleDisplayView: View {
}
}
- private func updateActiveNotificationPuzzleID(for phase: ScenePhase) {
- let id = gameID
+ /// Forwards settled scene phases to the session controller, which owns
+ /// the begin/end/grace choreography (active-puzzle ID, deferred play and
+ /// pause pushes, catch-up banner).
+ private func noteSessionPhase(_ phase: ScenePhase) {
switch phase {
case .active:
- NotificationState.setActivePuzzleID(gameID)
- // If a pause was queued in the grace window and the user
- // returned before it fired, drop it and skip the matching play
- // push — peers should see one continuous session, not a
- // pause/play pair, for a brief absence (phone sleep, a call,
- // app-switcher peek that escalated to background).
- let resumed = services.sessions.cancelPendingSessionEndPush(gameID: id)
- // Schedules the catch-up banner after a short settle (open or
- // resume); the matching commit happens on leave, below.
- services.sessions.handlePuzzleOpened(gameID: id)
- if !resumed {
- // Defer the play push so a quick pop-in/pop-out reaches no one;
- // backing out before the grace elapses cancels it below.
- services.sessions.scheduleSessionBeginPush(
- gameID: id,
- after: SessionCoordinator.sessionBeginGrace
- )
- }
+ services.sessions.notePuzzleActive(gameID: gameID)
case .background:
- NotificationState.clearActivePuzzleID(if: gameID)
- // Leaving the puzzle: commit the catch-up baseline (the user has
- // seen what's on screen) and drop a pending banner timer.
- services.sessions.handlePuzzleLeft(gameID: id)
- // A session still inside its begin grace was never announced, so
- // there's nothing to pause — drop the deferred play push and skip
- // the matching pause rather than firing an unpaired one.
- if services.sessions.cancelPendingSessionBeginPush(gameID: id) {
- break
- }
- services.sessions.scheduleSessionEndPush(
- gameID: id,
- after: SessionCoordinator.sessionEndGrace
- )
+ services.sessions.notePuzzleBackgrounded(gameID: gameID)
case .inactive:
// Transient (lock animation, app switcher, Control Center,
// banners) — the user is still on the puzzle. Toggling the
diff --git a/Crossmate/Services/PuzzleSession.swift b/Crossmate/Services/PuzzleSession.swift
@@ -0,0 +1,236 @@
+import Foundation
+import UIKit
+
+/// Per-open-game session state machine. Owns every timer and the announced
+/// state for one game — the begin-grace timer, the end-grace timer with its
+/// background-execution assertion, the catch-up-banner settle timer, and the
+/// "play announced, pause not yet sent" flag — so the begin/end/grace
+/// interleavings live (and are testable) in one place instead of being spread
+/// across per-game dictionaries and view lifecycle handlers.
+///
+/// `SessionCoordinator` creates one per open game, injects the side effects
+/// (the actual pushes and the banner), and prunes the session once it is
+/// idle. Tests inject stub effects and a manual sleep to drive the
+/// interleavings deterministically.
+@MainActor
+final class PuzzleSession {
+ /// Side effects, injected by `SessionCoordinator` and stubbed in tests.
+ struct Effects {
+ /// Sender-side "is solving" push —
+ /// `SessionCoordinator.publishSessionBeginPush(gameID:)`.
+ var publishBegin: @MainActor () async -> Void
+ /// Sender-side pause push, carrying the wall-clock captured when the
+ /// grace timer was scheduled —
+ /// `SessionCoordinator.publishSessionEndPush(gameID:pauseStart:)`.
+ var publishEnd: @MainActor (Date) async -> Void
+ /// Posts the receiver-side catch-up banner —
+ /// `SessionCoordinator.postSessionSummaryBanner(gameID:reason:)`.
+ var postSummaryBanner: @MainActor () -> Void
+ /// Diagnostics breadcrumb.
+ var note: @MainActor (String) -> Void
+ /// Takes a background-execution assertion (UIApplication in
+ /// production) whose expiration handler fires the pause early when
+ /// iOS is about to reclaim the app before the grace elapses.
+ var beginBackgroundAssertion: @MainActor (String, @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier
+ /// Releases an assertion taken by `beginBackgroundAssertion`.
+ var endBackgroundAssertion: @MainActor (UIBackgroundTaskIdentifier) -> Void
+ }
+
+ let gameID: UUID
+ private let effects: Effects
+ /// Injected so tests can gate the grace timers manually instead of racing
+ /// wall-clock timing; production uses `Task.sleep`.
+ private let sleep: @Sendable (Duration) async throws -> Void
+
+ /// When the still-open session was announced to peers (a "play" push went
+ /// out) and no "pause" has been sent since; `nil` re-arms the begin push.
+ /// The begin push consults this so "is solving" fires once per session: a
+ /// brief background bounce that never produced a stop must not
+ /// re-announce. Keyed off the *sent stop* — `noteEndAnnounced` is called
+ /// when a pause is actually published — not off a pending timer, so it
+ /// stays accurate even when a stop carries no unseen content.
+ private(set) var announcedAt: Date?
+ private var pendingBeginTask: Task<Void, Never>?
+ private var pendingEndTask: Task<Void, Never>?
+ private var pendingSummaryBannerTask: Task<Void, Never>?
+ /// Background-execution assertion keeping the end-grace timer alive after
+ /// the app is backgrounded. iOS grants only a limited budget (often well
+ /// under the grace), so the assertion's expiration handler fires the
+ /// pause early rather than letting suspension drop it.
+ private var endAssertion: UIBackgroundTaskIdentifier = .invalid
+
+ /// True when nothing keeps this session alive: no timer pending, no
+ /// assertion held, and no announced play session awaiting its pause.
+ /// `SessionCoordinator` prunes idle sessions from its per-game map.
+ var isIdle: Bool {
+ announcedAt == nil
+ && pendingBeginTask == nil
+ && pendingEndTask == nil
+ && pendingSummaryBannerTask == nil
+ && endAssertion == .invalid
+ }
+
+ init(
+ gameID: UUID,
+ effects: Effects,
+ sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
+ ) {
+ self.gameID = gameID
+ self.effects = effects
+ self.sleep = sleep
+ }
+
+ // MARK: Announced state
+
+ /// True when a begin push should fire: this session isn't already
+ /// announced (see `announcedAt`).
+ var shouldAnnounceBegin: Bool { announcedAt == nil }
+
+ /// Record that a "play" push has been sent, stamping when the session was
+ /// announced so a later pause can report its start.
+ func noteBeginAnnounced(at time: Date = Date()) {
+ announcedAt = time
+ }
+
+ /// Record that a "pause" push has been sent, re-arming the next begin push.
+ func noteEndAnnounced() {
+ announcedAt = nil
+ }
+
+ // MARK: Begin grace
+
+ /// Defer the session-begin push by `seconds`, replacing any pending
+ /// timer. The opening device owns the notification timing, but a brief
+ /// visit shouldn't reach peers at all, so the "Alice is solving X" push
+ /// waits out the grace; backing out before it elapses (app backgrounded,
+ /// or the puzzle view disappearing) calls `cancelPendingBeginPush` to
+ /// drop it silently. Unlike the pause timer this holds no background
+ /// assertion: the user is foregrounded and looking at the puzzle for the
+ /// whole window, and any departure cancels.
+ func scheduleBeginPush(after seconds: TimeInterval) {
+ pendingBeginTask?.cancel()
+ let sleep = self.sleep
+ pendingBeginTask = Task { [weak self] in
+ try? await sleep(.seconds(seconds))
+ guard !Task.isCancelled, let self else { return }
+ self.pendingBeginTask = nil
+ await self.effects.publishBegin()
+ }
+ }
+
+ /// Drop a begin push still waiting out its grace window. Returns whether
+ /// one was pending — i.e. the session was never announced to peers — so
+ /// the caller can skip the matching pause push (there's no play to pause).
+ @discardableResult
+ func cancelPendingBeginPush() -> Bool {
+ guard let task = pendingBeginTask else { return false }
+ pendingBeginTask = nil
+ task.cancel()
+ return true
+ }
+
+ // MARK: End grace
+
+ /// Defer the session-end push by `seconds`, replacing any previously
+ /// scheduled pause. If the user resumes within the grace window, call
+ /// `cancelPendingEndPush` to drop the timer and skip the matching
+ /// session-begin push so peers don't get a pause/play pair for a brief
+ /// absence. The wall-clock at scheduling time is passed through so the
+ /// fire-time peer-device-active check has a stable reference point for
+ /// "did anyone other than me write to Player during the grace window."
+ ///
+ /// Holds a background-execution assertion so the grace timer keeps
+ /// running once the app is backgrounded. If iOS is about to reclaim us
+ /// before the timer elapses, the expiration handler fires the pause
+ /// early (best effort) instead of letting suspension drop it.
+ func scheduleEndPush(after seconds: TimeInterval) {
+ let pauseStart = Date()
+ cancelPendingEndPush()
+ endAssertion = effects.beginBackgroundAssertion(
+ "session-end-\(gameID.uuidString)"
+ ) { [weak self] in
+ self?.fireEndPush(pauseStart: pauseStart, expedited: true)
+ }
+ let sleep = self.sleep
+ pendingEndTask = Task { [weak self] in
+ try? await sleep(.seconds(seconds))
+ guard !Task.isCancelled else { return }
+ self?.fireEndPush(pauseStart: pauseStart, expedited: false)
+ }
+ }
+
+ /// Cancel any pending scheduled session-end push, releasing the
+ /// background assertion. Returns `true` if a pending timer was dropped,
+ /// i.e. the caller is inside the grace window (a resume) and should
+ /// suppress the matching session-begin push.
+ @discardableResult
+ func cancelPendingEndPush() -> Bool {
+ releaseEndAssertion()
+ guard let task = pendingEndTask else { return false }
+ pendingEndTask = nil
+ task.cancel()
+ return true
+ }
+
+ /// A direct publish (e.g. from `onDisappear`) supersedes any pending
+ /// grace-window timer — drop it so the timer can't fire a second pause
+ /// once it elapses. Unlike `cancelPendingEndPush` this leaves the
+ /// background assertion alone; the publish in flight is what it is
+ /// keeping alive, and its expiration latch releases it.
+ func supersedePendingEndPush() {
+ pendingEndTask?.cancel()
+ pendingEndTask = nil
+ }
+
+ /// Single fire path for the deferred session-end push, shared by the
+ /// grace timer and the background-assertion expiration handler. The
+ /// pending-task slot doubles as a "not yet fired" flag, so this is
+ /// idempotent: whichever caller wins clears it, and the loser falls
+ /// through to releasing the assertion only. `expedited` marks the early
+ /// fire forced by an imminent suspension, purely for diagnostics.
+ private func fireEndPush(pauseStart: Date, expedited: Bool) {
+ guard let task = pendingEndTask else {
+ releaseEndAssertion()
+ return
+ }
+ pendingEndTask = nil
+ task.cancel()
+ if expedited {
+ effects.note("push(pause): firing early (background expiring)")
+ }
+ Task { [weak self] in
+ guard let self else { return }
+ await self.effects.publishEnd(pauseStart)
+ self.releaseEndAssertion()
+ }
+ }
+
+ /// Releases the background-execution assertion, if one is held. Safe to
+ /// call repeatedly — a released slot is a no-op.
+ private func releaseEndAssertion() {
+ guard endAssertion != .invalid else { return }
+ let id = endAssertion
+ endAssertion = .invalid
+ effects.endBackgroundAssertion(id)
+ }
+
+ // MARK: Catch-up banner
+
+ /// Defer the catch-up banner by `seconds`, replacing any pending timer.
+ /// Leaving the puzzle cancels it.
+ func scheduleSummaryBanner(after seconds: TimeInterval) {
+ pendingSummaryBannerTask?.cancel()
+ let sleep = self.sleep
+ pendingSummaryBannerTask = Task { [weak self] in
+ try? await sleep(.seconds(seconds))
+ guard !Task.isCancelled, let self else { return }
+ self.pendingSummaryBannerTask = nil
+ self.effects.postSummaryBanner()
+ }
+ }
+
+ func cancelPendingSummaryBanner() {
+ pendingSummaryBannerTask?.cancel()
+ pendingSummaryBannerTask = nil
+ }
+}
diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift
@@ -3,13 +3,14 @@ import CloudKit
import Foundation
import UIKit
-/// Owns the per-game play-session lifecycle that used to live in
-/// `AppServices`: the begin/end grace timers and their background-execution
-/// assertions, the sender-side session pushes (play / pause / win / resign /
-/// replay) with their once-per-session announcement state, and the
-/// receiver-side catch-up banner posted when a puzzle is opened. `AppServices`
-/// composes one instance; `CrossmateApp`'s scene-phase and puzzle-lifecycle
-/// handlers drive it directly.
+/// Owns the play-session lifecycle: the sender-side session pushes
+/// (play / pause / win / resign / replay) and the receiver-side catch-up
+/// banner, driven by three lifecycle events — `notePuzzleActive`,
+/// `notePuzzleBackgrounded`, `notePuzzleClosed` — that `CrossmateApp`'s
+/// scene-phase and `onDisappear` handlers forward. The per-game timer and
+/// announced state lives in one `PuzzleSession` state machine per open game,
+/// so the begin/end/grace interleavings are decided (and testable) in one
+/// place. `AppServices` composes one instance.
@MainActor
final class SessionCoordinator {
/// Grace window before opening a puzzle is announced to peers as a play
@@ -39,18 +40,11 @@ final class SessionCoordinator {
private let preferences: PlayerPreferences
private let pushClient: PushClient?
- private var pendingSessionBeginTasks: [UUID: Task<Void, Never>] = [:]
- private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:]
- private var pendingSessionSummaryBannerTasks: [UUID: Task<Void, Never>] = [:]
- /// Background-execution assertions keeping the matching grace timer alive
- /// after the app is backgrounded. iOS grants only a limited budget (often
- /// well under `sessionEndGrace`), so the assertion's expiration handler
- /// fires the pause early rather than letting suspension drop it. Keyed by
- /// game so a per-game timer owns exactly one assertion.
- private var sessionEndBackgroundTasks: [UUID: UIBackgroundTaskIdentifier] = [:]
- /// Per-game "session announced to peers" state machine driving the
- /// once-per-session begin push; see `SessionAnnouncementLog`.
- private var sessionAnnouncements = SessionAnnouncementLog()
+ /// Per-open-game session state machines — each owns its game's grace
+ /// timers, background assertion, banner timer, and announced state.
+ /// Created on the first event for a game and pruned once idle; see
+ /// `PuzzleSession`.
+ private var sessions: [UUID: PuzzleSession] = [:]
init(
persistence: PersistenceController,
@@ -74,6 +68,106 @@ final class SessionCoordinator {
self.pushClient = pushClient
}
+ // MARK: Per-game sessions
+
+ /// The session state machine for `gameID`, created on demand with its
+ /// effects wired back into this coordinator's push and banner paths.
+ private func session(for gameID: UUID) -> PuzzleSession {
+ if let existing = sessions[gameID] { return existing }
+ let session = PuzzleSession(
+ gameID: gameID,
+ effects: PuzzleSession.Effects(
+ publishBegin: { [weak self] in
+ await self?.publishSessionBeginPush(gameID: gameID)
+ },
+ publishEnd: { [weak self] pauseStart in
+ await self?.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart)
+ },
+ postSummaryBanner: { [weak self] in
+ self?.postSessionSummaryBanner(gameID: gameID, reason: "open")
+ },
+ note: { [weak self] message in
+ self?.syncMonitor.note(message)
+ },
+ beginBackgroundAssertion: { name, onExpiration in
+ UIApplication.shared.beginBackgroundTask(
+ withName: name,
+ expirationHandler: onExpiration
+ )
+ },
+ endBackgroundAssertion: { id in
+ UIApplication.shared.endBackgroundTask(id)
+ }
+ )
+ )
+ sessions[gameID] = session
+ return session
+ }
+
+ /// Drops `gameID`'s session once nothing keeps it alive (no pending
+ /// timer, no assertion held, no announced play session awaiting its
+ /// pause). Run after each lifecycle event and after a pause publish, so
+ /// the map only holds games with live session state.
+ private func pruneIfIdle(_ gameID: UUID) {
+ guard let session = sessions[gameID], session.isIdle else { return }
+ sessions[gameID] = nil
+ }
+
+ // MARK: Puzzle lifecycle events
+
+ /// The puzzle is on screen and the scene is active (open or resume).
+ /// Stamps the active-puzzle ID for notification suppression. If a pause
+ /// was queued in the grace window and the user returned before it fired,
+ /// drops it and skips the matching play push — peers should see one
+ /// continuous session, not a pause/play pair, for a brief absence (phone
+ /// sleep, a call, an app-switcher peek that escalated to background).
+ /// Otherwise defers the play push by the begin grace so a quick
+ /// pop-in/pop-out reaches no one. Either way, schedules the catch-up
+ /// banner after a short settle; the matching baseline commit happens on
+ /// leave.
+ func notePuzzleActive(gameID: UUID) {
+ NotificationState.setActivePuzzleID(gameID)
+ let session = session(for: gameID)
+ let resumed = session.cancelPendingEndPush()
+ handlePuzzleOpened(gameID: gameID)
+ if !resumed {
+ session.scheduleBeginPush(after: Self.sessionBeginGrace)
+ }
+ }
+
+ /// The app backgrounded while the puzzle is open. Clears the
+ /// active-puzzle ID and commits the catch-up baseline (the user has seen
+ /// what's on screen). A session still inside its begin grace was never
+ /// announced, so there's nothing to pause — drop the deferred play push
+ /// and skip the matching pause rather than firing an unpaired one;
+ /// otherwise defer the pause by the end grace under a background
+ /// assertion.
+ func notePuzzleBackgrounded(gameID: UUID) {
+ NotificationState.clearActivePuzzleID(if: gameID)
+ handlePuzzleLeft(gameID: gameID)
+ let session = session(for: gameID)
+ if session.cancelPendingBeginPush() {
+ pruneIfIdle(gameID)
+ return
+ }
+ session.scheduleEndPush(after: Self.sessionEndGrace)
+ }
+
+ /// The user navigated away from the puzzle (`onDisappear`). Same commit
+ /// as backgrounding, plus the begin-grace check: returns whether a
+ /// session-end push is owed — `false` when the visit never outlasted the
+ /// begin grace, so there is no play session to pause. The caller
+ /// sequences `publishSessionEndPush` after the moves flush: the pause
+ /// counts read the journal, so the buffered cell edits must land first.
+ @discardableResult
+ func notePuzzleClosed(gameID: UUID) -> Bool {
+ NotificationState.clearActivePuzzleID(if: gameID)
+ handlePuzzleLeft(gameID: gameID)
+ let neverAnnounced = sessions[gameID]?.cancelPendingBeginPush() ?? false
+ pruneIfIdle(gameID)
+ return !neverAnnounced
+ }
+
/// Completion fan-out, delivered through the push worker. Win sets
/// `completedAt`/`completedBy` on the local Game record; resign leaves
/// `completedBy` nil and reveals the remaining cells through the Moves
@@ -83,41 +177,11 @@ final class SessionCoordinator {
await publishReplayPush(gameID: gameID)
}
- /// Defer the session-begin push by `seconds`, replacing any pending timer
- /// for the same game. The opening device owns the notification timing, but
- /// a brief visit shouldn't reach peers at all, so the "Alice is solving X"
- /// push waits out `sessionBeginGrace`; backing out before it elapses (app
- /// backgrounded, or the puzzle view disappearing) calls
- /// `cancelPendingSessionBeginPush` to drop it silently. Unlike the pause
- /// timer this holds no background assertion: the user is foregrounded and
- /// looking at the puzzle for the whole window, and any departure cancels.
- func scheduleSessionBeginPush(gameID: UUID, after seconds: TimeInterval) {
- pendingSessionBeginTasks.removeValue(forKey: gameID)?.cancel()
- pendingSessionBeginTasks[gameID] = Task { [weak self] in
- try? await Task.sleep(for: .seconds(seconds))
- guard !Task.isCancelled, let self else { return }
- self.pendingSessionBeginTasks.removeValue(forKey: gameID)
- await self.publishSessionBeginPush(gameID: gameID)
- }
- }
-
- /// Drop a begin push still waiting out its grace window. Returns whether
- /// one was pending — i.e. the session was never announced to peers — so
- /// the caller can skip the matching pause push (there's no play to pause).
- @discardableResult
- func cancelPendingSessionBeginPush(gameID: UUID) -> Bool {
- guard let task = pendingSessionBeginTasks.removeValue(forKey: gameID) else {
- return false
- }
- task.cancel()
- return true
- }
-
/// Sender-side session-begin push. Peers get "Alice is solving X" once the
/// open has outlasted `sessionBeginGrace` (see `scheduleSessionBeginPush`).
/// Kind is `play` because the user-facing event is "started playing", not
/// the durable share-acceptance fact "joined".
- func publishSessionBeginPush(gameID: UUID) async {
+ private func publishSessionBeginPush(gameID: UUID) async {
guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
syncMonitor.note("push(play): skipped (no authorID)")
return
@@ -126,7 +190,7 @@ final class SessionCoordinator {
// we're inside the same continuous session (e.g. a brief background
// bounce). Re-announcing "is solving" here is the notification spam
// we're suppressing — the next genuine stop clears this.
- guard sessionAnnouncements.shouldAnnounceBegin(gameID) else {
+ guard sessions[gameID]?.shouldAnnounceBegin ?? true else {
syncMonitor.note("push(play): skipped (session already announced)")
return
}
@@ -171,77 +235,7 @@ final class SessionCoordinator {
)
// Session is now announced to peers; a "play" won't fire again until a
// "pause" is actually sent (see `publishSessionEndPush`).
- sessionAnnouncements.noteBeginAnnounced(gameID)
- }
-
- /// Defer the session-end push by `seconds`. Cancels any previously
- /// scheduled pause for the same game. If the user resumes within the
- /// grace window, call `cancelPendingSessionEndPush` to drop the timer
- /// and skip the matching session-begin push so peers don't get a
- /// pause/play pair for a brief absence. The wall-clock at scheduling
- /// time is passed through so the fire-time peer-device-active check has
- /// a stable reference point for "did anyone other than me write to
- /// Player during the grace window."
- func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) {
- let pauseStart = Date()
- cancelPendingSessionEndPush(gameID: gameID)
- // Hold a background-execution assertion so the grace timer keeps
- // running once the app is backgrounded. If iOS is about to reclaim
- // us before the timer elapses, the expiration handler fires the
- // pause early (best effort) instead of letting suspension drop it.
- sessionEndBackgroundTasks[gameID] = UIApplication.shared.beginBackgroundTask(
- withName: "session-end-\(gameID.uuidString)"
- ) { [weak self] in
- self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: true)
- }
- pendingSessionEndTasks[gameID] = Task { [weak self] in
- try? await Task.sleep(for: .seconds(seconds))
- guard !Task.isCancelled else { return }
- self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: false)
- }
- }
-
- /// Single fire path for the deferred session-end push, shared by the grace
- /// timer and the background-assertion expiration handler. The pending-task
- /// entry doubles as a "not yet fired" flag, so this is idempotent: whichever
- /// caller wins removes it, and the loser falls through to releasing the
- /// assertion only. `expedited` marks the early fire forced by an imminent
- /// suspension, purely for diagnostics.
- private func fireSessionEndPush(gameID: UUID, pauseStart: Date, expedited: Bool) {
- guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else {
- endSessionEndBackgroundTask(gameID: gameID)
- return
- }
- task.cancel()
- if expedited {
- syncMonitor.note("push(pause): firing early (background expiring)")
- }
- Task { [weak self] in
- guard let self else { return }
- await self.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart)
- self.endSessionEndBackgroundTask(gameID: gameID)
- }
- }
-
- /// Releases the background-execution assertion for `gameID`, if one is
- /// held. Safe to call repeatedly — a missing entry is a no-op.
- private func endSessionEndBackgroundTask(gameID: UUID) {
- guard let id = sessionEndBackgroundTasks.removeValue(forKey: gameID),
- id != .invalid else { return }
- UIApplication.shared.endBackgroundTask(id)
- }
-
- /// Cancel any pending scheduled session-end push for `gameID`. Returns
- /// `true` if a pending task was dropped, i.e. the caller is inside the
- /// grace window and should suppress the matching session-begin push.
- @discardableResult
- func cancelPendingSessionEndPush(gameID: UUID) -> Bool {
- endSessionEndBackgroundTask(gameID: gameID)
- guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else {
- return false
- }
- task.cancel()
- return true
+ session(for: gameID).noteBeginAnnounced()
}
/// Sender-side session-end push. For each recipient, counts cells in
@@ -258,7 +252,7 @@ final class SessionCoordinator {
// A direct call (e.g. from `.onDisappear`) supersedes any pending
// grace-window timer for this game — drop it so we don't fire a
// second pause once the timer elapses.
- pendingSessionEndTasks.removeValue(forKey: gameID)?.cancel()
+ sessions[gameID]?.supersedePendingEndPush()
guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
syncMonitor.note("push(pause): skipped (no authorID)")
return
@@ -305,7 +299,7 @@ final class SessionCoordinator {
var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID)
?? PushPayload.Diagnostics()
diagnostics.senderNow = Date()
- diagnostics.sessionStart = sessionAnnouncements.beginTime(gameID)
+ diagnostics.sessionStart = sessions[gameID]?.announcedAt
// Caught-up recipients are *not* dropped: a session end is a presence
// signal worth delivering even with nothing unseen (see
// `SessionPushPlanner.sessionEndAddressees`).
@@ -340,7 +334,7 @@ final class SessionCoordinator {
)
// Peers have now been told the session ended, so a fresh "play" is
// allowed again (see `publishSessionBeginPush`).
- sessionAnnouncements.noteEndAnnounced(gameID)
+ sessions[gameID]?.noteEndAnnounced()
// Advance each addressed recipient's notified-through watermark to the
// latest move this pause reported. A later pause windows its counts to
// the later of this and the recipient's readAt, so a bounce that adds
@@ -353,6 +347,9 @@ final class SessionCoordinator {
.map(\.authorID)
store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough)
}
+ // The pause closed the session; if no timer or assertion is live
+ // either, the per-game state machine has nothing left to hold.
+ pruneIfIdle(gameID)
}
private func publishCompletionPush(gameID: UUID, resigned: Bool) async {
@@ -486,14 +483,14 @@ final class SessionCoordinator {
/// transient announcement on the puzzle header, in lieu of the
/// local notification that would otherwise have fired in a few
/// minutes' time. No-op if nothing was accumulated.
- func handlePuzzleOpened(gameID: UUID) {
+ private func handlePuzzleOpened(gameID: UUID) {
logLocalPauseDiagnostics(for: gameID)
// Defer the banner so the open's `.appeared` grid freshen can land peer
// moves first; otherwise it would diff against a half-synced grid and
// under-report. The baseline is not touched here — it advances only on
// leave (`handlePuzzleLeft`) — so this is a pure read and re-running it
// on a later foreground is harmless.
- scheduleSessionSummaryBanner(gameID: gameID, after: Self.sessionSummaryBannerDelay)
+ session(for: gameID).scheduleSummaryBanner(after: Self.sessionSummaryBannerDelay)
}
/// Called when the user leaves the puzzle (backgrounded or navigated away).
@@ -503,8 +500,8 @@ final class SessionCoordinator {
/// account's own `Player.sessionSnapshot`, so they adopt it rather than
/// recomputing from their own view. Returns the committed snapshots.
@discardableResult
- func handlePuzzleLeft(gameID: UUID) -> [String: LocalMovesSnapshot] {
- cancelPendingSessionSummaryBanner(gameID: gameID)
+ private func handlePuzzleLeft(gameID: UUID) -> [String: LocalMovesSnapshot] {
+ sessions[gameID]?.cancelPendingSummaryBanner()
let committed = sessionMonitor.commitMovesBaseline(for: gameID)
guard let authorID = identity.currentID, !authorID.isEmpty,
!committed.isEmpty,
@@ -522,28 +519,12 @@ final class SessionCoordinator {
return committed
}
- /// Defer the catch-up banner by `seconds`, replacing any pending timer for
- /// the same game. Leaving the puzzle (`handlePuzzleLeft`) cancels it.
- func scheduleSessionSummaryBanner(gameID: UUID, after seconds: TimeInterval) {
- pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel()
- pendingSessionSummaryBannerTasks[gameID] = Task { [weak self] in
- try? await Task.sleep(for: .seconds(seconds))
- guard !Task.isCancelled, let self else { return }
- self.pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)
- self.postSessionSummaryBanner(gameID: gameID, reason: "open")
- }
- }
-
- func cancelPendingSessionSummaryBanner(gameID: UUID) {
- pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel()
- }
-
/// Computes the receiver-side catch-up summary for `gameID` and, when a peer
/// has unseen activity, posts (or replaces, by stable id) the "Puzzle
/// Updated" banner. Read-only — the baseline advances on leave, not here —
/// so it is safe to recompute on every foreground. Logs the per-peer counts
/// it surfaces so a missing or wrong banner is diagnosable after the fact.
- func postSessionSummaryBanner(gameID: UUID, reason: String) {
+ private func postSessionSummaryBanner(gameID: UUID, reason: String) {
let summaries = sessionMonitor.movesSummaries(for: gameID)
guard !summaries.isEmpty else { return }
let detail = summaries.map { summary -> String in
diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift
@@ -5,41 +5,6 @@ import Foundation
/// stack. `AppServices` still owns the state and the network call; these types
/// only decide *whether* and *what* to send.
-/// Per-game record of whether a live play session has been announced to peers
-/// (a "play" push went out) and not yet closed by a "pause" push.
-///
-/// The begin push consults this so "is solving" fires once per session: a
-/// brief background bounce that never produced a stop must not re-announce.
-/// State is keyed off the *sent stop* — `noteEndAnnounced` is called when a
-/// pause is actually published — not off a pending timer, so it stays accurate
-/// even when a stop carries no unseen content.
-struct SessionAnnouncementLog {
- private var announced: [UUID: Date] = [:]
-
- /// True when a begin push should fire: this session isn't already announced.
- func shouldAnnounceBegin(_ gameID: UUID) -> Bool {
- announced[gameID] == nil
- }
-
- /// Record that a "play" push has been sent for `gameID`, stamping when the
- /// session was announced so a later pause can report its start.
- mutating func noteBeginAnnounced(_ gameID: UUID, at time: Date = Date()) {
- announced[gameID] = time
- }
-
- /// Record that a "pause" push has been sent for `gameID`, re-arming the
- /// next begin push.
- mutating func noteEndAnnounced(_ gameID: UUID) {
- announced.removeValue(forKey: gameID)
- }
-
- /// When the still-open session for `gameID` was announced, or `nil` when no
- /// begin push is outstanding (e.g. solo play that never announced one).
- func beginTime(_ gameID: UUID) -> Date? {
- announced[gameID]
- }
-}
-
/// Everything a sender-side push helper needs to know about a game in
/// one Core Data round-trip: the roster authors to notify (each with the
/// last-known `Player.readAt` so the pause path can compute a
diff --git a/Tests/Unit/PuzzleSessionTests.swift b/Tests/Unit/PuzzleSessionTests.swift
@@ -0,0 +1,233 @@
+import Foundation
+import Testing
+import UIKit
+
+@testable import Crossmate
+
+@Suite("PuzzleSession", .serialized)
+@MainActor
+struct PuzzleSessionTests {
+ /// Records every effect firing so tests can assert which pushes a given
+ /// interleaving produced, and drive the background-assertion expiration
+ /// handler by hand.
+ @MainActor
+ final class EffectLog {
+ private(set) var begins = 0
+ private(set) var ends: [Date] = []
+ private(set) var banners = 0
+ private(set) var notes: [String] = []
+ private(set) var assertionNames: [String] = []
+ private(set) var releasedAssertions: [UIBackgroundTaskIdentifier] = []
+ private(set) var expirationHandler: (@MainActor () -> Void)?
+
+ var effects: PuzzleSession.Effects {
+ PuzzleSession.Effects(
+ publishBegin: { self.begins += 1 },
+ publishEnd: { self.ends.append($0) },
+ postSummaryBanner: { self.banners += 1 },
+ note: { self.notes.append($0) },
+ beginBackgroundAssertion: { name, onExpiration in
+ self.assertionNames.append(name)
+ self.expirationHandler = onExpiration
+ return UIBackgroundTaskIdentifier(rawValue: self.assertionNames.count)
+ },
+ endBackgroundAssertion: { self.releasedAssertions.append($0) }
+ )
+ }
+ }
+
+ private func waitUntil(
+ timeout: Duration = .seconds(5),
+ _ condition: () -> Bool
+ ) async throws {
+ let deadline = ContinuousClock.now.advanced(by: timeout)
+ while !condition(), ContinuousClock.now < deadline {
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ #expect(condition())
+ }
+
+ @Test("Begin push fires once the grace elapses")
+ func beginPushFiresAfterGrace() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleBeginPush(after: 10)
+ #expect(!session.isIdle)
+ try await sleeps.waitForSleeperCount(1)
+ #expect(log.begins == 0) // still inside the grace
+
+ sleeps.releaseAll()
+ try await waitUntil { log.begins == 1 }
+ // The stubbed publish never announced, so nothing holds the session.
+ #expect(session.isIdle)
+ }
+
+ @Test("Backing out inside the begin grace suppresses the play push")
+ func cancelInsideBeginGrace() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleBeginPush(after: 10)
+ try await sleeps.waitForSleeperCount(1)
+ #expect(session.cancelPendingBeginPush()) // true: never announced
+ #expect(!session.cancelPendingBeginPush()) // nothing left to drop
+
+ sleeps.releaseAll()
+ try await Task.sleep(for: .milliseconds(120))
+ #expect(log.begins == 0)
+ #expect(session.isIdle)
+ }
+
+ @Test("Rescheduling the begin push replaces the pending timer")
+ func rescheduleReplacesPendingBegin() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleBeginPush(after: 10)
+ try await sleeps.waitForSleeperCount(1)
+ session.scheduleBeginPush(after: 10)
+ try await sleeps.waitForSleeperCount(2)
+
+ sleeps.releaseAll()
+ try await waitUntil { log.begins == 1 }
+ try await Task.sleep(for: .milliseconds(120))
+ #expect(log.begins == 1) // the replaced timer never fires
+ }
+
+ @Test("End push fires with the pause start captured at scheduling time")
+ func endPushFiresWithScheduledPauseStart() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ let before = Date()
+ session.scheduleEndPush(after: 30)
+ let after = Date()
+ #expect(log.assertionNames.count == 1)
+ try await sleeps.waitForSleeperCount(1)
+
+ sleeps.releaseAll()
+ try await waitUntil { log.ends.count == 1 }
+ let pauseStart = try #require(log.ends.first)
+ #expect(pauseStart >= before && pauseStart <= after)
+ // The assertion is released only after the publish completes.
+ try await waitUntil { log.releasedAssertions.count == 1 }
+ #expect(session.isIdle)
+ }
+
+ @Test("Resuming inside the end grace cancels the pause and frees the assertion")
+ func resumeInsideEndGrace() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleEndPush(after: 30)
+ try await sleeps.waitForSleeperCount(1)
+ #expect(session.cancelPendingEndPush()) // true: this is a resume
+ #expect(log.releasedAssertions.count == 1)
+
+ sleeps.releaseAll()
+ try await Task.sleep(for: .milliseconds(120))
+ #expect(log.ends.isEmpty)
+ #expect(session.isIdle)
+ }
+
+ @Test("Assertion expiration fires the pause early, exactly once")
+ func expirationFiresEndEarlyExactlyOnce() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleEndPush(after: 30)
+ try await sleeps.waitForSleeperCount(1)
+
+ // iOS reclaims the app before the grace elapses.
+ log.expirationHandler?()
+ try await waitUntil { log.ends.count == 1 }
+ #expect(log.notes.contains { $0.contains("firing early") })
+
+ // The grace timer wakes late; the fired latch swallows it.
+ sleeps.releaseAll()
+ try await Task.sleep(for: .milliseconds(120))
+ #expect(log.ends.count == 1)
+ try await waitUntil { log.releasedAssertions.count == 1 }
+ #expect(session.isIdle)
+ }
+
+ @Test("A direct publish supersedes the pending timer; the latch frees the assertion")
+ func supersedeDropsTimerWithoutPublishing() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleEndPush(after: 30)
+ try await sleeps.waitForSleeperCount(1)
+ session.supersedePendingEndPush()
+
+ sleeps.releaseAll()
+ try await Task.sleep(for: .milliseconds(120))
+ #expect(log.ends.isEmpty) // the timer never fires its own pause
+ // The assertion is deliberately left for the expiration latch (the
+ // direct publish it covers is still in flight at this point in
+ // production).
+ #expect(log.releasedAssertions.isEmpty)
+ #expect(!session.isIdle)
+
+ // The expiration latch finds no pending timer and only releases.
+ log.expirationHandler?()
+ #expect(log.ends.isEmpty)
+ #expect(log.releasedAssertions.count == 1)
+ #expect(session.isIdle)
+ }
+
+ @Test("Announced state gates the begin push and is re-armed by a sent stop")
+ func announcedStateLifecycle() {
+ let log = EffectLog()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects)
+ let start = Date(timeIntervalSince1970: 5_000)
+
+ #expect(session.shouldAnnounceBegin) // fresh session announces
+ #expect(session.announcedAt == nil)
+ session.noteBeginAnnounced(at: start)
+ #expect(!session.shouldAnnounceBegin) // re-entry within session: suppressed
+ #expect(session.announcedAt == start)
+ #expect(!session.isIdle) // an announced session awaits its pause
+ session.noteEndAnnounced()
+ #expect(session.shouldAnnounceBegin) // a stop was sent: re-armed
+ #expect(session.announcedAt == nil)
+ #expect(session.isIdle)
+ }
+
+ @Test("Catch-up banner fires after the settle delay")
+ func bannerFiresAfterSettle() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleSummaryBanner(after: 3)
+ try await sleeps.waitForSleeperCount(1)
+ sleeps.releaseAll()
+ try await waitUntil { log.banners == 1 }
+ #expect(session.isIdle)
+ }
+
+ @Test("Leaving cancels a pending catch-up banner")
+ func leaveCancelsPendingBanner() async throws {
+ let log = EffectLog()
+ let sleeps = ManualDebounceSleep()
+ let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn)
+
+ session.scheduleSummaryBanner(after: 3)
+ try await sleeps.waitForSleeperCount(1)
+ session.cancelPendingSummaryBanner()
+
+ sleeps.releaseAll()
+ try await Task.sleep(for: .milliseconds(120))
+ #expect(log.banners == 0)
+ #expect(session.isIdle)
+ }
+}
diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift
@@ -3,46 +3,6 @@ import Testing
@testable import Crossmate
-@Suite("Session announcement log")
-struct SessionAnnouncementLogTests {
- @Test("Begin fires once, then only again after a stop is recorded")
- func beginGateCycle() {
- var log = SessionAnnouncementLog()
- let game = UUID()
-
- #expect(log.shouldAnnounceBegin(game)) // fresh session announces
- log.noteBeginAnnounced(game)
- #expect(!log.shouldAnnounceBegin(game)) // re-entry within session: suppressed
- log.noteEndAnnounced(game)
- #expect(log.shouldAnnounceBegin(game)) // a stop was sent: re-armed
- }
-
- @Test("Games are tracked independently")
- func independentPerGame() {
- var log = SessionAnnouncementLog()
- let a = UUID()
- let b = UUID()
-
- log.noteBeginAnnounced(a)
-
- #expect(!log.shouldAnnounceBegin(a))
- #expect(log.shouldAnnounceBegin(b))
- }
-
- @Test("Begin time is recorded for the open session and cleared on stop")
- func beginTimeTracked() {
- var log = SessionAnnouncementLog()
- let game = UUID()
- let start = Date(timeIntervalSince1970: 5_000)
-
- #expect(log.beginTime(game) == nil)
- log.noteBeginAnnounced(game, at: start)
- #expect(log.beginTime(game) == start)
- log.noteEndAnnounced(game)
- #expect(log.beginTime(game) == nil)
- }
-}
-
@Suite("Session push planner")
struct SessionPushPlannerTests {
/// One journal entry. `seq` orders entries within a cell; `kind` and