commit 5457ecc814ab8992aff63b3ef8653cfd1bac73a4
parent da62db48f631ade881a4859235ae88a11d678b9c
Author: Michael Camilleri <[email protected]>
Date: Mon, 25 May 2026 05:58:58 +0900
Avoid peer presence pre-empting selection debounce
peerPresenceMayHaveChanged was added to drain a held selection when a peer's
readAt lease flips active mid-session — the gate-resume path. But during
co-solving the gate is already open, so every inbound peer Player record fires
the same hook, potentially sending the selection ahead of its trailing-edge
flush. A collanborator who is moving rapidly would short-circuit every debounce
window, pressuring the user into roughly one CloudKit write per
collaborator-move instead of the 500 ms-throttled cadence.
This commit ensures sending resumes only when no debounce is currently
scheduled. The genuine gate-resume case (held under presence denial) has
debounceTask == nil because performFlush is reached via the trailing-edge
flush, which clears it; the co-solving case has debounceTask still pending. The
check distinguishes the two cleanly without adding bookkeeping.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -149,8 +149,15 @@ actor PlayerSelectionPublisher {
/// 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.
+ ///
+ /// Only resumes when no debounce is currently scheduled — i.e. the
+ /// pending selection is being held by the gate, not by the normal
+ /// trailing-edge debounce. Otherwise a co-solving partner whose own
+ /// cursor updates pre-empt every one of our debounce windows would
+ /// pressure us into one CloudKit write per partner-move instead of the
+ /// 500 ms-throttled cadence.
func peerPresenceMayHaveChanged(gameIDs: Set<UUID>? = nil) async {
- guard let gameID, pending != nil else { return }
+ guard let gameID, pending != nil, debounceTask == nil else { return }
if let gameIDs, !gameIDs.contains(gameID) { return }
await performFlush()
}
diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift
@@ -264,6 +264,33 @@ struct PlayerSelectionPublisherTests {
#expect(await capture.count == 1)
}
+ @Test("peerPresenceMayHaveChanged does not pre-empt an active debounce")
+ func gateResumeRespectsActiveDebounce() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let manualSleep = ManualDebounceSleep()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ peerPresent: { _ in true },
+ sleep: manualSleep.sleepFn
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
+ // Debounce is now scheduled. A peer-presence nudge here must not
+ // pre-empt it — that would let a chatty partner pressure us into
+ // one CloudKit write per partner-move.
+ await publisher.peerPresenceMayHaveChanged()
+ #expect(await capture.count == 0)
+
+ // Release the debounce normally and confirm we still ship exactly once.
+ manualSleep.releaseAll()
+ try await waitForCount(1, capture: capture)
+ #expect(await capture.count == 1)
+ }
+
@Test("peerPresenceMayHaveChanged with non-matching gameIDs is a no-op")
func gateResumeScopedByGameID() async throws {
let (persistence, gameID) = try makePersistenceWithGame()