crossmate

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

commit 3c8891e3d4a5b49a393baf5e042a708f4db7ba55
parent 363b80cdef109fad4c0b182307be9c65cfa5e7c9
Author: Michael Camilleri <[email protected]>
Date:   Wed, 10 Jun 2026 02:40:35 +0900

Grace a lapsed read lease before treating a peer as gone

A peer's cursor would blink out whenever the peer bounced to another app
and back. Player.readAt doubles as the presence lease and the account
read horizon, and a device collapses it to a current-time value
synchronously on background/leave.

This commit treats presence as a live or recently-lapsed lease instead
of a strictly future one: PeerPresence.isPresent now holds for
presenceGrace (60s) after a lease lapses. A brief absence reads as
continued presence; a genuine departure still clears within about a
minute. The grace lives at the single PeerPresence chokepoint, so the
cursor gate, the engagement teardown, and the Core Data presence queries
all inherit it; the engagement lease-expiry and the roster ghost-clear
now fire at `readAt + grace` so a departed peer still self-clears on
time. The cost is that a cleanly-departed peer's cursor lingers for the
grace, since a clean leave and a quick bounce both write a current-time
readAt.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/PlayerRoster.swift | 17++++++++++-------
MCrossmate/Services/AppServices.swift | 29+++++++++++++++++------------
MCrossmate/Sync/Presence.swift | 32++++++++++++++++++++++++++++----
MTests/Unit/PlayerRosterTests.swift | 3++-
MTests/Unit/Sync/AppServicesAnnouncementTests.swift | 32+++++++++++++++++++++++++++++++-
ATests/Unit/Sync/PeerPresenceGraceTests.swift | 38++++++++++++++++++++++++++++++++++++++
7 files changed, 130 insertions(+), 25 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; }; + 51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; }; 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; @@ -191,6 +192,7 @@ /* Begin PBXFileReference section */ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; + 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerPresenceGraceTests.swift; sourceTree = "<group>"; }; 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreContributingDevicesTests.swift; sourceTree = "<group>"; }; 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; @@ -551,6 +553,7 @@ 800CCFBE90554F287E765755 /* FriendZoneTests.swift */, 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */, EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */, + 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */, BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */, @@ -768,6 +771,7 @@ E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */, C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, + 51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */, A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */, 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -384,17 +384,20 @@ final class PlayerRoster { lastPresentAuthors = present } - /// Schedules a single recompute at the soonest future peer lease expiry. - /// When it fires, `refresh()` re-reads `readAt` (reassigning `remoteReadAt`, - /// which triggers observation so the cursor and engagement icon re-evaluate) - /// and reschedules for the next-soonest lease. No-op when no peer holds a - /// live lease. + /// Schedules a single recompute at the soonest moment a present peer drops + /// out of presence — its `readAt` plus the presence grace, since a lapsed + /// lease still counts as present through the grace. When it fires, + /// `refresh()` re-reads `readAt` (reassigning `remoteReadAt`, which triggers + /// observation so the cursor and engagement icon re-evaluate) and + /// reschedules for the next-soonest. No-op when no peer is present. private func scheduleLeaseExpiryRecompute() { leaseExpiryTask?.cancel() leaseExpiryTask = nil let now = Date() - guard let soonest = remoteReadAt.values.filter({ $0 > now }).min() else { return } - let interval = soonest.timeIntervalSince(now) + guard let soonest = remoteReadAt.values + .filter({ PeerPresence.isPresent(readAt: $0, asOf: now) }) + .min() else { return } + let interval = max(0, soonest.addingTimeInterval(PeerPresence.presenceGrace).timeIntervalSince(now)) leaseExpiryTask = Task { [weak self] in try? await Task.sleep(for: .seconds(interval)) guard !Task.isCancelled, let self else { return } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1471,12 +1471,17 @@ final class AppServices { } } await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer) - // When the soonest present peer's lease lapses, re-reconcile: tear down - // (dropping the bolt) if no renewal arrived, or reschedule onto the new - // horizon if one did. This makes teardown track lease expiry precisely; - // the 30s reconnect tick remains the coarse backstop. A force/no-peer - // reconcile has no lease to watch, so this just clears any prior wake. - scheduleEngagementLeaseExpiry(gameID: gameID, at: soonestLease) + // When the soonest present peer drops out of presence, re-reconcile: + // tear down (dropping the bolt) if no renewal arrived, or reschedule + // onto the new horizon if one did. That instant is the lease plus the + // presence grace, not the bare lease — a peer stays present through the + // grace, so tearing down at the raw lease would race it. The 30s + // reconnect tick remains the coarse backstop. A force/no-peer reconcile + // has no lease to watch, so this just clears any prior wake. + scheduleEngagementLeaseExpiry( + gameID: gameID, + at: soonestLease?.addingTimeInterval(PeerPresence.presenceGrace) + ) } func offerEngagement(gameID: UUID) async { @@ -3654,13 +3659,13 @@ final class AppServices { let context = persistence.container.newBackgroundContext() return await withCheckedContinuation { continuation in context.perform { + let now = Date() let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") req.predicate = NSPredicate( format: "game.id == %@ AND readAt > %@", gameID as CVarArg, - Date() as NSDate + PeerPresence.presenceCutoff(asOf: now) as NSDate ) - let now = Date() let players = (try? context.fetch(req)) ?? [] let hasPeer = players.contains { player in guard let authorID = player.authorID, !authorID.isEmpty else { return false } @@ -3686,13 +3691,13 @@ final class AppServices { let context = persistence.container.newBackgroundContext() return await withCheckedContinuation { continuation in context.perform { + let now = Date() let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") req.predicate = NSPredicate( format: "game.id == %@ AND readAt > %@", gameID as CVarArg, - Date() as NSDate + PeerPresence.presenceCutoff(asOf: now) as NSDate ) - let now = Date() var soonest: Date? for player in (try? context.fetch(req)) ?? [] { guard let authorID = player.authorID, !authorID.isEmpty else { continue } @@ -3715,9 +3720,10 @@ final class AppServices { let context = persistence.container.newBackgroundContext() return await withCheckedContinuation { continuation in context.perform { + let now = Date() let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") var predicates = [ - NSPredicate(format: "readAt > %@", Date() as NSDate) + NSPredicate(format: "readAt > %@", PeerPresence.presenceCutoff(asOf: now) as NSDate) ] if let gameIDs, !gameIDs.isEmpty { predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs))) @@ -3727,7 +3733,6 @@ final class AppServices { } req.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - let now = Date() var result: [UUID: Set<String>] = [:] for player in (try? context.fetch(req)) ?? [] { guard let gameID = player.game?.id, diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -2,13 +2,37 @@ import CloudKit import Foundation /// The single rule for "is a peer present." A peer is present iff their -/// active-session lease (`Player.readAt`) is still in the future — the cursor -/// (`PlayerRoster`), the engagement icon, engagement teardown, and the -/// selection-publisher send gate all derive presence from this. +/// active-session lease (`Player.readAt`) is still in the future, or lapsed no +/// more than `presenceGrace` ago — the cursor (`PlayerRoster`), the engagement +/// icon, engagement teardown, and the selection-publisher send gate all derive +/// presence from this. +/// +/// The grace exists because `readAt` doubles as the account read horizon and is +/// legitimately collapsed to a current-time value whenever a device +/// backgrounds or leaves (it must close the lease synchronously, without a +/// background assertion it can't rely on). A co-solver bouncing between apps — +/// expected to be common — therefore writes a current-time `readAt` and returns +/// seconds later. Without a grace the partner would see them blink out and back +/// on every such hop; the grace treats a brief absence as continued presence, +/// at the cost of a departed peer lingering for up to `presenceGrace`. enum PeerPresence { + /// How long a lapsed `readAt` still counts as present. Sized to cover a + /// realistic app-switch — glancing at and replying to a notification — with + /// margin, while clearing a genuine departure within about a minute. The + /// costs are asymmetric: too short reintroduces the blink, too long only + /// lingers a stale cursor, so this rounds up. + static let presenceGrace: TimeInterval = 60 + + /// The cutoff a `readAt` must exceed to count as present. Exposed for + /// callers that filter in a query rather than per-record (Core Data + /// predicates), so they stay in lockstep with `isPresent`. + static func presenceCutoff(asOf now: Date = Date()) -> Date { + now.addingTimeInterval(-presenceGrace) + } + static func isPresent(readAt: Date?, asOf now: Date = Date()) -> Bool { guard let readAt else { return false } - return readAt > now + return readAt > presenceCutoff(asOf: now) } } diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -247,7 +247,8 @@ struct PlayerRosterTests { persistence: persistence, selection: PlayerSelection(row: 1, col: 2, direction: .across), updatedAt: Date(), - readAt: Date().addingTimeInterval(-60) + // Lapsed well past the presence grace so the peer reads as gone. + readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)) ) let roster = makeRoster(gameID: gameID, persistence: persistence) diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift @@ -100,7 +100,8 @@ struct AppServicesPeerPresenceTests { gameID: gameID, authorID: "bob", selection: PlayerSelection(row: 1, col: 2, direction: .down), - readAt: Date().addingTimeInterval(-1), + // Lapsed past the presence grace so it no longer counts as present. + readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)), updatedAt: Date(), persistence: persistence ) @@ -120,6 +121,35 @@ struct AppServicesPeerPresenceTests { #expect(peers[gameID] == nil) } + @Test("a lease lapsed within the grace still counts as a present peer") + func recentlyLapsedLeaseStillCountsAsPresentPeer() async throws { + let (persistence, gameID) = try makePersistence(authorID: "alice") + try addPlayer( + gameID: gameID, + authorID: "bob", + selection: PlayerSelection(row: 1, col: 2, direction: .down), + // A peer who bounced to another app seconds ago: lease collapsed to + // a recent current-time value, still inside the grace. + readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace - 10)), + updatedAt: Date(), + persistence: persistence + ) + + let hasPeer = await AppServices.hasPresentPeer( + persistence: persistence, + gameID: gameID, + localAuthorID: "alice" + ) + let peers = await AppServices.presentPeers( + persistence: persistence, + gameIDs: [gameID], + localAuthorID: "alice" + ) + + #expect(hasPeer) + #expect(peers[gameID] == ["bob"]) + } + @Test("a fresh cursor without a read lease does not count as a present peer") func freshCursorWithoutLeaseDoesNotCountAsPresentPeer() async throws { let (persistence, gameID) = try makePersistence(authorID: "alice") diff --git a/Tests/Unit/Sync/PeerPresenceGraceTests.swift b/Tests/Unit/Sync/PeerPresenceGraceTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PeerPresence grace") +struct PeerPresenceGraceTests { + private let now = Date(timeIntervalSince1970: 10_000) + + @Test("a live (future) lease is present") + func futureLeaseIsPresent() { + #expect(PeerPresence.isPresent(readAt: now.addingTimeInterval(600), asOf: now)) + } + + @Test("a lease lapsed within the grace still counts as present") + func recentlyLapsedLeaseIsPresent() { + let lapsed = now.addingTimeInterval(-(PeerPresence.presenceGrace - 5)) + #expect(PeerPresence.isPresent(readAt: lapsed, asOf: now)) + } + + @Test("a lease lapsed past the grace is absent") + func longLapsedLeaseIsAbsent() { + let lapsed = now.addingTimeInterval(-(PeerPresence.presenceGrace + 5)) + #expect(!PeerPresence.isPresent(readAt: lapsed, asOf: now)) + } + + @Test("no lease is absent") + func noLeaseIsAbsent() { + #expect(!PeerPresence.isPresent(readAt: nil, asOf: now)) + } + + @Test("the cutoff is the grace before now and agrees with isPresent") + func cutoffMatchesGrace() { + #expect(PeerPresence.presenceCutoff(asOf: now) == now.addingTimeInterval(-PeerPresence.presenceGrace)) + // A readAt exactly at the cutoff is not present (strictly greater wins). + #expect(!PeerPresence.isPresent(readAt: PeerPresence.presenceCutoff(asOf: now), asOf: now)) + } +}