commit 266a0c5c47c0f1e75391fed0873a8d29c86ab786
parent 48d8875767cfa36fce7f8c4b121d29b4129c65cf
Author: Michael Camilleri <[email protected]>
Date: Sun, 24 May 2026 13:15:51 +0900
Replace Player coalesce timer with an explicit open burst
The previous commit added a 750 ms per-scope window on top of
PlayerSelectionPublisher's existing 500 ms debounce so the puzzle-open fan-out
(read cursor + name-open + the trailing cursor track) shipped in one
CKSyncEngine drain. That required the two timers to be tuned together — the
outbound window's docstring literally encoded the arithmetic of "500 ms
upstream debounce + actor hop" — and a steady-state rename or read-cursor
refresh paid 750 ms of wall-clock latency it did not need. enqueuePlayer now
drains immediately like enqueueMoves; the puzzle- open path brackets its
fan-out in beginPlayerSendBurst/endPlayerSendBurst, which gate the per-enqueue
drain via a per-scope depth counter and issue one sendChanges when the
outermost frame exits. PlayerSelectionPublisher gains publishImmediately(_:) so
the initial cursor track can ride the burst instead of arriving 500 ms later
via the source-side debounce. PlayerSession exposes currentCursorTrack so the
open path can hand that track over directly rather than round-tripping through
onSelectionChanged.
The net effect is one round-trip on open (same as before) and one round-trip
per write at steady state (down from one per 750 ms tick), with the batching
bounded by the work that produces it rather than by a wall-clock window that
has to be re-derived whenever upstream timings shift. resetSyncState now also
clears the new burst-state maps so a reset that races a puzzle-open can't leave
a stale depth gating drains on the freshly-swapped engines.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
8 files changed, 105 insertions(+), 57 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -497,7 +497,6 @@ private struct PuzzleDisplayView: View {
let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
roster = newRoster
session = newSession
- Task { await services.publishReadCursor(for: gameID, mode: .activeLease) }
openPuzzleFollowUpTask = Task { @MainActor in
await finishOpeningPuzzle(
session: newSession,
@@ -619,6 +618,7 @@ private struct PuzzleDisplayView: View {
services.syncMonitor.note(
"PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster"
)
+ await services.publishReadCursor(for: gameID, mode: .activeLease)
await services.playerSelectionPublisher.clear()
}
}
@@ -652,18 +652,30 @@ private struct PuzzleDisplayView: View {
if refreshRoster {
await activeRoster.refresh()
}
- // Publish the local user's name to this game's zone before any
- // selection publish — otherwise the partner sees "Player" until we
- // happen to rename ourselves. Keep this scoped to the open game so a
- // stale shared zone elsewhere cannot hold up this collaboration.
- await services.playerNamePublisher?.publishName(for: gameID)
guard let authorID = services.identity.currentID else { return }
let selectionPublisher = services.playerSelectionPublisher
+ // Fan out read-cursor lease, display name, and the initial cursor
+ // track inside one Player-record send burst so they ship in a single
+ // CKSyncEngine drain. Name publish lands before the selection so the
+ // partner never sees a "Player" placeholder; the burst close then
+ // issues exactly one `sendChanges`. Subsequent selection edits go
+ // through `PlayerSelectionPublisher`'s trailing-edge debounce and
+ // each fires its own drain — same shape as Moves.
+ let syncEngine = services.syncEngine
+ let burstScope = await syncEngine.beginPlayerSendBurst(gameID: gameID)
+ await services.publishReadCursor(for: gameID, mode: .activeLease)
+ await services.playerNamePublisher?.publishName(for: gameID)
await selectionPublisher.begin(
gameID: gameID,
authorID: authorID,
currentName: preferences.name
)
+ if let track = session.currentCursorTrack {
+ await selectionPublisher.publishImmediately(track)
+ }
+ if let burstScope {
+ await syncEngine.endPlayerSendBurst(scope: burstScope)
+ }
session.onSelectionChanged = { selection in
Task { await selectionPublisher.publish(selection) }
}
@@ -678,7 +690,6 @@ private struct PuzzleDisplayView: View {
)
}
}
- session.publishCurrentSelection()
}
private func pollOpenSyncedPuzzle() async {
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -127,16 +127,24 @@ final class PlayerSession {
func publishCurrentSelection() {
guard let onSelectionChanged else { return }
- guard let track = puzzle.cursorTrack(
- atRow: selectedRow,
- col: selectedCol,
- direction: direction
- ) else { return }
+ guard let track = currentCursorTrack else { return }
guard track != lastPublishedCursorTrack else { return }
lastPublishedCursorTrack = track
onSelectionChanged(track)
}
+ /// Cursor track for the active selection, or `nil` if the selected
+ /// cell has no answer slot in the current direction. Exposed so the
+ /// puzzle-open path can ship the initial track in the open burst
+ /// without routing it through `onSelectionChanged`'s debounced sink.
+ var currentCursorTrack: PlayerSelection? {
+ puzzle.cursorTrack(
+ atRow: selectedRow,
+ col: selectedCol,
+ direction: direction
+ )
+ }
+
func select(row: Int, col: Int) {
guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
if row == selectedRow && col == selectedCol {
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -208,7 +208,7 @@ final class AppServices {
sink: { gameID, authorID in
let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
guard isEnabled else { return }
- await syncEngine.enqueuePlayerRecord(
+ await syncEngine.enqueuePlayer(
gameID: gameID,
authorID: authorID,
reason: "selection"
@@ -391,10 +391,10 @@ final class AppServices {
preferences: preferences,
persistence: persistence,
authorIdentity: identity,
- enqueuePlayerRecord: { [preferences, syncEngine] gameID, authorID, reason in
+ enqueuePlayer: { [preferences, syncEngine] gameID, authorID, reason in
let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
guard isEnabled else { return }
- await syncEngine.enqueuePlayerRecord(
+ await syncEngine.enqueuePlayer(
gameID: gameID,
authorID: authorID,
reason: reason
@@ -1638,7 +1638,7 @@ final class AppServices {
case .activeLease: reason = "readCursor(activeLease)"
case .currentTime: reason = "readCursor(currentTime)"
}
- await syncEngine.enqueuePlayerRecord(
+ await syncEngine.enqueuePlayer(
gameID: gameID,
authorID: authorID,
reason: reason
diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift
@@ -14,7 +14,7 @@ final class PlayerNamePublisher {
private let preferences: PlayerPreferences
private let persistence: PersistenceController
private let authorIdentity: AuthorIdentity
- private let enqueuePlayerRecord: (UUID, String, String) async -> Void
+ private let enqueuePlayer: (UUID, String, String) async -> Void
private var debounceTask: Task<Void, Never>?
private var observationTask: Task<Void, Never>?
@@ -27,12 +27,12 @@ final class PlayerNamePublisher {
preferences: PlayerPreferences,
persistence: PersistenceController,
authorIdentity: AuthorIdentity,
- enqueuePlayerRecord: @escaping (UUID, String, String) async -> Void
+ enqueuePlayer: @escaping (UUID, String, String) async -> Void
) {
self.preferences = preferences
self.persistence = persistence
self.authorIdentity = authorIdentity
- self.enqueuePlayerRecord = enqueuePlayerRecord
+ self.enqueuePlayer = enqueuePlayer
startObserving()
}
@@ -89,7 +89,7 @@ final class PlayerNamePublisher {
name: preferences.name
) else { return }
- await enqueuePlayerRecord(gameID, authorID, "name-open")
+ await enqueuePlayer(gameID, authorID, "name-open")
}
private func fanOut(newName: String) async {
@@ -104,7 +104,7 @@ final class PlayerNamePublisher {
)
for gameID in touchedGameIDs {
- await enqueuePlayerRecord(gameID, authorID, "name-rename")
+ await enqueuePlayer(gameID, authorID, "name-rename")
}
onFanOutForTesting?(newName)
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -86,6 +86,20 @@ actor PlayerSelectionPublisher {
await performFlush()
}
+ /// Publishes `selection` synchronously, skipping the trailing-edge
+ /// debounce and discarding any pending debounce. Used by the
+ /// puzzle-open path so the initial cursor track ships in the same
+ /// 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 }
+ if selection == lastPublished { return }
+ debounceTask?.cancel()
+ debounceTask = nil
+ pending = selection
+ await performFlush()
+ }
+
private func scheduleDebounce() {
debounceTask?.cancel()
let interval = debounceInterval
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -286,35 +286,45 @@ actor SyncEngine {
Task.detached { [engine] in try? await engine.sendChanges() }
}
- /// Per-scope debounce slot for the coalesced Player-record drain.
- /// Keyed by `GameEntity.databaseScope` (0 = private, 1 = shared).
- private var playerSendDebounce: [Int16: Task<Void, Never>] = [:]
- /// How long to wait after the first Player enqueue before firing
- /// `sendChanges`. Sized to cover the full puzzle-open burst: read cursor
- /// and name-open enqueue synchronously, then the cursor-track selection
- /// arrives after PlayerSelectionPublisher's 500 ms debounce plus an
- /// actor hop — measured at ~1 s end-to-end on device. 750 ms catches
- /// the trailing selection in the same batch without perceptibly
- /// deferring a true solo edit.
- private static let playerSendCoalesceWindow: Duration = .milliseconds(750)
-
- /// Coalescing variant of `sendChangesDetached` for the Player-record
- /// path. The first enqueue in a window schedules a deferred drain;
- /// subsequent enqueues for the same scope are absorbed into that same
- /// drain (CKSyncEngine state dedupes repeated `.saveRecord` for the same
- /// record ID, and one `sendChanges` ships them all in a single batch).
- private func scheduleCoalescedPlayerSend(on engine: CKSyncEngine, scope: Int16) {
- if playerSendDebounce[scope] != nil { return }
- let window = Self.playerSendCoalesceWindow
- playerSendDebounce[scope] = Task.detached { [engine, weak self] in
- try? await Task.sleep(for: window)
- await self?.clearPlayerSendDebounce(scope: scope)
- try? await engine.sendChanges()
- }
+ /// Per-scope burst depth. `enqueuePlayer` consults this — while
+ /// non-zero for a scope, it records that a drain is owed instead of
+ /// firing `sendChanges` immediately. The outermost frame issues one
+ /// `sendChanges` on exit if any enqueue landed during the burst.
+ /// Replaces an earlier time-window coalesce that had to be tuned to
+ /// match `PlayerSelectionPublisher`'s debounce: the open path now
+ /// explicitly fans out read cursor, name, and the initial selection
+ /// inside a burst, so the batch is bounded by the work that produces
+ /// it rather than by a wall-clock window.
+ private var playerSendBurstDepth: [Int16: Int] = [:]
+ private var playerSendBurstPending: Set<Int16> = []
+
+ /// Opens a Player-record send burst for `gameID`'s scope. Subsequent
+ /// `enqueuePlayer` calls on that scope skip their immediate
+ /// drain; the caller must pair this with `endPlayerSendBurst(scope:)`,
+ /// which fires one `sendChanges` if any enqueue landed in between.
+ /// Returns the scope so the caller can close the matching burst even
+ /// if the game's zone routing changes after the call.
+ func beginPlayerSendBurst(gameID: UUID) -> Int16? {
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return nil }
+ playerSendBurstDepth[info.scope, default: 0] += 1
+ return info.scope
}
- private func clearPlayerSendDebounce(scope: Int16) {
- playerSendDebounce.removeValue(forKey: scope)
+ /// Closes a burst opened by `beginPlayerSendBurst`. The outermost
+ /// frame drains the affected engine if any enqueue landed during the
+ /// burst; nested frames just decrement the counter.
+ func endPlayerSendBurst(scope: Int16) {
+ guard let depth = playerSendBurstDepth[scope], depth > 0 else { return }
+ if depth > 1 {
+ playerSendBurstDepth[scope] = depth - 1
+ return
+ }
+ playerSendBurstDepth.removeValue(forKey: scope)
+ guard playerSendBurstPending.remove(scope) != nil else { return }
+ let engine = scope == 1 ? sharedEngine : privateEngine
+ guard let engine else { return }
+ sendChangesDetached(on: engine)
}
// MARK: - Outbound
@@ -688,11 +698,10 @@ actor SyncEngine {
/// (game, authorID), so participants only ever write their own slot.
/// `reason` is logged so the diagnostics view can attribute each enqueue
/// to its caller (rename, read cursor, cursor track, …); it does not
- /// affect routing. The outbound drain is coalesced via
- /// `scheduleCoalescedPlayerSend` so several enqueues in the same tick
- /// (puzzle-open fans out name + read cursor + cursor track in <1s) ship
- /// as one batch instead of one round-trip per record.
- func enqueuePlayerRecord(gameID: UUID, authorID: String, reason: String) async {
+ /// affect routing. Drains immediately unless a burst is open for this
+ /// scope (see `beginPlayerSendBurst`) — the open path uses a burst to
+ /// ship read cursor + name + initial selection in one round-trip.
+ func enqueuePlayer(gameID: UUID, authorID: String, reason: String) async {
let ctx = persistence.container.newBackgroundContext()
guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
let engine = info.scope == 1 ? sharedEngine : privateEngine
@@ -701,7 +710,11 @@ actor SyncEngine {
let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
await trace("enqueue Player[\(gameID.uuidString.prefix(8))] reason=\(reason)")
- scheduleCoalescedPlayerSend(on: engine, scope: info.scope)
+ if (playerSendBurstDepth[info.scope] ?? 0) > 0 {
+ playerSendBurstPending.insert(info.scope)
+ } else {
+ sendChangesDetached(on: engine)
+ }
}
/// Registers a Game record as a pending send and ensures its zone is
@@ -772,6 +785,8 @@ actor SyncEngine {
seenPingRecords = [:]
liveQueryCheckpoints = [:]
loggedFirstSharedPushPayload = false
+ playerSendBurstDepth = [:]
+ playerSendBurstPending = []
_ = enqueueUnconfirmedMoves()
}
diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift
@@ -73,7 +73,7 @@ struct PlayerNamePublisherTests {
preferences: preferences,
persistence: persistence,
authorIdentity: AuthorIdentity(testing: authorID),
- enqueuePlayerRecord: { _, _, _ in }
+ enqueuePlayer: { _, _, _ in }
)
}
@@ -184,7 +184,7 @@ struct PlayerNamePublisherTests {
preferences: prefs,
persistence: p,
authorIdentity: AuthorIdentity(testing: "_local"),
- enqueuePlayerRecord: { gameID, _, _ in enqueued.append(gameID) }
+ enqueuePlayer: { gameID, _, _ in enqueued.append(gameID) }
)
await broadcaster.publishName(for: secondID)
diff --git a/Tests/Unit/Sync/ZoneOrphaningTests.swift b/Tests/Unit/Sync/ZoneOrphaningTests.swift
@@ -93,7 +93,7 @@ struct ZoneOrphaningTests {
let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
let engine = await makeEngine(persistence: persistence)
- await engine.enqueuePlayerRecord(gameID: gameID, authorID: "_localAuthor", reason: "test")
+ await engine.enqueuePlayer(gameID: gameID, authorID: "_localAuthor", reason: "test")
let beforeNames = await engine.pendingSaveRecordNames(scope: .shared)
let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor")
#expect(beforeNames.contains(playerRecordName))