crossmate

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

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:
MCrossmate/Services/AppServices.swift | 18+++++++++++-------
MCrossmate/Sync/PlayerSelectionPublisher.swift | 14+++++++-------
MTests/Unit/Sync/AppServicesAnnouncementTests.swift | 37++++++++++++++++++++++++++++++++++---
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 {