commit fd383b72e1809739db5bc6851dbfdfc86a08db53
parent 2d34ca07102fba746333386ad2a76eb94095fb0d
Author: Michael Camilleri <[email protected]>
Date: Tue, 2 Jun 2026 14:14:32 +0900
Extract ensureInBackground for guaranteed flushes
Two completion/background paths held a UIApplication
background-execution assertion so a flush + CKSyncEngine enqueue could
finish even if iOS suspended the app: syncOnBackground (the
on-background MovesUpdater flush) and beginCompletionJournalUpload. Each
open-coded the same dance — take the assertion synchronously before the
first await, run the work in a Task, release on completion, release
again in the expiration handler — and each carried its own token storage
(a single slot or a per-game map) plus a paired end…BackgroundTask
helper to release it idempotently. Adding a third such action meant
copying the pattern and reviewing the release bookkeeping all over
again.
Both now route through a single ensureInBackground(_:_:) helper. The
assertion owns its own lifetime via a captured token and a released
latch, so it self-releases exactly once and needs no instance slot —
overlapping calls each hold an independent assertion. It is correct only
on the main actor (the expiration handler and the Task completion both
run there, so the latch serialises into a single endBackgroundTask; a
double release is a UIKit fault), which the doc comment spells out. This
deletes the backgroundFlushTask slot, the journalUploadBackgroundTasks
map and both release helpers.
scheduleSessionEndPush keeps its own assertion: it guards a cancelable
grace timer whose token is released from outside the work, and its
expiration handler fires the pause early rather than merely releasing —
a different shape that ensureInBackground deliberately doesn't model.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 45 insertions(+), 67 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -88,19 +88,6 @@ final class AppServices {
/// fires the pause early rather than letting suspension drop it. Keyed by
/// game so a per-game timer owns exactly one assertion.
private var sessionEndBackgroundTasks: [UUID: UIBackgroundTaskIdentifier] = [:]
- /// Background-execution assertions held while a just-completed game's
- /// journal is flushed and registered for upload, so a solver who
- /// 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()
@@ -650,37 +637,44 @@ final class AppServices {
isGameListVisible = false
}
- /// 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.
+ /// Runs `work` to completion under a `UIApplication` background-execution
+ /// assertion, so a flush or enqueue that begins as the app heads to the
+ /// background still reaches durable state before iOS suspends us. The
+ /// assertion is taken **synchronously** — before `work`'s first await — and
+ /// released exactly once: when `work` returns, or when iOS signals imminent
+ /// expiration, whichever comes first. Best-effort: a force-quit before
+ /// `work` lands is the one case this can't cover, so callers pair it with a
+ /// foreground reconcile sweep that re-runs anything that didn't finish.
+ ///
+ /// The assertion owns its own lifetime — no instance slot — so overlapping
+ /// calls each hold an independent assertion that self-releases. Correct
+ /// only on the main actor: the expiration handler and the completion both
+ /// run there, so the `released` latch serialises into a single
+ /// `endBackgroundTask` (a double release is a UIKit fault). `name` is the
+ /// debug label iOS shows for the assertion.
+ func ensureInBackground(_ name: String, _ work: @escaping () async -> Void) {
+ var token = UIBackgroundTaskIdentifier.invalid
+ var released = false
+ func release() {
+ guard !released, token != .invalid else { return }
+ released = true
+ UIApplication.shared.endBackgroundTask(token)
+ }
+ token = UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: release)
+ Task {
+ await work()
+ release()
+ }
+ }
+
+ /// Flush buffered cell edits on the way to the background. Held under a
+ /// background assertion so the persist + CKSyncEngine enqueue completes
+ /// even if the scene is suspended immediately; whatever doesn't land is
+ /// recovered by the next foreground's `enqueueUnconfirmedMoves`.
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
+ ensureInBackground("moves-flush") { [weak self] in
+ await self?.movesUpdater.flush()
}
- backgroundFlushTask = nil
- UIApplication.shared.endBackgroundTask(task)
}
/// Completion fan-out, delivered through the push worker. Win sets
@@ -1466,40 +1460,24 @@ final class AppServices {
}
/// Edge-triggered, immediate companion to `reconcilePendingJournalUploads`:
- /// fired synchronously on completion (`GameStore.onJournalComplete`). Takes a
- /// background-execution assertion *before* any await so the journal flush and
- /// the CKSyncEngine enqueue reach durable state even if the user backgrounds
- /// the app the instant they finish; CKSyncEngine then completes the send.
- /// The flush runs regardless of iCloud (it persists local journal entries
- /// that would otherwise be lost on termination); the enqueue is gated on
- /// sync being enabled. A force-quit before the assertion's work completes is
- /// the one case this can't cover — that falls to the foreground sweep.
+ /// fired synchronously on completion (`GameStore.onJournalComplete`). Runs
+ /// under a background assertion so the journal flush and the CKSyncEngine
+ /// enqueue reach durable state even if the user backgrounds the app the
+ /// instant they finish; CKSyncEngine then completes the send. The flush runs
+ /// regardless of iCloud (it persists local journal entries that would
+ /// otherwise be lost on termination); the enqueue is gated on sync being
+ /// enabled. A force-quit before the work completes is the one case this
+ /// can't cover — that falls to the foreground sweep.
func beginCompletionJournalUpload(gameID: UUID, authorID: String) {
- endJournalUploadBackgroundTask(gameID: gameID)
- journalUploadBackgroundTasks[gameID] = UIApplication.shared.beginBackgroundTask(
- withName: "journal-upload-\(gameID.uuidString)"
- ) { [weak self] in
- // Suspension imminent — release best-effort. If the enqueue didn't
- // land, the next foreground sweep re-enqueues it.
- self?.endJournalUploadBackgroundTask(gameID: gameID)
- }
- Task { [weak self] in
+ ensureInBackground("journal-upload-\(gameID.uuidString)") { [weak self] in
guard let self else { return }
await self.store.flushJournal()
if self.preferences.isICloudSyncEnabled {
await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
}
- self.endJournalUploadBackgroundTask(gameID: gameID)
}
}
- /// Releases the journal-upload assertion for `gameID`, if held. Safe to call
- /// repeatedly — a missing entry is a no-op.
- private func endJournalUploadBackgroundTask(gameID: UUID) {
- guard let task = journalUploadBackgroundTasks.removeValue(forKey: gameID) else { return }
- UIApplication.shared.endBackgroundTask(task)
- }
-
private func freshenGameList(
scope: CKDatabase.Scope,
reason: FreshenReason