crossmate

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

commit 39f5d88a629f7369095156a3038f1ad6fa076965
parent 35441f5d9f1002487dcb538b6e59636641639025
Author: Michael Camilleri <[email protected]>
Date:   Fri,  3 Jul 2026 13:04:44 +0900

Trace engine-state save failures and convert RecordApplier's batch block

A failed SyncEngine state save means the next launch cold-starts sync —
change tokens rebuild and pending changes resend from scratch — but the
only evidence was a bare print from the nonisolated trace fallback,
invisible in Production, where the on-device diagnostics log is the sole
observability. This commit has the resetSyncState and saveEngineState
perform closures return the failure message so the caller can route it
through the async trace after the block, the same collect-then-trace
shape the send path uses for its failure messages.  A serialization that
fails to encode is surfaced the same way instead of silently skipping
the save. The print-only trace is deleted now that nothing calls it.

This commit also converts applyDirectRecordZoneChanges from
performAndWait to `await ctx.perform`. It lives in an extension on the
sync actor, so the earlier pass that converted the rest of the actor's
sites missed it; the closure is identical in shape to the converted
handleFetchedRecordZoneChanges and its helpers were already nonisolated.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Sync/RecordApplier.swift | 8++++----
MCrossmate/Sync/SyncEngine.swift | 22+++++++++++-----------
2 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -58,7 +58,7 @@ struct BatchEffects { /// newly-arrived game featuring a blocked author is hidden without a full /// library scan. var visibilityCandidateGameIDs = Set<UUID>() - /// Diagnostics emitted while applying the batch inside `performAndWait` — + /// Diagnostics emitted while applying the batch inside `ctx.perform` — /// chiefly Core Data fetch/save failures, which silently drop records (the /// engine's change token has already advanced, so they never redeliver). /// The batch context can't `await`, so messages accumulate here and the @@ -78,7 +78,7 @@ extension SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let effects: BatchEffects = ctx.performAndWait { + let effects: BatchEffects = await ctx.perform { var effects = BatchEffects() for record in records { switch record.recordType { @@ -391,7 +391,7 @@ extension SyncEngine { /// Merges every device's `MovesEntity` row for `gameID` and reconciles the /// `CellEntity` cache against the resulting grid. Must be called inside a - /// `performAndWait` block on the same context. Returns diagnostic messages + /// `perform` block on the same context. Returns diagnostic messages /// for any fetch failure (normally empty) — the caller folds them into /// `BatchEffects.traces` so they reach the diagnostics log once the batch /// context unwinds. @@ -475,7 +475,7 @@ extension SyncEngine { /// Formats a sync-context fetch/save failure for the diagnostics log. The /// engine's change token has already advanced by the time these helpers - /// run inside performAndWait, so the only available remediation is making + /// run inside the batch perform block, so the only available remediation is making /// the drop visible — and visible means traced (the on-device diagnostics /// log), not printed: console output never reaches a collected log. nonisolated static func syncErrorMessage(_ label: String, gameID: UUID, error: Error) -> String { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1264,16 +1264,18 @@ actor SyncEngine { /// moves are re-enqueued so the new engines push them on the next cycle. func resetSyncState() async { let ctx = syncStateContext - await ctx.perform { + let failureMessage: String? = await ctx.perform { let entity = SyncStateEntity.current(in: ctx) entity.ckPrivateEngineState = nil entity.ckSharedEngineState = nil do { try ctx.save() + return nil } catch { - self.trace("resetSyncState ctx.save failed — \(error)") + return "resetSyncState ctx.save failed — \(error)" } } + if let failureMessage { await trace(failureMessage) } privateEngine = CKSyncEngine(CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: nil, @@ -1333,8 +1335,10 @@ actor SyncEngine { isPrivate: Bool ) async { let ctx = syncStateContext - await ctx.perform { - guard let data = try? JSONEncoder().encode(serialization) else { return } + let failureMessage: String? = await ctx.perform { + guard let data = try? JSONEncoder().encode(serialization) else { + return "saveEngineState encode failed — engine state not persisted" + } let entity = SyncStateEntity.current(in: ctx) if isPrivate { entity.ckPrivateEngineState = data @@ -1343,10 +1347,12 @@ actor SyncEngine { } do { try ctx.save() + return nil } catch { - self.trace("saveEngineState ctx.save failed — \(error)") + return "saveEngineState ctx.save failed — \(error)" } } + if let failureMessage { await trace(failureMessage) } } private func handleFetchedDatabaseChanges( @@ -2177,12 +2183,6 @@ actor SyncEngine { entity.journalUploaded = true } - // MARK: - Logging helpers - - private nonisolated func trace(_ message: String) { - print("SyncEngine: \(message)") - } - } // MARK: - CKSyncEngineDelegate