crossmate

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

commit 60c2bb24bb5b795b0bc940b061404902fbf222ef
parent 7f4f7af5371b706d291687e9b55d845c01f872af
Author: Michael Camilleri <[email protected]>
Date:   Tue,  9 Jun 2026 23:18:34 +0900

Stop forcing CloudKit drains on leave-path Player sends

A backgrounded app shares one scarce, non-extendable execution budget
across every assertion it holds. The session-end grace timer is the
longest-lived of these, so it's the most likely to be cut short -- and
its expiration handler is the 'firing early (background expiring)' path.

The Player-record sends on the way to the background are pure presence/
coordination writes: a forced sendChanges() only buys promptness. With
the engagement socket carrying live cursor and presence (and tearing
down on background, which signals departure directly), that promptness
is redundant in the concurrent case, and CKSyncEngine delivers the
records durably on its own schedule regardless.

This commit enqueues these durably and stops forcing the drain:

- read cursor (.currentTime exit/background cursor)
- session snapshot (handlePuzzleLeft)
- selection clear (leave-path cursor-overlay cleanup)

enqueuePlayer gains drain: Bool = true; drain: false does the state.add
and skips both the forced send and the burst-pending mark.
PlayerSelectionPublisher's sink threads the same flag so the leave clear
defers while live cursor updates keep draining.

