commit 24d7723a0939b8631669737bb1cff839f525bfa4
parent f049050afd89651a66f610fe40c460851a828769
Author: Michael Camilleri <[email protected]>
Date: Thu, 25 Jun 2026 18:21:14 +0900
Stop collaborator push churn from dragging out a share join
Accepting a shared puzzle from an invitation could hold the joining
screen for more than 10 seconds when the other players were mid-game. A
collaborator playing live writes a CKRecord per move, selection, and
lease renewal, and every one fires this device's shared-database
subscription. Those pushes contended with the accept's own puzzleSource
asset download and, while backgrounded, drove a full-zone session scan
per record.
This commit serialises the accept against that churn. The share-accept
drain now marks the shared scope busy for its whole duration, and
handleRemoteNotification defers a shared push that lands in the window —
scheduling the debounced catch-up and returning rather than fanning out
zone discovery, fetchChanges, or a session scan against the in-flight
join.
The background session scan is also coalesced behind a short trailing
window instead of running eagerly on every push. It deliberately
coalesces rather than cancelling and rescheduling, so sustained
collaborator activity cannot push the scan out indefinitely and starve
presence updates the way a debounce would.
Finally, waitForPlayablePuzzle now observes the local store each second
and only re-issues a CloudKit read on a longer backstop, and that
backstop fetches just the Game record. The playability gate reads only
puzzleSource, the initial accept fetch already pulled the zone's Moves
and Players, and the Puzzle Grid re-fetches them on open; the new
onlyGame pass skips the two full-zone queries and leaves the live-query
checkpoint untouched so a later fetchGameDirect cannot skip unfetched
moves.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
3 files changed, 137 insertions(+), 26 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -249,6 +249,11 @@ final class AppServices {
private(set) var playerNamePublisher: PlayerNamePublisher?
private var isReadyForShareAcceptance = false
private var isProcessingShareAcceptanceQueue = false
+ /// True while `processPendingShareAcceptances` is draining. A share accept
+ /// holds the shared database to download the puzzle asset on the joining
+ /// screen; shared-scope pushes that land in this window defer their heavy
+ /// fan-out so collaborator activity doesn't contend with the join.
+ private var isAcceptingSharedGame = false
private var pendingShareMetadatas: [CKShare.Metadata] = []
/// Wall-clock timestamp of the most recent inbound silent push. Bypasses
/// the game-list freshen cooldown when a push has arrived since the last
@@ -256,6 +261,8 @@ final class AppServices {
private var lastRemoteNotificationAt: Date?
private var privatePushCatchUpTask: Task<Void, Never>?
private var sharedPushCatchUpTask: Task<Void, Never>?
+ private var privateSessionScanTask: Task<Void, Never>?
+ private var sharedSessionScanTask: Task<Void, Never>?
private var isHandlingPrivateRemoteNotification = false
private var isHandlingSharedRemoteNotification = false
private var gameListFreshenTask: Task<Void, Never>?
@@ -1512,19 +1519,19 @@ final class AppServices {
cancelBackgroundPushCatchUp(scope: scope)
+ // A share accept is downloading the puzzle asset on the joining screen.
+ // Don't fan out a session scan, zone discovery, or fetchChanges against
+ // the shared database while it does — the deferred catch-up runs once
+ // the burst (and the join) settle.
+ if scope == .shared, isAcceptingSharedGame {
+ syncMonitor.note("shared remote notification deferred during share acceptance")
+ scheduleBackgroundPushCatchUp(scope: scope)
+ await refreshSnapshot()
+ return
+ }
+
if isBackground {
- let result = await syncMonitor.run("remote-notification background session scan") {
- try await syncEngine.fetchBackgroundSessionsDirect(scope: scope)
- }
- if let result {
- // The receiver-side `presentBegins` path is no longer wired
- // up. The catch-up banner that summarises peer adds/clears
- // still consumes the SessionMonitor buckets via `consumeOnOpen`
- // — see `handlePuzzleOpened`.
- if result.isEmpty {
- syncMonitor.note("remote-notification background session scan: no active sessions")
- }
- }
+ scheduleBackgroundSessionScan(scope: scope)
scheduleBackgroundPushCatchUp(scope: scope)
await refreshSnapshot()
return
@@ -1706,6 +1713,71 @@ final class AppServices {
}
}
+ /// Trailing-edge window over which a burst of background pushes is collapsed
+ /// into a single session scan. A collaborator playing live writes a record
+ /// every second or two; running the full-zone scan per record turned a
+ /// backgrounded join into minutes of back-to-back fetches.
+ private static let backgroundSessionScanDebounce: UInt64 = 5_000_000_000
+
+ /// Coalesces background-push session scans. Unlike the catch-up scheduler
+ /// this does *not* cancel a pending scan — under sustained activity a
+ /// cancel-and-reschedule would push the scan out indefinitely and starve
+ /// presence. The first push arms a scan; later pushes within the window are
+ /// no-ops; the task clears its own handle when it runs.
+ private func scheduleBackgroundSessionScan(scope: CKDatabase.Scope) {
+ switch scope {
+ case .private:
+ guard privateSessionScanTask == nil else { return }
+ privateSessionScanTask = makeBackgroundSessionScanTask(scope: scope)
+ case .shared:
+ guard sharedSessionScanTask == nil else { return }
+ sharedSessionScanTask = makeBackgroundSessionScanTask(scope: scope)
+ case .public:
+ return
+ @unknown default:
+ return
+ }
+ }
+
+ private func clearBackgroundSessionScanTask(scope: CKDatabase.Scope) {
+ switch scope {
+ case .private:
+ privateSessionScanTask = nil
+ case .shared:
+ sharedSessionScanTask = nil
+ case .public:
+ return
+ @unknown default:
+ return
+ }
+ }
+
+ private func makeBackgroundSessionScanTask(scope: CKDatabase.Scope) -> Task<Void, Never> {
+ Task { @MainActor in
+ defer { clearBackgroundSessionScanTask(scope: scope) }
+ do {
+ try await Task.sleep(nanoseconds: Self.backgroundSessionScanDebounce)
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+ guard await ensureICloudSyncStarted() else { return }
+ let result = await syncMonitor.run("remote-notification background session scan") {
+ try await syncEngine.fetchBackgroundSessionsDirect(scope: scope)
+ }
+ if let result {
+ // The receiver-side `presentBegins` path is no longer wired
+ // up. The catch-up banner that summarises peer adds/clears
+ // still consumes the SessionMonitor buckets via `consumeOnOpen`
+ // — see `handlePuzzleOpened`.
+ if result.isEmpty {
+ syncMonitor.note("remote-notification background session scan: no active sessions")
+ }
+ }
+ await refreshSnapshot()
+ }
+ }
+
private func makeBackgroundPushCatchUpTask(
scope: CKDatabase.Scope,
label: String
@@ -1868,7 +1940,11 @@ final class AppServices {
private func processPendingShareAcceptances() async {
guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return }
isProcessingShareAcceptanceQueue = true
- defer { isProcessingShareAcceptanceQueue = false }
+ isAcceptingSharedGame = true
+ defer {
+ isProcessingShareAcceptanceQueue = false
+ isAcceptingSharedGame = false
+ }
while !pendingShareMetadatas.isEmpty {
let metadata = pendingShareMetadatas.removeFirst()
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -152,6 +152,11 @@ final class CloudService {
/// `RootView`'s invite-ping join wait.
private static let joinSyncTimeout: TimeInterval = 30
private static let joinSyncPollInterval: Duration = .seconds(1)
+ /// How often `waitForPlayablePuzzle` re-issues the CloudKit fetch while
+ /// waiting. Between backstops it only observes the store, which the initial
+ /// accepted-game fetch (and concurrent sync) populate — so a slow asset
+ /// commit costs cheap store polls, not repeated three-query CloudKit reads.
+ private static let joinSyncRefetchInterval: TimeInterval = 3
/// Polls the just-joined game's own zone until its puzzle is playable, so
/// the joining screen holds through a slow sync rather than dropping the
@@ -164,17 +169,31 @@ final class CloudService {
guard let gameID else { return nil }
if store.joinedSharedGameIDs().contains(gameID) { return gameID }
let deadline = Date().addingTimeInterval(Self.joinSyncTimeout)
+ // The caller already issued one accepted-game fetch, so begin by just
+ // observing the store and only re-issue the CloudKit fetch on a backstop
+ // interval. In the common case the asset commits within a poll or two
+ // and we return without a single redundant three-query read.
+ var nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval)
while Date() < deadline {
- if let zoneID {
- _ = try? await syncEngine.fetchAcceptedSharedGameDirect(
- gameID: gameID,
- zoneID: zoneID
- )
- } else {
- _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID)
- }
if store.joinedSharedGameIDs().contains(gameID) { return gameID }
+ if Date() >= nextRefetch {
+ if let zoneID {
+ // The gate only reads `puzzleSource`, so the backstop needs
+ // just the Game record — the initial accept fetch already
+ // pulled Moves/Players, and the grid re-fetches them on open.
+ _ = try? await syncEngine.fetchAcceptedSharedGameDirect(
+ gameID: gameID,
+ zoneID: zoneID,
+ onlyGame: true
+ )
+ } else {
+ _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID)
+ }
+ nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval)
+ if store.joinedSharedGameIDs().contains(gameID) { return gameID }
+ }
+
do {
try await Task.sleep(for: Self.joinSyncPollInterval)
} catch {
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -739,8 +739,18 @@ extension SyncEngine {
/// is not known locally yet, so `fetchGameDirect(scope:gameID:)` cannot
/// find its zone, and a full shared-zone discovery would query every
/// unknown shared zone before opening the one the user just tapped.
+ /// Pass `onlyGame: true` to fetch just the Game record. The join poll's
+ /// playability gate reads only `puzzleSource`, so its backstop re-fetches
+ /// don't need the two full-zone Moves/Player queries — the Puzzle Grid
+ /// re-fetches those itself on open. A Game-only pass also leaves the
+ /// live-query checkpoint untouched, since it hasn't comprehensively read the
+ /// zone's Moves/Players and must not let a later `since:` query skip them.
@discardableResult
- func fetchAcceptedSharedGameDirect(gameID: UUID, zoneID: CKRecordZone.ID) async throws -> Bool {
+ func fetchAcceptedSharedGameDirect(
+ gameID: UUID,
+ zoneID: CKRecordZone.ID,
+ onlyGame: Bool = false
+ ) async throws -> Bool {
let database = container.sharedCloudDatabase
let gameRecordID = CKRecord.ID(
recordName: RecordSerializer.recordName(forGameID: gameID),
@@ -755,14 +765,14 @@ extension SyncEngine {
for: [gameRecordID],
desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"]
)
- async let movesTask = queryLiveRecords(
+ async let movesTask = onlyGame ? [] : queryLiveRecords(
type: "Moves",
database: database,
zoneID: zoneID,
since: nil,
desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
)
- async let playersTask = queryLiveRecords(
+ async let playersTask = onlyGame ? [] : queryLiveRecords(
type: "Player",
database: database,
zoneID: zoneID,
@@ -780,7 +790,11 @@ extension SyncEngine {
}
let records = moves + players + [game]
- if let latestModification = records.compactMap(\.modificationDate).max() {
+ // Only advance the checkpoint when this pass actually read the zone's
+ // Moves/Players; a Game-only backstop hasn't, so leaving it alone keeps
+ // a later `fetchGameDirect(since:)` from skipping unfetched moves.
+ if !onlyGame,
+ let latestModification = records.compactMap(\.modificationDate).max() {
setLiveQueryCheckpoint(latestModification, scopeValue: 1, gameID: gameID)
}
@@ -791,7 +805,9 @@ extension SyncEngine {
)
await trace(
"shared accepted-game fetch: \(gameID.uuidString.prefix(8)), " +
- "game=1, moves=\(moves.count), players=\(players.count)"
+ (onlyGame
+ ? "game=1 (game-only backstop)"
+ : "game=1, moves=\(moves.count), players=\(players.count)")
)
return true
}