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:
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