crossmate

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

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:
MCrossmate/CrossmateApp.swift | 25++++++++++++++++++-------
MCrossmate/Models/PlayerSession.swift | 18+++++++++++++-----
MCrossmate/Services/AppServices.swift | 8++++----
MCrossmate/Services/PlayerNamePublisher.swift | 10+++++-----
MCrossmate/Sync/PlayerSelectionPublisher.swift | 14++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 81+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
MTests/Unit/PlayerNamePublisherTests.swift | 4++--
MTests/Unit/Sync/ZoneOrphaningTests.swift | 2+-
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))