commit bc26a85e9ed8b8d2eb3605a9640e7c42427e5803
parent f9583647242fa49b1c146c73dd887d3e0f354db4
Author: Michael Camilleri <[email protected]>
Date: Thu, 14 May 2026 13:41:46 +0900
Cut redundant CloudKit work
A device-pair log dump showed three overlapping sources of churn on cold launch
and during a typing burst. None affected correctness, but they inflated
CloudKit reads, APN traffic and per-keystroke pushes.
services.start() and the scene-active syncOnForeground both called
ensureICloudSyncStarted on cold launch; with two await suspensions between the
!syncStarted guard and the syncStarted = true assignment, both callers slipped
through and each invoked SyncEngine.start(), recreating engines and double-
firing ensureDatabaseSubscriptions (visible as paired "subscription already
present" log lines). The two paths also ran initial fetch+push and foreground
fetch+push concurrently against the same fresh engine. Now (a)
ensureICloudSyncStarted caches an in-flight Task so concurrent callers converge
on one run, (b) SyncEngine.start() short-circuits when the engines already
exist and (c) services.start() no longer fires its own fetch+push —
scene-active's syncOnForeground handles that for both cold launch and resume.
Separately, PuzzleDisplayView polled the open puzzle every 5s while a session
was active, even when nothing had arrived for minutes. The poll exists to catch
the rare case where CKSyncEngine misses a push, so it can back off while idle.
AppServices now records the timestamp of each inbound silent push and exposes
hadRecentRemoteNotification(within:); the poll runs every 5s while a push has
landed in the last 30s and every 60s otherwise.
Finally, MovesUpdater flushed its buffer every time the focused cell changed,
which turned the 500ms debounce into "debounce within a single cell" — typing
across a word produced one CloudKit save per cell. The visible grid is driven
by the in-memory Puzzle via GameMutator, not by the MovesUpdater flush, so
coalescing across cells is safe. The cell-change flush is removed; the buffer
keys by cell and merges with per-cell enqueuedAt timestamps, so cross-cell
ordering is preserved by per-cell LWW at merge time.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
5 files changed, 78 insertions(+), 46 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -305,7 +305,17 @@ private extension UIApplication {
/// Loads a game when navigated to.
private struct PuzzleDisplayView: View {
- private static let syncedPuzzlePollingInterval: Duration = .seconds(5)
+ /// Tight cadence used while a collaborator burst is in progress (a silent
+ /// push has arrived recently). Most updates ride the push path directly;
+ /// this poll just covers the rare cases where CKSyncEngine drops one.
+ private static let activePollingInterval: Duration = .seconds(5)
+ /// Long safety-belt cadence used when no pushes have arrived for a while.
+ /// Trades latency-on-missed-push for far fewer CloudKit reads at idle.
+ private static let idlePollingInterval: Duration = .seconds(60)
+ /// How recent a push must be to count as "active". Slightly longer than
+ /// the active interval so a burst with brief pauses keeps tight polling.
+ private static let activityWindow: TimeInterval = 30
+
private var syncedID: UUID? {
preferences.isICloudSyncEnabled && session != nil ? gameID : nil
}
@@ -503,8 +513,11 @@ private struct PuzzleDisplayView: View {
guard let scope = syncedScope else { return }
await services.syncOpenPuzzle(gameID: gameID, scope: scope)
while !Task.isCancelled {
+ let interval = services.hadRecentRemoteNotification(within: Self.activityWindow)
+ ? Self.activePollingInterval
+ : Self.idlePollingInterval
do {
- try await Task.sleep(for: Self.syncedPuzzlePollingInterval)
+ try await Task.sleep(for: interval)
} catch {
break
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -25,10 +25,19 @@ final class AppServices {
private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
private var started = false
private var syncStarted = false
+ /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent
+ /// callers so the cold-launch race between `services.start()` and a
+ /// near-simultaneous `syncOnForeground()` doesn't admit two parallel
+ /// `SyncEngine.start()` runs.
+ private var syncStartTask: Task<Bool, Never>?
private(set) var playerNamePublisher: PlayerNamePublisher?
private var isReadyForShareAcceptance = false
private var isProcessingShareAcceptanceQueue = false
private var pendingShareMetadatas: [CKShare.Metadata] = []
+ /// Wall-clock timestamp of the most recent inbound silent push. Lets the
+ /// open-puzzle live-fetch loop poll quickly during a collaborator burst
+ /// and back off when nothing has arrived for a while.
+ private var lastRemoteNotificationAt: Date?
init() {
let preferences = PlayerPreferences()
@@ -200,17 +209,12 @@ final class AppServices {
)
guard await ensureICloudSyncStarted() else {
- syncMonitor.note("iCloud sync disabled — initial fetch/push skipped")
+ syncMonitor.note("iCloud sync disabled — engine startup skipped")
return
}
-
- await syncMonitor.run("initial fetch") {
- try await syncEngine.fetchChanges(source: "initial")
- }
- await syncMonitor.run("initial push") {
- try await syncEngine.pushChanges()
- }
- await refreshSnapshot()
+ // The scene-active phase that fires alongside cold launch runs the
+ // first fetch + push via `syncOnForeground`. Doing it here as well
+ // would mean two concurrent CKSyncEngine fetches on a fresh engine.
}
func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async {
@@ -281,6 +285,15 @@ final class AppServices {
}
}
+ /// True if a silent push has arrived within `window` seconds. Drives the
+ /// open-puzzle poll cadence — a recent push suggests a collaborator
+ /// burst, so polling stays tight; otherwise it backs off to a long
+ /// safety-belt interval.
+ func hadRecentRemoteNotification(within window: TimeInterval) -> Bool {
+ guard let last = lastRemoteNotificationAt else { return false }
+ return Date().timeIntervalSince(last) <= window
+ }
+
func syncOpenPuzzle(gameID: UUID, scope: CKDatabase.Scope) async {
await movesUpdater.flush()
guard await ensureICloudSyncStarted() else { return }
@@ -318,6 +331,7 @@ final class AppServices {
return
}
guard await ensureICloudSyncStarted() else { return }
+ lastRemoteNotificationAt = Date()
syncMonitor.note("remote notification: \(summary)")
guard let scope, scope != .public else {
@@ -401,18 +415,25 @@ final class AppServices {
private func ensureICloudSyncStarted() async -> Bool {
guard preferences.isICloudSyncEnabled else { return false }
guard !syncStarted else { return true }
+ if let inFlight = syncStartTask { return await inFlight.value }
- await identity.refresh(using: ckContainer)
- await syncEngine.start()
- syncStarted = true
+ let task = Task { @MainActor in
+ await identity.refresh(using: ckContainer)
+ await syncEngine.start()
+ syncStarted = true
- let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
- if recoveredMoveCount > 0 {
- syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
+ let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
+ if recoveredMoveCount > 0 {
+ syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
+ }
+ isReadyForShareAcceptance = true
+ await processPendingShareAcceptances()
+ return true
}
- isReadyForShareAcceptance = true
- await processPendingShareAcceptances()
- return true
+ syncStartTask = task
+ let result = await task.value
+ syncStartTask = nil
+ return result
}
private func presentPings(_ pings: [Ping]) async {
diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift
@@ -1,16 +1,22 @@
import CoreData
import Foundation
-/// In-memory staging area for cell edits. Coalesces rapid same-cell edits down
-/// to one per-cell entry, on flush merges them into the local device's
+/// In-memory staging area for cell edits. Coalesces rapid edits — same-cell
+/// rewrites collapse to the latest value, cross-cell edits accumulate as
+/// distinct entries — and on flush merges them into the local device's
/// `MovesEntity` row (per-cell wall-clock LWW), updates the local `CellEntity`
/// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine
/// can enqueue Moves records for upload.
///
/// Flush triggers:
/// - trailing-edge debounce (the user has stopped typing);
-/// - cell change (focus moves to a different cell);
/// - explicit `flush()` (view exit, app background, game completion, tests).
+///
+/// The UI does not depend on a flush for typed letters to appear — those go
+/// straight into the in-memory `Puzzle` via `GameMutator`. This staging area
+/// is purely for durable persistence + CloudKit handoff, so coalescing a full
+/// across-or-down word into one flush is a pure win: fewer Core Data writes
+/// and one CloudKit push per typing burst instead of one per cell.
actor MovesUpdater {
private struct Key: Hashable {
let gameID: UUID
@@ -36,10 +42,6 @@ actor MovesUpdater {
private let sleep: @Sendable (Duration) async throws -> Void
private var buffer: [Key: Pending] = [:]
- /// Cell most recently enqueued. A subsequent enqueue targeting a different
- /// cell flushes first so `updatedAt` ordering in the merged grid matches
- /// editing order for cells whose wall clocks are within the same tick.
- private var lastCell: Key?
private var debounceTask: Task<Void, Never>?
init(
@@ -71,11 +73,6 @@ actor MovesUpdater {
actingAuthorID: String? = nil
) async {
let key = Key(gameID: gameID, row: row, col: col)
-
- if let lastCell, lastCell != key {
- await performFlush()
- }
-
buffer[key] = Pending(
letter: letter,
markKind: markKind,
@@ -83,7 +80,6 @@ actor MovesUpdater {
authorID: authorID,
enqueuedAt: Date()
)
- lastCell = key
scheduleDebounce()
}
@@ -125,7 +121,6 @@ actor MovesUpdater {
let snapshot = buffer
buffer.removeAll(keepingCapacity: true)
- lastCell = nil
guard let affected = persistAndMerge(
snapshot: snapshot,
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -155,8 +155,12 @@ actor SyncEngine {
/// Creates both `CKSyncEngine` instances, restoring previously-saved state
/// so pending changes and change tokens survive restarts. Call once after
- /// wiring callbacks.
+ /// wiring callbacks. Idempotent — extra calls are no-ops so a race between
+ /// `services.start()` and a scene-active foreground sync can't double-
+ /// initialise the engines or re-fire subscription setup.
func start() {
+ guard privateEngine == nil, sharedEngine == nil else { return }
+
let bgCtx = persistence.container.newBackgroundContext()
let privateState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift
@@ -139,8 +139,8 @@ struct MovesUpdaterTests {
#expect(cells[GridPosition(row: 0, col: 0)]?.letter == "C")
}
- @Test("Enqueuing a different cell flushes the previous cell first")
- func cellChangeFlushesPrevious() async throws {
+ @Test("Cross-cell enqueues coalesce into a single flush")
+ func crossCellEnqueuesCoalesce() async throws {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
let updater = makeUpdater(persistence: persistence, capture: capture)
@@ -148,18 +148,17 @@ struct MovesUpdaterTests {
await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice")
await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice")
- // Cell-change triggered the first flush.
- #expect(await capture.flushCount == 1)
- let cellsAfterFirst = try decodedCells(gameID: gameID, persistence: persistence)
- #expect(cellsAfterFirst[GridPosition(row: 0, col: 0)]?.letter == "A")
- #expect(cellsAfterFirst[GridPosition(row: 0, col: 1)] == nil)
+ // The cell change should NOT have triggered a flush — both edits are
+ // still buffered until the debounce (or explicit flush) fires.
+ #expect(await capture.flushCount == 0)
await updater.flush()
- let cellsAfterFinal = try decodedCells(gameID: gameID, persistence: persistence)
- #expect(cellsAfterFinal.count == 2)
- #expect(cellsAfterFinal[GridPosition(row: 0, col: 1)]?.letter == "B")
- #expect(await capture.flushCount == 2)
+ let cells = try decodedCells(gameID: gameID, persistence: persistence)
+ #expect(cells.count == 2)
+ #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A")
+ #expect(cells[GridPosition(row: 0, col: 1)]?.letter == "B")
+ #expect(await capture.flushCount == 1)
}
@Test("Debounce coalesces rapid same-cell enqueues into one flush")