commit 9268ce328a22b85134d4fc9a2b4cb8a066f0f11a
parent a27fd1c70b3ed73dd4b7601e4ce033636edcb874
Author: Michael Camilleri <[email protected]>
Date: Mon, 1 Jun 2026 16:19:39 +0900
Ensure a completed game's replay journal gets uploaded
Replay's per-device Journal upload fired edge-style only: once at local
completion (persistCompletion → triggerJournalUpload →
enqueueJournalUpload) or when an inbound sync revealed the completion
(RecordSerializer's onCompletedTransition). The local-completion enqueue
is async — flush the journal, check prefs, then reach CKSyncEngine's
durable state — and runs after the win banner is already up. A solver
who swipes the app away the instant they finish can be suspended before
engine.state.add ever runs, so nothing is registered and nothing
retries: the edge never re-fires (the game is already complete locally)
and there was no level check. The contributor's Journal never publishes,
and because replay gates on strict completeness, every other player's
scrubber waits on that device forever with no signal.
The upload is now backed by two additions. At completion, GameStore's
onJournalComplete fires synchronously on the main actor so AppServices
can take a UIApplication background-execution assertion before any
await; the journal flush and the CKSyncEngine enqueue then run under it
(beginCompletionJournalUpload), so an instant background still reaches
durable state. Separately, reconcilePendingJournalUploads runs on every
foreground freshen as a level-triggered backstop: for each completed
game not yet confirmed uploaded it re-enqueues the upload (a re-send is
a benign no-op) when this device has local journal entries, else marks
it done. A new local-only GameEntity.journalUploaded flag, set on the
confirmed save in handleSentRecordZoneChanges, makes the sweep converge
to a no-op instead of re-enqueuing every freshen.
The flag is local Core Data only (lightweight, additive migration), not
a CloudKit field, so no schema change is needed. A force-quit before the
assertion's work completes is the one case neither path covers in the
moment — the foreground sweep catches it on next launch.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 127 insertions(+), 20 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -19,6 +19,7 @@
<attribute name="hasPendingSave" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="journalUploaded" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastReadOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO" renamingIdentifier="lastSeenOtherMoveAt"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latestOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -235,8 +235,10 @@ final class GameStore {
/// Called once a game completes (win or resign) with `(gameID, authorID)`,
/// so this device's move journal can be uploaded for later replay (Phase
/// 2). Separate from `onGameUpdated`: that re-pushes the Game record, this
- /// pushes the per-device Journal asset.
- private let onJournalComplete: (UUID, String) -> Void
+ /// pushes the per-device Journal asset. Assigned post-init (like the other
+ /// UI-facing callbacks below) so the handler can reference `AppServices`.
+ @ObservationIgnored
+ var onJournalComplete: (@MainActor (UUID, String) -> Void)?
/// Fires when the count of shared games with unseen other-author moves
/// may have changed (inbound moves merged, a game opened, a game
@@ -257,7 +259,6 @@ final class GameStore {
onGameCreated: @escaping (String) -> Void,
onGameUpdated: @escaping (String) -> Void,
onGameDeleted: @escaping (GameCloudDeletion) -> Void,
- onJournalComplete: @escaping (UUID, String) -> Void = { _, _ in },
eventLog: EventLog? = nil
) {
self.persistence = persistence
@@ -270,7 +271,6 @@ final class GameStore {
self.onGameCreated = onGameCreated
self.onGameUpdated = onGameUpdated
self.onGameDeleted = onGameDeleted
- self.onJournalComplete = onJournalComplete
self.eventLog = eventLog
}
@@ -683,16 +683,22 @@ final class GameStore {
return true
}
- /// Uploads this device's move journal for a just-completed game (Phase 2).
- /// Flushes the journal's async persistence first so the record builder,
- /// reading Core Data on its own context, sees every entry. Attributed to
- /// the local user (not the solver) — a resigner still has a log to upload.
+ /// Signals that a just-completed game's move journal should be uploaded
+ /// (Phase 2). Fired synchronously on the main actor at completion so the
+ /// app layer can take a background-execution assertion *before* any
+ /// suspension point — the flush and CKSyncEngine enqueue then run under it
+ /// via `flushJournal()`. Attributed to the local user (not the solver) — a
+ /// resigner still has a log to upload.
private func triggerJournalUpload(id: UUID) {
guard let authorID = authorIDProvider(), !authorID.isEmpty else { return }
- Task {
- await movesJournal.flush()
- onJournalComplete(id, authorID)
- }
+ onJournalComplete?(id, authorID)
+ }
+
+ /// Drains the journal's async persistence queue so the upload's record
+ /// builder, reading Core Data on its own context, sees every entry. Called
+ /// by the app-layer upload path under its background assertion.
+ func flushJournal() async {
+ await movesJournal.flush()
}
/// This device's live journal for a game, tagged with its device key. The
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -88,6 +88,11 @@ 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] = [:]
/// Per-game "session announced to peers" state machine driving the
/// once-per-session begin push; see `SessionAnnouncementLog`.
private var sessionAnnouncements = SessionAnnouncementLog()
@@ -256,12 +261,6 @@ final class AppServices {
guard preferences.isICloudSyncEnabled else { return }
onGameDeletedHandler(deletion)
},
- onJournalComplete: { [preferences, syncEngine] gameID, authorID in
- Task {
- guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
- await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
- }
- },
eventLog: eventLog
)
self.store = store
@@ -352,6 +351,9 @@ final class AppServices {
guard self.engagementStatus.isLive(gameID: gameID) else { return }
Task { await self.engagementCoordinator.sendCellEdits(edits) }
}
+ self.store.onJournalComplete = { [weak self] gameID, authorID in
+ self?.beginCompletionJournalUpload(gameID: gameID, authorID: authorID)
+ }
}
func start(appDelegate: AppDelegate) async {
@@ -1353,6 +1355,86 @@ final class AppServices {
reason: reason
)
await refreshSnapshot()
+ await reconcilePendingJournalUploads()
+ }
+
+ /// Level-triggered backstop for replay journal uploads. The upload is
+ /// normally fired edge-style — once, at local completion or when an inbound
+ /// sync reveals the completion. But the local-completion enqueue is async
+ /// (journal flush → prefs check → `enqueueJournalUpload`), so a solver who
+ /// swipes the app away the instant they win can be suspended before the save
+ /// reaches CKSyncEngine's durable state; nothing re-fires it, and replay's
+ /// strict completeness then waits on that contributor forever. This sweep
+ /// re-enqueues any completed game whose journal hasn't been confirmed
+ /// uploaded. A re-send is a benign no-op, and `journalUploaded` (set on the
+ /// confirmed save, here for games this device never contributed to) makes it
+ /// converge to a no-op rather than re-enqueuing every freshen.
+ private func reconcilePendingJournalUploads() async {
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ let candidates: [UUID] = ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "completedAt != nil AND journalUploaded == NO")
+ return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
+ }
+ guard !candidates.isEmpty else { return }
+
+ var nothingToUpload: [UUID] = []
+ for gameID in candidates {
+ if store.localJournalEntries(for: gameID).isEmpty {
+ nothingToUpload.append(gameID)
+ } else {
+ await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
+ }
+ }
+ guard !nothingToUpload.isEmpty else { return }
+
+ // No local journal for these — this device never played them, so there
+ // is nothing to publish. Mark them done so the sweep stops reconsidering.
+ ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id IN %@", nothingToUpload)
+ for game in (try? ctx.fetch(req)) ?? [] {
+ game.journalUploaded = true
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ }
+ }
+
+ /// 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.
+ 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
+ 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(
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1432,8 +1432,15 @@ actor SyncEngine {
var settledDecisions = Set<CKRecord.ID>()
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
- if record.recordID.recordName.hasPrefix("game-") {
- self.clearPendingSaveFlag(for: record.recordID.recordName, in: ctx)
+ let savedName = record.recordID.recordName
+ if savedName.hasPrefix("game-") {
+ self.clearPendingSaveFlag(for: savedName, in: ctx)
+ } else if savedName.hasPrefix("journal-"),
+ let (gid, _, _) = RecordSerializer.parseJournalRecordName(savedName) {
+ // Confirmed durable upload of this device's journal — record
+ // it so the level-triggered backstop
+ // (`reconcilePendingJournalUploads`) stops re-enqueuing it.
+ self.markJournalUploaded(gameID: gid, in: ctx)
}
}
for failure in event.failedRecordSaves {
@@ -1646,6 +1653,17 @@ actor SyncEngine {
entity.hasPushPending = false
}
+ private nonisolated func markJournalUploaded(
+ gameID: UUID,
+ in ctx: NSManagedObjectContext
+ ) {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first else { return }
+ entity.journalUploaded = true
+ }
+
// MARK: - Logging helpers
private nonisolated func trace(_ message: String) {