crossmate

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

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:
MCrossmate/Sync/PlayerSelectionPublisher.swift | 9++++++++-
MTests/Unit/PlayerSelectionPublisherTests.swift | 27+++++++++++++++++++++++++++
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()