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:
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