commit da62db48f631ade881a4859235ae88a11d678b9c
parent 9d06727eea2cca05647ed1e905bbf4aa2ed56f24
Author: Michael Camilleri <[email protected]>
Date: Mon, 25 May 2026 05:48:25 +0900
Gate cursor-track sends on peer presence
PlayerSelectionPublisher's selection writes were being sent on every track
change for the whole session, including time when nobody else was in the
puzzle. The 500 ms debounce caps the burst rate but not the steady-state cost —
a solo solving session was paying one CloudKit round trip per slot change for a
presence value nobody was reading. But the system already has a method of
determining whether anyone else is looking at the puzzle: Player.readAt.
PlayerEntity gains an optional readAt attribute (this is a lightweight
migration; the CKRecord field already exists in production, deployed with the
read-cursor redesign), populated by applyPlayerRecord for every author.
AppServices exposes hasPresentPeer, a fetch for any non-local PlayerEntity in
the game with readAt > now, and passes it to PlayerSelectionPublisher as a
peerPresent predicate. performFlush still writes the local row so the outbound
CKRecord remains current, but skips the sink when the gate denies and retains
the pending selection. setOnRemotePlayersUpdated now also nudges the publisher
via peerPresenceMayHaveChanged(gameIDs:), so an inbound Player record that
flips a peer's lease to active drains the held selection. publishImmediately
and clear bypass the gate — the open burst still seeds the peer's roster
(otherwise both ends would gate each other into mutual silence), and teardown
still clears the ghost cursor.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
5 files changed, 198 insertions(+), 6 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -36,6 +36,7 @@
<attribute name="ckRecordName" attributeType="String"/>
<attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
+ <attribute name="readAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="selRow" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -213,6 +213,14 @@ final class AppServices {
authorID: authorID,
reason: "selection"
)
+ },
+ peerPresent: { [persistence, identity] gameID in
+ let localAuthorID = await MainActor.run { identity.currentID }
+ return await Self.hasPresentPeer(
+ persistence: persistence,
+ gameID: gameID,
+ localAuthorID: localAuthorID
+ )
}
)
self.friendController = FriendController(
@@ -293,6 +301,10 @@ final class AppServices {
// not on moves and not on their later name / cursor updates.
await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in
await self?.reconcileFriendships(forGameIDs: gameIDs)
+ // An inbound Player record may have updated a peer's readAt
+ // lease; nudge the selection publisher to re-evaluate its gate
+ // and ship any selection it was holding for an absent audience.
+ await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs)
}
// A sibling device of the same iCloud account has published its read
@@ -1672,6 +1684,41 @@ final class AppServices {
}
}
+ /// True iff some non-local participant in `gameID` has an unexpired
+ /// `Player.readAt` lease — i.e. a peer is currently in the puzzle. Gates
+ /// the cursor-track publish path so we don't spend CloudKit writes
+ /// broadcasting presence to nobody.
+ static func hasPresentPeer(
+ persistence: PersistenceController,
+ gameID: UUID,
+ localAuthorID: String?
+ ) async -> Bool {
+ let context = persistence.container.newBackgroundContext()
+ return await withCheckedContinuation { continuation in
+ context.perform {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ let now = Date() as NSDate
+ if let localAuthorID, !localAuthorID.isEmpty {
+ req.predicate = NSPredicate(
+ format: "game.id == %@ AND authorID != %@ AND readAt != nil AND readAt > %@",
+ gameID as CVarArg,
+ localAuthorID,
+ now
+ )
+ } else {
+ req.predicate = NSPredicate(
+ format: "game.id == %@ AND readAt != nil AND readAt > %@",
+ gameID as CVarArg,
+ now
+ )
+ }
+ req.fetchLimit = 1
+ let count = (try? context.count(for: req)) ?? 0
+ continuation.resume(returning: count > 0)
+ }
+ }
+ }
+
}
private extension Array {
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -10,6 +10,13 @@ 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.
+ 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
/// `Task.sleep` from the actor's own task queue. Mirrors the pattern in
@@ -32,11 +39,13 @@ actor PlayerSelectionPublisher {
debounceInterval: Duration = .milliseconds(500),
persistence: PersistenceController,
sink: @escaping @Sendable (UUID, String) async -> Void,
+ peerPresent: @escaping @Sendable (UUID) async -> Bool = { _ in true },
sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
) {
self.debounceInterval = debounceInterval
self.persistence = persistence
self.sink = sink
+ self.peerPresent = peerPresent
self.sleep = sleep
}
@@ -92,12 +101,14 @@ actor PlayerSelectionPublisher {
/// CKSyncEngine drain as the read cursor and name-open enqueues
/// instead of arriving ~500 ms later.
func publishImmediately(_ selection: PlayerSelection) async {
- guard gameID != nil, authorID != nil else { return }
+ guard let gameID, let authorID else { return }
if selection == lastPublished { return }
debounceTask?.cancel()
debounceTask = nil
- pending = selection
- await performFlush()
+ pending = nil
+ lastPublished = selection
+ await write(gameID: gameID, authorID: authorID, selection: selection)
+ await sink(gameID, authorID)
}
private func scheduleDebounce() {
@@ -119,12 +130,31 @@ actor PlayerSelectionPublisher {
private func performFlush() async {
guard let gameID, let authorID, let selection = pending else { return }
if selection == lastPublished { return }
+ // Always keep the local PlayerEntity row in sync — that's the
+ // source of truth for the eventual outbound CKRecord, so the row
+ // must reflect the latest selection whether or not we're shipping
+ // it right now.
+ await write(gameID: gameID, authorID: authorID, selection: selection)
+ // If no peer is currently in the puzzle, hold the send. Keep
+ // `pending` set (and don't advance `lastPublished`) so that a peer
+ // joining later triggers a flush of the latest value.
+ if !(await peerPresent(gameID)) { return }
pending = nil
lastPublished = selection
- await write(gameID: gameID, authorID: authorID, selection: selection)
await sink(gameID, authorID)
}
+ /// Called when peer-presence state may have changed (e.g. an inbound
+ /// Player record updated a peer's `readAt` lease). 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.
+ func peerPresenceMayHaveChanged(gameIDs: Set<UUID>? = nil) async {
+ guard let gameID, pending != nil else { return }
+ if let gameIDs, !gameIDs.contains(gameID) { return }
+ await performFlush()
+ }
+
private func flushClear() async {
guard let gameID, let authorID else { return }
if lastPublished == nil { return }
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -209,8 +209,9 @@ extension SyncEngine {
entity.selCol = nil
entity.selDir = nil
}
- if authorID == localAuthorID,
- let readAt = RecordSerializer.parsePlayerReadAt(from: record) {
+ let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record)
+ entity.readAt = incomingReadAt
+ if authorID == localAuthorID, let readAt = incomingReadAt {
onReadCursor(gameID, readAt)
}
if !foundExisting {
diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift
@@ -17,6 +17,13 @@ struct PlayerSelectionPublisherTests {
}
}
+ /// Mutable flag for tests that need to toggle the peer-presence
+ /// predicate mid-test.
+ actor PresenceFlag {
+ private(set) var value: Bool = false
+ func set(_ newValue: Bool) { value = newValue }
+ }
+
private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
let persistence = makeTestPersistence()
let context = persistence.viewContext
@@ -208,6 +215,112 @@ struct PlayerSelectionPublisherTests {
#expect(rows.first?.selCol == 6)
}
+ @Test("Gate denied: Core Data row still updated but sink is skipped")
+ func gateDeniedSkipsSink() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ peerPresent: { _ in false }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 0)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selRow == 3)
+ #expect(values?.selCol == 4)
+ }
+
+ @Test("Gate denied then peerPresenceMayHaveChanged ships the held selection")
+ func gateResumeShipsPending() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let presence = PresenceFlag()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ peerPresent: { _ in await presence.value }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .across))
+ await publisher.flush()
+ #expect(await capture.count == 0)
+
+ await presence.set(true)
+ await publisher.peerPresenceMayHaveChanged()
+ #expect(await capture.count == 1)
+
+ // A subsequent publish to the same selection shouldn't refire.
+ await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .across))
+ await publisher.flush()
+ #expect(await capture.count == 1)
+ }
+
+ @Test("peerPresenceMayHaveChanged with non-matching gameIDs is a no-op")
+ func gateResumeScopedByGameID() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ peerPresent: { _ in true }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
+ // Don't flush — keep pending set by skipping the debounce. Force a
+ // gated state by swapping the predicate isn't supported, so instead
+ // verify scoping: a resume call for a different game shouldn't drain.
+ await publisher.peerPresenceMayHaveChanged(gameIDs: [UUID()])
+ #expect(await capture.count == 0)
+ }
+
+ @Test("publishImmediately bypasses the gate")
+ func publishImmediatelyBypassesGate() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ peerPresent: { _ in false }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publishImmediately(PlayerSelection(row: 7, col: 8, direction: .across))
+
+ let count = await capture.count
+ #expect(count == 1)
+ }
+
+ @Test("clear bypasses the gate")
+ func clearBypassesGate() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ peerPresent: { _ in false }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publishImmediately(PlayerSelection(row: 1, col: 1, direction: .down))
+ await publisher.clear()
+
+ let count = await capture.count
+ #expect(count == 2)
+ }
+
@Test("Publishing again after clear writes the new selection")
func publishAfterClear() async throws {
let (persistence, gameID) = try makePersistenceWithGame()