crossmate

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

commit 2c0f11b1dff0a9feeafecbffcd27c66a0a89ea58
parent c5b5e6280d4b6b1100679b6d015f5835b89b5b6c
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 14:40:26 +0900

Coalesce duplicate remote notification handlers

Device logs showed paired private database pushes starting the same work twice:
background notifications ran two session scans, while foreground notifications
ran duplicate zone discovery, ping fast-path scans and push fetches. The ping
dedup kept notifications from being presented twice, but the device still spent
CloudKit reads and filled diagnostics with repeated already-shown ping lines.

AppServices now tracks whether a private or shared remote-notification handler
is already in flight. A same-scope notification that arrives during that window
is coalesced into the active handler instead of starting duplicate work, with a
diagnostic breadcrumb noting the coalescing point.

Handling a scoped remote notification also cancels any pending delayed
Game/Moves catch-up for that scope. Background handlers schedule a fresh
catch-up after their session scan; foreground handlers supersede the delayed
work with their own fetch path.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 58 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -41,6 +41,8 @@ final class AppServices { private var lastRemoteNotificationAt: Date? private var privatePushCatchUpTask: Task<Void, Never>? private var sharedPushCatchUpTask: Task<Void, Never>? + private var isHandlingPrivateRemoteNotification = false + private var isHandlingSharedRemoteNotification = false init() { let preferences = PlayerPreferences() @@ -367,6 +369,11 @@ final class AppServices { return } + guard beginRemoteNotificationHandling(scope: scope) else { return } + defer { endRemoteNotificationHandling(scope: scope) } + + cancelBackgroundPushCatchUp(scope: scope) + if isBackground { let result = await syncMonitor.run("remote-notification background session scan") { try await syncEngine.fetchBackgroundSessionsDirect(scope: scope) @@ -422,6 +429,57 @@ final class AppServices { await refreshSnapshot() } + private func beginRemoteNotificationHandling(scope: CKDatabase.Scope) -> Bool { + switch scope { + case .private: + guard !isHandlingPrivateRemoteNotification else { + syncMonitor.note("private remote notification coalesced into in-flight handler") + return false + } + isHandlingPrivateRemoteNotification = true + return true + case .shared: + guard !isHandlingSharedRemoteNotification else { + syncMonitor.note("shared remote notification coalesced into in-flight handler") + return false + } + isHandlingSharedRemoteNotification = true + return true + case .public: + return false + @unknown default: + return false + } + } + + private func endRemoteNotificationHandling(scope: CKDatabase.Scope) { + switch scope { + case .private: + isHandlingPrivateRemoteNotification = false + case .shared: + isHandlingSharedRemoteNotification = false + case .public: + return + @unknown default: + return + } + } + + private func cancelBackgroundPushCatchUp(scope: CKDatabase.Scope) { + switch scope { + case .private: + privatePushCatchUpTask?.cancel() + privatePushCatchUpTask = nil + case .shared: + sharedPushCatchUpTask?.cancel() + sharedPushCatchUpTask = nil + case .public: + return + @unknown default: + return + } + } + private func scheduleBackgroundPushCatchUp(scope: CKDatabase.Scope) { switch scope { case .private: