crossmate

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

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:
MCrossmate/Services/AppServices.swift | 112++++++++++++++++++++++++++++++++-----------------------------------------------
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