commit 4ed262e195b17e4a66a060ca1bda724a4fccc50c
parent 1524e3fed29a1218aaf5af13b74f71f8d97066a3
Author: Michael Camilleri <[email protected]>
Date: Wed, 27 May 2026 10:43:53 +0900
Suppress stale peer cursor presence
This commit tightens the selection-publishing gate so a shared puzzle opened
solo does not keep enqueueing Player saves just because a collaborator has an
old cursor row. Peer presence now requires a non-local cursor track with a
fresh updatedAt, matching the roster's visible-selection freshness window.
The PlayerSelectionPublisher comments are updated to describe the actual gate,
and the AppServices peer-presence tests now cover fresh cursor presence, stale
cursor non-presence and read-lease-only non-presence.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 52 insertions(+), 17 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -23,6 +23,7 @@ final class AppServices {
private static let readLeaseDuration: TimeInterval = 10 * 60
private static let readLeaseRefreshFloor: TimeInterval = 5 * 60
+ private static let peerPresenceFreshnessWindow: TimeInterval = 60
private static let engagementTeardownDelaySeconds = 120
private static let engagementTeardownDelay: Duration = .seconds(engagementTeardownDelaySeconds)
@@ -2050,10 +2051,10 @@ final class AppServices {
}
}
- /// True iff some non-local participant in `gameID` has a cursor track.
- /// Cursor fields are the user-visible presence signal; stale cursors are
- /// still good enough to justify trying an engagement or publishing our
- /// own cursor back.
+ /// True iff some non-local participant in `gameID` has a fresh cursor
+ /// track. Cursor fields are the user-visible presence signal; stale
+ /// cursor rows are ignored so a solo solver in a shared puzzle does not
+ /// keep publishing local cursor changes after the peer has left.
static func hasPresentPeer(
persistence: PersistenceController,
gameID: UUID,
@@ -2063,9 +2064,11 @@ final class AppServices {
return await withCheckedContinuation { continuation in
context.perform {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ let cutoff = Date().addingTimeInterval(-peerPresenceFreshnessWindow)
req.predicate = NSPredicate(
- format: "game.id == %@ AND selRow != nil AND selCol != nil AND selDir != nil",
- gameID as CVarArg
+ format: "game.id == %@ AND updatedAt > %@ AND selRow != nil AND selCol != nil AND selDir != nil",
+ gameID as CVarArg,
+ cutoff as NSDate
)
let players = (try? context.fetch(req)) ?? []
let hasPeer = players.contains { player in
@@ -2088,8 +2091,9 @@ final class AppServices {
return await withCheckedContinuation { continuation in
context.perform {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ let cutoff = Date().addingTimeInterval(-peerPresenceFreshnessWindow)
var predicates = [
- NSPredicate(format: "selRow != nil AND selCol != nil AND selDir != nil")
+ NSPredicate(format: "updatedAt > %@ AND selRow != nil AND selCol != nil AND selDir != nil", cutoff as NSDate)
]
if let gameIDs, !gameIDs.isEmpty {
predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs)))
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -10,12 +10,12 @@ actor PlayerSelectionPublisher {
private let debounceInterval: Duration
private let persistence: PersistenceController
private let sink: @Sendable (UUID, String) async -> Void
- /// Returns `true` if any non-local peer is currently in the puzzle (their
- /// `Player.readAt` lease is unexpired). When the predicate returns `false`
- /// the publisher still writes the local PlayerEntity row but skips the
- /// CloudKit enqueue — the cursor track is presence data nobody is watching
- /// for. Defaults to "always present" so callers that don't care about the
- /// gate (tests, contexts without peer info) get the pre-gate behaviour.
+ /// Returns `true` if any non-local peer currently has a fresh cursor track.
+ /// When the predicate returns `false` the publisher still writes the local
+ /// PlayerEntity row but skips the CloudKit enqueue — the cursor track is
+ /// presence data nobody is watching for. Defaults to "always present" so
+ /// callers that don't care about the gate (tests, contexts without peer
+ /// info) get the pre-gate behaviour.
private let peerPresent: @Sendable (UUID) async -> Bool
/// Sleep primitive used by the debounce timer. Injected so tests can
/// drive flushes deterministically instead of racing against wall-clock
@@ -145,7 +145,7 @@ actor PlayerSelectionPublisher {
}
/// Called when peer-presence state may have changed (e.g. an inbound
- /// Player record updated a peer's `readAt` lease). Re-evaluates the gate
+ /// Player record updated a peer's cursor track). Re-evaluates the gate
/// and ships any held `pending` selection. `gameIDs`, if provided, scopes
/// the call to player records in those games — a no-op if the active
/// session isn't one of them.
diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift
@@ -56,14 +56,15 @@ struct AppServicesAnnouncementTests {
@MainActor
struct AppServicesPeerPresenceTests {
- @Test("cursor track counts as a present peer without a read lease")
- func cursorTrackCountsAsPresentPeer() async throws {
+ @Test("fresh cursor track counts as a present peer without a read lease")
+ func freshCursorTrackCountsAsPresentPeer() async throws {
let (persistence, gameID) = try makePersistence(authorID: "alice")
try addPlayer(
gameID: gameID,
authorID: "bob",
selection: PlayerSelection(row: 1, col: 2, direction: .down),
readAt: nil,
+ updatedAt: Date(),
persistence: persistence
)
@@ -82,6 +83,33 @@ struct AppServicesPeerPresenceTests {
#expect(peers[gameID] == ["bob"])
}
+ @Test("stale cursor track does not count as a present peer")
+ func staleCursorTrackDoesNotCountAsPresentPeer() async throws {
+ let (persistence, gameID) = try makePersistence(authorID: "alice")
+ try addPlayer(
+ gameID: gameID,
+ authorID: "bob",
+ selection: PlayerSelection(row: 1, col: 2, direction: .down),
+ readAt: nil,
+ updatedAt: Date().addingTimeInterval(-90),
+ 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] == nil)
+ }
+
@Test("read lease alone does not count as engagement presence")
func readLeaseWithoutCursorDoesNotCountAsPresentPeer() async throws {
let (persistence, gameID) = try makePersistence(authorID: "alice")
@@ -90,6 +118,7 @@ struct AppServicesPeerPresenceTests {
authorID: "bob",
selection: nil,
readAt: Date().addingTimeInterval(60),
+ updatedAt: Date(),
persistence: persistence
)
@@ -124,6 +153,7 @@ struct AppServicesPeerPresenceTests {
authorID: authorID,
selection: PlayerSelection(row: 0, col: 0, direction: .across),
readAt: nil,
+ updatedAt: Date(),
persistence: persistence
)
return (persistence, gameID)
@@ -134,6 +164,7 @@ struct AppServicesPeerPresenceTests {
authorID: String,
selection: PlayerSelection?,
readAt: Date?,
+ updatedAt: Date = Date(),
persistence: PersistenceController
) throws {
let context = persistence.viewContext
@@ -145,7 +176,7 @@ struct AppServicesPeerPresenceTests {
player.game = game
player.authorID = authorID
player.name = authorID
- player.updatedAt = Date()
+ player.updatedAt = updatedAt
player.ckRecordName = "player-\(gameID.uuidString)-\(authorID)"
player.readAt = readAt
if let selection {