crossmate

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

commit 51d8695b45911984b64eba1d3847bd899dac5097
parent 95232622cc612c9f1d76d806c7f1e6008da4a6ec
Author: Michael Camilleri <[email protected]>
Date:   Mon,  1 Jun 2026 11:43:07 +0900

Renew the read lease only while the app is foregrounded

A collaborator who had left could keep showing a live cursor and hold
the engagement room open for up to ~10 minutes if CloudKit syncing kep
rearming the Player.readAt lease. Presence is one rule — a peer is
present while their Player.readAt lease is in the future — and a
leaver's lease is meant to collapse on background. But readAt doubles as
the 'foregrounded on this puzzle' heartbeat, and several paths re-arm
it. CKSyncEngine wakes a backgrounded device constantly, and within the
15s leave-grace window an arriving peer move — the other solver still
typing — ran the incoming-moves handler, which could re-lease readAt to
now+10min and silently undo the collapse written moments earlier. An
earlier fix (4ea6f4f) had closed only the reconnect-tick path; this one
still leaked, and the renewal was driven by the surviving solver's own
activity.

'The user is actively present' is now a single fact. AppServices gains
an app-owned isAppForeground flag, fed from RootView's scene-phase
observer, and publishReadCursor refuses an .activeLease renewal unless
it is set — the .currentTime collapse stays unconditional, so a lease
can always be ended on the way to the background. Every re-lease caller
(the reconnect tick, the incoming-moves handler, anything added later)
is now gated at this one chokepoint, so a background wake can never
advance presence. A skipped renewal logs readCursor(activeLease)
skipped: backgrounded.

