commit 002eff09abc718780159be29e0c828fd829f578a
parent 3eac7a3ecace999445bbf690b467831b51276709
Author: Michael Camilleri <[email protected]>
Date: Sun, 31 May 2026 06:40:12 +0900
Defer the session-begin push behind a grace window
Opening a puzzle announced the play session to peers at once — the
instant a shared game became active, every other device got 'Alice is
solving X'. A user who popped in and straight back out (opened the wrong
puzzle, peeked at a shared grid, fat-fingered a row in the list) fanned
that push to everyone for a visit that never happened.
This commit mirrors the existing pause grace onto the begin side.
scheduleSessionBeginPush defers the play push by sessionBeginGrace (30s)
and cancelPendingSessionBeginPush drops a still-waiting one, reporting
whether it was pending so the caller can tell 'never announced' from
'announced, now leaving'. The scene-phase .active path schedules instead
of firing; backing out before the grace elapses — the app backgrounding
or the puzzle view disappearing — cancels the timer and, since there was
no play to pause, skips the matching pause push rather than firing an
unpaired one.
Unlike the pause timer this holds no background-execution assertion: the
user is foregrounded on the puzzle for the whole window, and any
departure cancels, so there is nothing to keep alive under suspension. A
visit that outlasts the grace announces exactly as before, 30s later.
The change is confined to the play push; the readAt presence lease and
engagement socket still arm on open.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 61 insertions(+), 10 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -617,6 +617,9 @@ private struct PuzzleDisplayView: View {
let syncEngine = services.syncEngine
let id = gameID
services.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.cancelPendingSessionBeginPush(gameID: id)
Task {
await movesUpdater.flush()
// Mirror the open-burst pattern: the clear-cursor and
@@ -632,7 +635,9 @@ 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.
- await services.publishSessionEndPush(gameID: id)
+ if !neverAnnounced {
+ await services.publishSessionEndPush(gameID: id)
+ }
}
}
}
@@ -682,14 +687,23 @@ private struct PuzzleDisplayView: View {
// pause/play pair, for a brief absence (phone sleep, a call,
// app-switcher peek that escalated to background).
let resumed = services.cancelPendingSessionEndPush(gameID: id)
- Task {
- services.handlePuzzleOpened(gameID: id)
- if !resumed {
- await services.publishSessionBeginPush(gameID: id)
- }
+ services.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.scheduleSessionBeginPush(
+ gameID: id,
+ after: AppServices.sessionBeginGrace
+ )
}
case .background:
NotificationState.clearActivePuzzleID(if: gameID)
+ // 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.cancelPendingSessionBeginPush(gameID: id) {
+ break
+ }
services.scheduleSessionEndPush(
gameID: id,
after: AppServices.sessionEndGrace
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -69,6 +69,13 @@ final class AppServices {
let playerSelectionPublisher: PlayerSelectionPublisher
let identity: AuthorIdentity
let pushClient: PushClient?
+ /// Grace window before opening a puzzle is announced to peers as a play
+ /// session. A user who pops in and out — opens the wrong puzzle, peeks at
+ /// a shared grid, then leaves — within this window should fan out nothing.
+ /// The begin push is deferred by this much and cancelled if the user backs
+ /// out (app backgrounded or navigated away) before it elapses.
+ static let sessionBeginGrace: TimeInterval = 30
+ private var pendingSessionBeginTasks: [UUID: Task<Void, Never>] = [:]
/// Grace window before a backgrounded session is treated as ended. A
/// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a
/// call) should not fan out pause/play pings to peers on every flicker —
@@ -645,10 +652,40 @@ final class AppServices {
pushClient.setAddresses(result.addresses)
}
- /// Sender-side session-begin push. The opening device owns the
- /// notification timing, so peers get "Alice is solving X" the instant
- /// Alice opens the puzzle. Kind is `play` because the user-facing event is
- /// "started playing", not the durable share-acceptance fact "joined".
+ /// 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 {
guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
syncMonitor.note("push(play): skipped (no authorID)")