crossmate

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

commit 2d9c2e4f2ace8ea774d95d16b1e7fbef43972a88
parent 681c1167a501aa2f35b856afec48480d776db43e
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 00:17:22 +0900

Flush buffered cell edits under a background assertion

A typed letter is broadcast to peers over the engagement channel
synchronously and immediately, while its durable write is deferred:
GameMutator hands the edit to MovesUpdater, which debounces before
persisting to the MovesEntity row and enqueueing the CloudKit send. The
on-background flush that should close this gap ran as a bare
fire-and-forget Task off the scenePhase change, with no background-
execution assertion — so iOS could suspend the app mid-flush. The result
is a letter a collaborator saw live that never reached Core Data or
iCloud: a sibling device opening the game later merges everyone's Moves
records and shows a hole the peer never saw.

syncOnBackground now takes a UIApplication background-execution
assertion synchronously — before the flush Task's first await — and
releases it when the flush completes (or in the expiration handler if
suspension is imminent), mirroring the completion journal-upload
backstop. The scenePhase handler calls it synchronously rather than
wrapping it in a Task, so the assertion is held before any suspension
point. A force-quit before the work lands is the one case this can't
cover; the next foreground's enqueueUnconfirmedMoves re-enqueues
anything that reached Core Data. Per-keystroke behaviour is untouched,
so this doesn't reintroduce the typing-time churn that earlier
coalescing removed

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 5++++-
MCrossmate/Services/AppServices.swift | 41+++++++++++++++++++++++++++++++++++++++--
2 files changed, 43 insertions(+), 3 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -361,7 +361,10 @@ struct RootView: View { case .background, .inactive: services.noteAppForeground(false) NotificationState.setActivePuzzleID(nil) - Task { await services.syncOnBackground() } + // Synchronous: takes a background-execution assertion before the + // flush Task suspends, so buffered edits persist + enqueue even + // if the scene is suspended immediately. + services.syncOnBackground() @unknown default: break } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -93,6 +93,14 @@ final class AppServices { /// backgrounds the app the instant they win still reaches CKSyncEngine's /// durable state before suspension. Keyed by game. private var journalUploadBackgroundTasks: [UUID: UIBackgroundTaskIdentifier] = [:] + /// Background-execution assertion held while the on-background MovesUpdater + /// flush persists buffered cell edits and hands them to CKSyncEngine. App- + /// wide (one flush covers every game), so a single slot rather than a + /// per-game map. Without it the flush — spawned as a Task as the scene + /// leaves the foreground — can be suspended before it persists, which is + /// how a letter shown live to a peer over engagement can fail to reach + /// Core Data or iCloud. + private var backgroundFlushTask: UIBackgroundTaskIdentifier? /// Per-game "session announced to peers" state machine driving the /// once-per-session begin push; see `SessionAnnouncementLog`. private var sessionAnnouncements = SessionAnnouncementLog() @@ -642,8 +650,37 @@ final class AppServices { isGameListVisible = false } - func syncOnBackground() async { - await movesUpdater.flush() + /// Flush buffered cell edits on the way to the background. The assertion is + /// grabbed **synchronously** here — before the flush Task's first await — so + /// the persist + CKSyncEngine enqueue completes after the scene is + /// suspended. Mirrors `beginCompletionJournalUpload`. A force-quit before + /// the work lands is the one case this can't cover; `enqueueUnconfirmedMoves` + /// on the next foreground re-enqueues anything that reached Core Data. + func syncOnBackground() { + endBackgroundFlushTask() + backgroundFlushTask = UIApplication.shared.beginBackgroundTask( + withName: "moves-flush" + ) { [weak self] in + // Suspension imminent — release best-effort. Whatever persisted is + // recovered by the next foreground's enqueueUnconfirmedMoves. + self?.endBackgroundFlushTask() + } + Task { [weak self] in + guard let self else { return } + await self.movesUpdater.flush() + self.endBackgroundFlushTask() + } + } + + /// Releases the on-background flush assertion, if held. Safe to call + /// repeatedly — a missing or invalid token is a no-op. + private func endBackgroundFlushTask() { + guard let task = backgroundFlushTask, task != .invalid else { + backgroundFlushTask = nil + return + } + backgroundFlushTask = nil + UIApplication.shared.endBackgroundTask(task) } /// Completion fan-out, delivered through the push worker. Win sets