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:
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))
+ }
+}