The reconnect-tick background cancel and the incoming-moves isSuppressed
gate are kept, but each now owns a single concern rather than doubling
as a presence guard: the cancel stops the engagement reconnect loop from
re-dialling the socket on background wakes, and isSuppressed scopes the
unread read-cursor to the puzzle actually on screen (Player.readAt is
also that cursor). Their comments are rewritten to match.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 15++++++++-------
MCrossmate/Services/AppServices.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 76 insertions(+), 7 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -356,8 +356,10 @@ struct RootView: View { .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: + services.noteAppForeground(true) Task { await services.syncOnForeground() } case .background, .inactive: + services.noteAppForeground(false) NotificationState.setActivePuzzleID(nil) Task { await services.syncOnBackground() } @unknown default: @@ -615,13 +617,11 @@ private struct PuzzleDisplayView: View { await services.startEngagementIfPossible(gameID: id) } case .background: - // Stop the reconnect backstop before collapsing the lease. - // The tick re-leases `readAt` (future-dated) on every wake, - // and CKSyncEngine wakes us constantly in the background, so - // without this the `.currentTime` collapse below is undone - // moments later — which is what kept peers from sending us - // session summaries while we were away. `.active` re-arms it - // via `startEngagementIfPossible`. + // Stop the engagement reconnect loop so it doesn't keep + // re-dialling the live socket on background CKSyncEngine wakes. + // (Re-leasing `readAt` in the background is now prevented + // centrally by publishReadCursor's foreground gate, not here.) + // `.active` re-arms the loop via `startEngagementIfPossible`. services.cancelEngagementReconnectRetry(gameID: id) Task { await services.publishReadCursor(for: id, mode: .currentTime) } case .inactive: @@ -688,6 +688,7 @@ private struct PuzzleDisplayView: View { services.syncMonitor.note( "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster" ) + await services.logPlayerLeaseSnapshot(gameID: gameID) await activateSharing(for: loadedSession, refreshRoster: false) } else { services.syncMonitor.note( diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -157,6 +157,13 @@ final class AppServices { /// debounced. private let remotePuzzleGridFreshenDebounce: TimeInterval = 5 private var isGameListVisible = false + /// Whether the app is foreground-active — the single source of truth for + /// "the user is actively using the app." `publishReadCursor(.activeLease)` + /// consults it so a background CKSyncEngine wake can never re-arm our + /// presence lease. Fed from `RootView`'s scene-phase observer; defaults to + /// `true` because the app launches into the foreground and `.onChange` does + /// not fire for the initial phase. + private(set) var isAppForeground = true private var latestLocalSelections: [UUID: PlayerSelection] = [:] private var scheduledEngagementEndTasks: [UUID: Task<Void, Never>] = [:] private var engagementReconnectTasks: [UUID: Task<Void, Never>] = [:] @@ -398,6 +405,13 @@ final class AppServices { if let currentID = store.currentEntity?.id, gameIDs.contains(currentID) { store.refreshCurrentGame() + // `readAt` doubles as the other-author read cursor: advancing it + // marks these incoming peer moves as seen. Gate on `isSuppressed` + // — "the user is viewing *this* puzzle right now" — so moves are + // marked read only while actually on screen, not merely because + // the app is foreground on some other view (`currentEntity` + // lingers after navigating away). The background re-lease is + // blocked separately by publishReadCursor's foreground gate. if NotificationState.isSuppressed(gameID: currentID) { await self?.publishReadCursor(for: currentID, mode: .activeLease) } @@ -2447,11 +2461,28 @@ final class AppServices { /// record. Active puzzle sessions write a future lease and refresh it /// only when less than `readLeaseRefreshFloor` remains; exits/background /// write the current time, which can intentionally close that lease. + /// Records the app's foreground/active state. The one place the "user is + /// actively using the app" fact is set; `publishReadCursor` reads it to + /// decide whether a presence-lease renewal is legitimate. + func noteAppForeground(_ foreground: Bool) { + isAppForeground = foreground + } + func publishReadCursor( for gameID: UUID, mode: ReadCursorPublishMode = .activeLease ) async { guard let authorID = identity.currentID, !authorID.isEmpty else { return } + // A read lease asserts "the user is actively present on this puzzle," so + // only a foregrounded app may advance it. A background CKSyncEngine wake + // must never re-arm presence — that is what resurrected a departed + // peer's cursor and held the engagement room open. The `.currentTime` + // collapse is always allowed: we must be able to *end* the lease on the + // way to the background. + if case .activeLease = mode, !isAppForeground { + syncMonitor.note("readCursor(activeLease) skipped for \(gameID.uuidString): backgrounded") + return + } let now = Date() let didUpdate: Bool switch mode { @@ -2477,6 +2508,43 @@ final class AppServices { ) } + /// Diagnostic: logs each participant's `Player.readAt` lease for `gameID` + /// at open, so a lingering peer cursor or engagement room can be reasoned + /// about from the device log alone — the lease value is otherwise only + /// visible server-side. One line per player: self/peer, author prefix, + /// name, the raw `readAt` (UTC), and whether it currently reads as present + /// (`+Ns` until expiry) or lapsed (`Ns ago`). + func logPlayerLeaseSnapshot(gameID: UUID) async { + let localAuthorID = identity.currentID + let context = persistence.container.newBackgroundContext() + let lines: [String] = await withCheckedContinuation { continuation in + context.perform { + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) + let now = Date() + let players = (try? context.fetch(req)) ?? [] + let lines = players.map { player -> String in + let author = player.authorID ?? "?" + let isLocal = author == CKCurrentUserDefaultName + || (localAuthorID.map { author == $0 } ?? false) + let tag = isLocal ? "self" : "peer" + let name = (player.name?.isEmpty == false) ? player.name! : "—" + guard let readAt = player.readAt else { + return "\(tag) \(author.prefix(8)) [\(name)] readAt=nil" + } + let delta = Int(readAt.timeIntervalSince(now)) + let state = delta > 0 ? "present, +\(delta)s" : "absent, \(-delta)s ago" + return "\(tag) \(author.prefix(8)) [\(name)] readAt=\(readAt.ISO8601Format()) (\(state))" + } + continuation.resume(returning: lines) + } + } + syncMonitor.note("open lease snapshot \(gameID.uuidString.prefix(8)): \(lines.count) player(s)") + for line in lines { + syncMonitor.note(" \(line)") + } + } + /// Sets the app icon badge to the cardinality of `BadgeState.unreadGameIDs` /// — Core Data ground truth (the `hasUnreadOtherMoves` heuristic that /// drives the per-row dot in the library list) unioned with any