The moves flush still drains -- it's durable-write work, it's the
solver's final letters, and the socket already carried them live.
Trade-off: in the asynchronous case (no peer connected when the user
leaves), CloudKit keeps showing the prior future-dated lease until the
deferred send lands or the lease expires, so a peer may briefly read the
user as still present. That's eventually consistent, not a correctness
loss, and the live path is unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 13++++---------
MCrossmate/Services/AppServices.swift | 25+++++++++++++++++++------
MCrossmate/Sync/PlayerSelectionPublisher.swift | 17++++++++++++-----
MCrossmate/Sync/SyncEngine.swift | 13++++++++++++-
MTests/Unit/PlayerSelectionPublisherTests.swift | 30+++++++++++++++---------------
5 files changed, 62 insertions(+), 36 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -700,7 +700,6 @@ private struct PuzzleDisplayView: View { NotificationState.clearActivePuzzleID(if: gameID) let selectionPublisher = services.playerSelectionPublisher let movesUpdater = services.movesUpdater - let syncEngine = services.syncEngine let id = gameID // Navigating away is a leave: commit the catch-up baseline and drop // any pending banner timer (idempotent with the .background path). @@ -711,16 +710,12 @@ private struct PuzzleDisplayView: View { let neverAnnounced = services.cancelPendingSessionBeginPush(gameID: id) Task { await movesUpdater.flush() - // Mirror the open-burst pattern: the clear-cursor and - // close-lease enqueues both target the same Player record, - // so wrap them in a player send burst to collapse the two - // CKSyncEngine drains into one. - let burstScope = await syncEngine.beginPlayerSendBurst(gameID: id) + // The clear-cursor and close-lease writes both enqueue without + // forcing a drain (see `enqueuePlayer`'s `drain` flag), so there + // are no sends for a burst to collapse — CKSyncEngine ships both + // Player-record changes on its own schedule. await selectionPublisher.clear() await services.publishReadCursor(for: id, mode: .currentTime) - if let burstScope { - await syncEngine.endPlayerSendBurst(scope: burstScope) - } // Backgrounding may have already drained the tracker; the // skip-if-zero guard inside publishSessionEndPush keeps the // close-after-background case from firing a second push. diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -306,13 +306,14 @@ final class AppServices { engagementStatus.isLive(gameID: gameID) ? .milliseconds(2500) : .milliseconds(500) }, persistence: persistence, - sink: { gameID, authorID in + sink: { gameID, authorID, drain in let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } await syncEngine.enqueuePlayer( gameID: gameID, authorID: authorID, - reason: "selection" + reason: "selection", + drain: drain ) }, peerPresent: { [persistence, identity] gameID in @@ -3039,7 +3040,10 @@ final class AppServices { // guarantees it ships even when that write is a no-op. store.setSessionSnapshot(data, gameID: gameID, authorID: authorID) let syncEngine = self.syncEngine - Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot") } + // Leave-path Player write: enqueue durably but don't force a drain that + // would race the suspension budget — siblings adopt the snapshot on the + // next CKSyncEngine sync. + Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot", drain: false) } return committed } @@ -3314,14 +3318,23 @@ final class AppServices { } guard didUpdate else { return } let reason: String + let drain: Bool switch mode { - case .activeLease: reason = "readCursor(activeLease)" - case .currentTime: reason = "readCursor(currentTime)" + case .activeLease: + reason = "readCursor(activeLease)" + drain = true + case .currentTime: + // Exit/background cursor: enqueue durably but don't force a send. + // Live presence rides the engagement socket; CloudKit carries the + // cursor on its own schedule, off the scarce suspension budget. + reason = "readCursor(currentTime)" + drain = false } await syncEngine.enqueuePlayer( gameID: gameID, authorID: authorID, - reason: reason + reason: reason, + drain: drain ) } diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift @@ -20,7 +20,11 @@ actor PlayerSelectionPublisher { /// 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 + /// Pushes the local Player record. The `Bool` is `drain`: `true` forces an + /// immediate CKSyncEngine send (live cursor updates), `false` enqueues + /// durably and lets the engine ship on its own schedule — used by the + /// leave-path clear, which mustn't race the suspension budget. + private let sink: @Sendable (UUID, String, Bool) async -> Void /// Returns `true` if any non-local peer currently has a fresh cursor track. /// When the predicate returns `false` the publisher still writes the local /// PlayerEntity row but skips the CloudKit enqueue — the cursor track is @@ -49,7 +53,7 @@ actor PlayerSelectionPublisher { init( debounceInterval: @escaping @MainActor @Sendable (UUID) async -> Duration = { _ in .milliseconds(500) }, persistence: PersistenceController, - sink: @escaping @Sendable (UUID, String) async -> Void, + sink: @escaping @Sendable (UUID, String, Bool) async -> Void, peerPresent: @escaping @Sendable (UUID) async -> Bool = { _ in true }, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } ) { @@ -119,7 +123,7 @@ actor PlayerSelectionPublisher { pending = nil lastPublished = selection await write(gameID: gameID, authorID: authorID, selection: selection) - await sink(gameID, authorID) + await sink(gameID, authorID, true) } private func scheduleDebounce() { @@ -155,7 +159,7 @@ actor PlayerSelectionPublisher { if !(await peerPresent(gameID)) { return } pending = nil lastPublished = selection - await sink(gameID, authorID) + await sink(gameID, authorID, true) } /// Called when peer-presence state may have changed (e.g. an inbound @@ -181,7 +185,10 @@ actor PlayerSelectionPublisher { if lastPublished == nil { return } lastPublished = nil await write(gameID: gameID, authorID: authorID, selection: nil) - await sink(gameID, authorID) + // Leave-path clear: enqueue durably but don't force a drain that would + // race the suspension budget. The peer's live overlay is already gone + // via the socket tear-down; this is the durable cleanup. + await sink(gameID, authorID, false) } private func write( diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -976,7 +976,14 @@ actor SyncEngine { /// 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 { + /// + /// `drain: false` enqueues the change durably but does not force an + /// immediate `sendChanges()`; the record ships on CKSyncEngine's own + /// schedule instead. Used on the way to the background / on puzzle leave, + /// where a forced drain would race the scarce suspension budget to deliver + /// a presence write the engagement socket already carries live (and that + /// CloudKit delivers durably regardless). + func enqueuePlayer(gameID: UUID, authorID: String, reason: String, drain: Bool = true) async { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } // The shared zone has already been confirmed missing server-side @@ -999,6 +1006,10 @@ 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)") + // Durably enqueued above. A `drain: false` caller leaves the send to + // CKSyncEngine's automatic scheduling — and skips the burst-pending + // mark too, so a concurrent burst won't drain on this record's behalf. + guard drain else { return } if (playerSendBurstDepth[info.scope] ?? 0) > 0 { playerSendBurstPending.insert(info.scope) } else { diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift @@ -58,7 +58,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author, _ in await capture.append(id, author) } ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") @@ -81,7 +81,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .milliseconds(40) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author, _ in await capture.append(id, author) } ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") @@ -104,7 +104,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, sleep: manualSleep.sleepFn ) @@ -137,7 +137,7 @@ struct PlayerSelectionPublisherTests { return .milliseconds(1234) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, sleep: { duration in await sleepRecorder.record(duration) } ) @@ -170,7 +170,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author, _ in await capture.append(id, author) } ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") @@ -194,7 +194,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author, _ in await capture.append(id, author) } ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") @@ -211,7 +211,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .milliseconds(40) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author, _ in await capture.append(id, author) } ) await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) @@ -240,7 +240,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { _, _ in } + sink: { _, _, _ in } ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across)) @@ -260,7 +260,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, peerPresent: { _ in false } ) @@ -283,7 +283,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, peerPresent: { _ in await presence.value } ) @@ -310,7 +310,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, peerPresent: { _ in true }, sleep: manualSleep.sleepFn ) @@ -336,7 +336,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, peerPresent: { _ in true } ) @@ -356,7 +356,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, peerPresent: { _ in false } ) @@ -374,7 +374,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) }, + sink: { id, author, _ in await capture.append(id, author) }, peerPresent: { _ in false } ) @@ -393,7 +393,7 @@ struct PlayerSelectionPublisherTests { let publisher = PlayerSelectionPublisher( debounceInterval: { _ in .seconds(10) }, persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author, _ in await capture.append(id, author) } ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")