commit 9cbc8c816576adf63eabaca7c09ddd32733f6b2b
parent 5b7d552061d794e170fb35464229222f09b98d22
Author: Michael Camilleri <[email protected]>
Date: Wed, 3 Jun 2026 14:09:54 +0900
Lengthen the cursor debounce while a live room carries it
Each cursor move writes the player's track to the durable Player record,
debounced to ~500ms. While an engagement room is live that write is
redundant on both ends: the WebSocket already delivers the cursor to the
peer, so the durable copy is only a fallback for after the socket drops.
Yet every one of those writes still fans out as a CloudKit push that
both devices fetch and merge — the self-echo the writer gets back plus
the copy the peer pulls — which is the bulk of the per-second Player
traffic during co-solving. (The receive side already no-ops a stale/own
echo via RecordApplier's etag/updatedAt guards, and the feedback path
where an inbound peer cursor could provoke an outbound write is already
broken by PlayerSelectionPublisher's debounceTask-nil guard, so the win
is in not emitting the writes, not in handling them.)
PlayerSelectionPublisher's fixed `debounceInterval: Duration` becomes an
injected `(UUID) async -> Duration` provider — consistent with the
actor's already-injected `sleep` and `peerPresent` dependencies.
AppServices supplies the only policy: ~2.5s while
engagementStatus.isLive(gameID:) return true, the snappy 500ms
otherwise.
It can't drop to zero while live: the only other thing that flushes the
cursor durably is the readAt lease heartbeat, floor-gated to ~5 min, so
a full gate would freeze the fallback and strand the peer on a stale
cursor after a disconnect. A longer trailing debounce still lands a
resting position on each pause, keeping the fallback recent while
cutting the write rate during active solving. Reducing our own writes
also reduces what the peer receives once both devices run this.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
3 files changed, 75 insertions(+), 17 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -299,6 +299,12 @@ final class AppServices {
Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
}
self.playerSelectionPublisher = PlayerSelectionPublisher(
+ // While the live room carries the cursor over the websocket, the
+ // durable write is a lagging fallback — throttle it hard. When the
+ // room is down it is the peer's only delivery path, so keep it snappy.
+ debounceInterval: { [engagementStatus] gameID in
+ engagementStatus.isLive(gameID: gameID) ? .milliseconds(2500) : .milliseconds(500)
+ },
persistence: persistence,
sink: { gameID, authorID in
let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -7,7 +7,18 @@ import Foundation
/// edits don't go through `MovesUpdater` because they aren't cell edits — they
/// live on `PlayerEntity` with last-writer-wins semantics.
actor PlayerSelectionPublisher {
- private let debounceInterval: Duration
+ /// How long to wait before flushing the durable cursor write for `gameID`.
+ /// Injected (rather than a fixed `Duration`) so the caller can lengthen it
+ /// while an engagement room is live: the live websocket already carries the
+ /// cursor to the peer, so the durable PlayerEntity/CloudKit write is only a
+ /// post-disconnect fallback and can lag well behind. A longer interval there
+ /// cuts the bulk of the Player-record traffic during co-solving, while the
+ /// trailing flush still lands a resting position on each pause so the
+ /// fallback stays usable. Defaults to a flat 500 ms so tests and
+ /// non-collaborative contexts keep the prior single-interval behaviour.
+ /// `@MainActor` so the provider can read main-actor state (the engagement
+ /// liveness flag) directly; the `await` at the call site does the hop.
+ private let debounceInterval: @MainActor @Sendable (UUID) async -> Duration
private let persistence: PersistenceController
private let sink: @Sendable (UUID, String) async -> Void
/// Returns `true` if any non-local peer currently has a fresh cursor track.
@@ -36,7 +47,7 @@ actor PlayerSelectionPublisher {
private var fallbackName: String = ""
init(
- debounceInterval: Duration = .milliseconds(500),
+ debounceInterval: @escaping @MainActor @Sendable (UUID) async -> Duration = { _ in .milliseconds(500) },
persistence: PersistenceController,
sink: @escaping @Sendable (UUID, String) async -> Void,
peerPresent: @escaping @Sendable (UUID) async -> Bool = { _ in true },
@@ -113,9 +124,12 @@ actor PlayerSelectionPublisher {
private func scheduleDebounce() {
debounceTask?.cancel()
- let interval = debounceInterval
+ guard let gameID else { return }
+ let resolveInterval = debounceInterval
let sleep = self.sleep
debounceTask = Task { [weak self] in
+ let interval = await resolveInterval(gameID)
+ if Task.isCancelled { return }
try? await sleep(interval)
if Task.isCancelled { return }
await self?.debouncedFlush()
diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift
@@ -24,6 +24,18 @@ struct PlayerSelectionPublisherTests {
func set(_ newValue: Bool) { value = newValue }
}
+ /// Records the durations handed to the injected sleep.
+ actor DurationRecorder {
+ private(set) var durations: [Duration] = []
+ func record(_ duration: Duration) { durations.append(duration) }
+ }
+
+ /// Records the game IDs the interval provider is consulted for.
+ actor GameIDRecorder {
+ private(set) var ids: [UUID] = []
+ func record(_ id: UUID) { ids.append(id) }
+ }
+
private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
let persistence = makeTestPersistence()
let context = persistence.viewContext
@@ -44,7 +56,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) }
)
@@ -67,7 +79,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .milliseconds(40),
+ debounceInterval: { _ in .milliseconds(40) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) }
)
@@ -90,7 +102,7 @@ struct PlayerSelectionPublisherTests {
let capture = Capture()
let manualSleep = ManualDebounceSleep()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
sleep: manualSleep.sleepFn
@@ -113,6 +125,32 @@ struct PlayerSelectionPublisherTests {
#expect(values?.selCol == 4)
}
+ @Test("Debounce interval is sourced from the provider, keyed by the active game")
+ func debounceIntervalComesFromProvider() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let sleepRecorder = DurationRecorder()
+ let providerGames = GameIDRecorder()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: { id in
+ await providerGames.record(id)
+ return .milliseconds(1234)
+ },
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) },
+ sleep: { duration in await sleepRecorder.record(duration) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 0, col: 1, direction: .across))
+ try await waitForCount(1, capture: capture)
+
+ // The provider was asked for this game, and its returned interval is
+ // exactly what the debounce slept on.
+ #expect(await providerGames.ids == [gameID])
+ #expect(await sleepRecorder.durations == [.milliseconds(1234)])
+ }
+
private func waitForCount(
_ expected: Int,
capture: Capture,
@@ -130,7 +168,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) }
)
@@ -154,7 +192,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) }
)
@@ -171,7 +209,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, _) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .milliseconds(40),
+ debounceInterval: { _ in .milliseconds(40) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) }
)
@@ -200,7 +238,7 @@ struct PlayerSelectionPublisherTests {
try context.save()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { _, _ in }
)
@@ -220,7 +258,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
peerPresent: { _ in false }
@@ -243,7 +281,7 @@ struct PlayerSelectionPublisherTests {
let capture = Capture()
let presence = PresenceFlag()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
peerPresent: { _ in await presence.value }
@@ -270,7 +308,7 @@ struct PlayerSelectionPublisherTests {
let capture = Capture()
let manualSleep = ManualDebounceSleep()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
peerPresent: { _ in true },
@@ -296,7 +334,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
peerPresent: { _ in true }
@@ -316,7 +354,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
peerPresent: { _ in false }
@@ -334,7 +372,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) },
peerPresent: { _ in false }
@@ -353,7 +391,7 @@ struct PlayerSelectionPublisherTests {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let publisher = PlayerSelectionPublisher(
- debounceInterval: .seconds(10),
+ debounceInterval: { _ in .seconds(10) },
persistence: persistence,
sink: { id, author in await capture.append(id, author) }
)