commit 2446484fe1026f01254857662d038b029eed177b
parent 62674e9f3aed9ba7f69b4063abfb35bb013f63e6
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 14:41:49 +0900
Suppress redundant freshens between push signals
Diagnostic capture during a typical session — Game List appearance,
pull-to-refresh, entry into a Puzzle Grid, square filling, Game List
reappearance — showed freshenGameListScope running its full discovery +
game/moves catch-up on every view event.
Silent pushes from iCloud are the primary 'something changed' signal; the
freshen exists as a backstop for the case where Apple drops a silent push or a
share-accept notification. The freshen is now gated per-scope on a 5-minute
cooldown that skips when no inbound push has arrived since the last successful
run. The bypass conditions: .manual (pull-to-refresh) always runs, and any push
later than the last successful freshen always runs. Otherwise a single trace
line records the skip with the elapsed time. The gate covers both discovery and
the game/moves catch-up — the same logic applies to both, and discovery's
per-scope round-trip is worth saving when nothing else fired it.
The timestamp updates only on catch-up success (the more expensive
phase), so a transient discovery error doesn't suppress the next
attempt indefinitely. lastRemoteNotificationAt is the existing global
push timestamp reused as the bypass signal — slight over-trigger across
scopes, but harmless because the bypassed scope just runs and either
finds something or doesn't.
Separately, PlayerSelectionPublisher's default debounce moves from
300ms to 500ms to match MovesUpdater. A typing burst now fires both
Player and Moves debounces at the same instant rather than 200ms apart,
giving CKSyncEngine a real chance to batch the two saves into one HTTP
request instead of two.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -63,6 +63,17 @@ final class AppServices {
private var isHandlingSharedRemoteNotification = false
private var isFresheningPrivateGameList = false
private var isFresheningSharedGameList = false
+ /// Wall-clock timestamp of the last successful game-list freshen per
+ /// scope, used to suppress redundant polls when no inbound push has
+ /// arrived since. Pushes own freshness; the freshen (zone discovery +
+ /// game/moves catch-up) is only a backstop for the case where Apple
+ /// drops a silent push or a share-accept notification.
+ private var lastPrivateGameListFreshenAt: Date?
+ private var lastSharedGameListFreshenAt: Date?
+ /// Maximum staleness budget for the game list before an unprompted view
+ /// event re-runs the freshen. Bypassed by `.manual` and by any push that
+ /// arrived after the last successful freshen.
+ private let gameListFreshenCooldown: TimeInterval = 300
private var fresheningPuzzleGridKeys: Set<String> = []
private var isGameListVisible = false
@@ -397,6 +408,9 @@ final class AppServices {
reason: FreshenReason
) async {
let reasonLabel = reason.diagnosticLabel
+ if !shouldRunGameListFreshen(scope: scope, reason: reason, label: label) {
+ return
+ }
guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else {
return
}
@@ -405,8 +419,70 @@ final class AppServices {
await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") {
_ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
}
- await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") {
- _ = try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
+ let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") {
+ try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
+ }
+ if catchUpResult != nil {
+ noteGameListFreshenCompleted(scope: scope)
+ }
+ }
+
+ /// Decides whether a game-list freshen should run for this scope. The
+ /// freshen polls each active zone for new records and enumerates
+ /// database zones for newly-shared games; between inbound pushes it
+ /// can't surface anything new, so we skip when the push signal hasn't
+ /// moved since the last successful run and the staleness budget hasn't
+ /// been exhausted. `.manual` (pull-to-refresh) always runs because the
+ /// user explicitly asked.
+ private func shouldRunGameListFreshen(
+ scope: CKDatabase.Scope,
+ reason: FreshenReason,
+ label: String
+ ) -> Bool {
+ if reason == .manual {
+ return true
+ }
+ guard let last = lastGameListFreshenAt(scope: scope) else {
+ return true
+ }
+ if let pushAt = lastRemoteNotificationAt, pushAt > last {
+ return true
+ }
+ let elapsed = Date().timeIntervalSince(last)
+ if elapsed >= gameListFreshenCooldown {
+ return true
+ }
+ let elapsedSeconds = Int(elapsed.rounded())
+ syncMonitor.note(
+ "freshen game list \(reason.diagnosticLabel): \(label) skipped (cooldown, last \(elapsedSeconds)s ago)"
+ )
+ return false
+ }
+
+ private func lastGameListFreshenAt(scope: CKDatabase.Scope) -> Date? {
+ switch scope {
+ case .private:
+ return lastPrivateGameListFreshenAt
+ case .shared:
+ return lastSharedGameListFreshenAt
+ case .public:
+ return nil
+ @unknown default:
+ return nil
+ }
+ }
+
+ private func noteGameListFreshenCompleted(scope: CKDatabase.Scope) {
+ let now = Date()
+ switch scope {
+ case .private:
+ lastPrivateGameListFreshenAt = now
+ case .shared:
+ lastSharedGameListFreshenAt = now
+ case .public:
+ return
+ @unknown default:
+ return
}
}
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -29,7 +29,7 @@ actor PlayerSelectionPublisher {
private var fallbackName: String = ""
init(
- debounceInterval: Duration = .milliseconds(300),
+ debounceInterval: Duration = .milliseconds(500),
persistence: PersistenceController,
sink: @escaping @Sendable (UUID, String) async -> Void,
sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }