crossmate

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

commit 628182a7d3be8e54690f066cfcae0a02f3a15777
parent e67cd76ae4688b8c2386447774baad2599fc6df8
Author: Michael Camilleri <[email protected]>
Date:   Sun, 19 Jul 2026 11:19:36 +0900

Serialise startup before draining buffered push wakes

A buffered wake could start SyncEngine as soon as onRemoteNotification
was assigned, while AppServices.start was still installing its change
callbacks. The first fetch could then advance its token without
delivering the one-shot identity and state updates that arrived with it.
A wake racing an already-entered start also returned before startup and
its drain were complete.

This commit gives AppServices.start one in-flight task that every caller
awaits, and installs the remote-notification handler only after the
complete SyncEngine callback graph is ready. The background-wake path
now has a narrow test seam that verifies its fetch callback remains open
until racing startup finishes and the buffered notification drains.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 20++++++++++++++------
MCrossmate/Services/AppServices.swift | 53+++++++++++++++++++++++++++++++++++++----------------
MTests/Unit/AppDelegatePushBufferTests.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 119 insertions(+), 22 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -174,6 +174,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU /// a new drain awaits its predecessor, so replays never interleave. private var remoteNotificationDrain: Task<Void, Never>? + /// Test seams for the process-global UIApplication/AppServices lookup in + /// the background-only startup path. Production leaves both nil. + var isInstalledApplicationDelegateForTesting: Bool? + var startServicesForTesting: (() async -> Void)? + private func drainBufferedRemoteNotifications() { guard onRemoteNotification != nil, !bufferedRemoteNotifications.isEmpty else { return } let previous = remoteNotificationDrain @@ -340,11 +345,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU // Pre-start arrival: buffer the wake, then drive startup — this // push may be the only driver the process gets, because on a // background-only launch no scene activates and the root view's - // startup task never runs. `start` is idempotent, so racing the - // root task is safe: if startup is already under way this returns - // promptly and the drain runs when the handler installs, exactly - // as in a foreground cold launch. When this call *is* the one - // that starts services, awaiting the drain keeps the fetch + // startup task never runs. `start` is one-shot and shares its + // in-flight task, so racing the root task waits for the same + // handler-ready boundary. Awaiting the drain then keeps the fetch // completion honest — it reports after the buffered work ran, // inside the background execution budget. // @@ -361,7 +364,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU presenceUntil: presenceUntil, isBackground: isBackground )) - if application.delegate === self, let services = AppServices.current { + let isInstalledDelegate = isInstalledApplicationDelegateForTesting + ?? (application.delegate === self) + if isInstalledDelegate, let startServicesForTesting { + await startServicesForTesting() + await remoteNotificationDrain?.value + } else if isInstalledDelegate, let services = AppServices.current { await services.start(appDelegate: self) await remoteNotificationDrain?.value } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -388,7 +388,11 @@ final class AppServices { let preferences: PlayerPreferences private let ckContainer = CloudContainer.container - private var started = false + /// The process-wide startup operation. Retaining the task makes `start` + /// both one-shot and awaitable: a caller that arrives while startup is in + /// progress waits for the same readiness boundary instead of treating + /// "startup entered" as "startup complete." + private var startupTask: Task<Void, Never>? private var syncStarted = false /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent /// callers so the cold-launch race between `services.start()` and a @@ -791,9 +795,19 @@ final class AppServices { } func start(appDelegate: AppDelegate) async { - guard !started else { return } - started = true + if let startupTask { + await startupTask.value + return + } + let task = Task { @MainActor in + await self.performStartup(appDelegate: appDelegate) + } + startupTask = task + await task.value + } + + private func performStartup(appDelegate: AppDelegate) async { // One-time transition off the pre-v4 CloudKit container. The v4 build // points at a brand-new, empty container, so any games this device // cached under v3 are orphans that can never sync again — wipe them and @@ -852,19 +866,6 @@ final class AppServices { GameEntity.rebuildContentKeyDirectory(in: nicknameCtx) } - appDelegate.onRemoteNotification = { - summary, scope, event, gameID, kind, senderDeviceID, presenceUntil, isBackground in - await self.handleRemoteNotification( - summary: summary, - scope: scope, - event: event, - gameID: gameID, - kind: kind, - senderDeviceID: senderDeviceID, - presenceUntil: presenceUntil, - isBackground: isBackground - ) - } appDelegate.onVisibleNotificationReceiptsAvailable = { [weak self] in Task { @MainActor in self?.importVisibleNotificationReceipts() @@ -1285,6 +1286,26 @@ final class AppServices { } ) + // Install this only after every SyncEngine callback above is ready. + // Assignment starts the delegate's buffered-notification drain; doing + // it earlier could let that drain start SyncEngine and advance its + // change token while one-shot callbacks were still absent. Keep the + // installation above the sync-enablement guard so buffered wakes are + // also drained (and diagnosed as ignored) when iCloud sync is off. + appDelegate.onRemoteNotification = { + summary, scope, event, gameID, kind, senderDeviceID, presenceUntil, isBackground in + await self.handleRemoteNotification( + summary: summary, + scope: scope, + event: event, + gameID: gameID, + kind: kind, + senderDeviceID: senderDeviceID, + presenceUntil: presenceUntil, + isBackground: isBackground + ) + } + guard await ensureICloudSyncStarted() else { syncMonitor.note("iCloud sync disabled — engine startup skipped") return diff --git a/Tests/Unit/AppDelegatePushBufferTests.swift b/Tests/Unit/AppDelegatePushBufferTests.swift @@ -7,6 +7,37 @@ import UIKit @Suite("App delegate push buffering") @MainActor struct AppDelegatePushBufferTests { + private actor StartupProbe { + private(set) var started = false + private(set) var finished = false + private(set) var callbackReturned = false + private var continuation: CheckedContinuation<Void, Never>? + private var resumeRequested = false + + func pause() async { + started = true + if resumeRequested { + finished = true + return + } + await withCheckedContinuation { continuation = $0 } + finished = true + } + + func resume() { + if let continuation { + continuation.resume() + self.continuation = nil + } else { + resumeRequested = true + } + } + + func noteCallbackReturned() { + callbackReturned = true + } + } + @Test("An APNs token that beats handler installation is replayed once") func tokenReplayedOnce() { let delegate = AppDelegate() @@ -100,4 +131,41 @@ struct AppDelegatePushBufferTests { ]) #expect(drained.count == 3) } + + @Test("A background wake waits for racing startup and the buffered drain") + func backgroundWakeAwaitsStartupAndDrain() async { + let delegate = AppDelegate() + let startup = StartupProbe() + let gameID = UUID() + var received: [UUID?] = [] + + delegate.isInstalledApplicationDelegateForTesting = true + delegate.startServicesForTesting = { + await startup.pause() + delegate.onRemoteNotification = { _, _, _, gameID, _, _, _, _ in + received.append(gameID) + } + } + + let resultTask = Task { @MainActor in + let result = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [ + "kind": "presence", + "gameID": gameID.uuidString, + "senderDeviceID": "alice-device", + ]) + await startup.noteCallbackReturned() + return result + } + + for _ in 0..<1000 where !(await startup.started) { await Task.yield() } + #expect(await startup.started) + #expect(!(await startup.finished)) + #expect(!(await startup.callbackReturned)) + #expect(received.isEmpty) + + await startup.resume() + #expect(await resultTask.value == .newData) + #expect(await startup.finished) + #expect(received == [gameID]) + } }