commit 1f1777c8b879b7c0131a35e7347e831aa60f1e34
parent 656995e45142d659fd4459b1840a1ae8c01f399f
Author: Michael Camilleri <[email protected]>
Date: Sun, 24 May 2026 04:16:19 +0900
Surface Core Data save failures in the diagnostics log
This commit adds some logging after a review of the `try? ctx.save()` sites
that were silently dropping write failures. Each one now catches the error and
routes it into the diagnostics log so that a missed save is visible rather than
invisible.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
6 files changed, 97 insertions(+), 25 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -236,13 +236,16 @@ final class GameStore {
@ObservationIgnored
var onUnreadOtherMovesChanged: (() -> Void)?
+ private let eventLog: EventLog?
+
init(
persistence: PersistenceController,
movesUpdater: MovesUpdater,
authorIDProvider: @escaping @MainActor () -> String?,
onGameCreated: @escaping (String) -> Void,
onGameUpdated: @escaping (String) -> Void,
- onGameDeleted: @escaping (GameCloudDeletion) -> Void
+ onGameDeleted: @escaping (GameCloudDeletion) -> Void,
+ eventLog: EventLog? = nil
) {
self.persistence = persistence
self.movesUpdater = movesUpdater
@@ -250,6 +253,15 @@ final class GameStore {
self.onGameCreated = onGameCreated
self.onGameUpdated = onGameUpdated
self.onGameDeleted = onGameDeleted
+ self.eventLog = eventLog
+ }
+
+ private func saveContext(_ label: String) {
+ do {
+ try context.save()
+ } catch {
+ eventLog?.note("GameStore: \(label) save failed — \(error)", level: "error")
+ }
}
enum LoadError: Error {
@@ -345,7 +357,7 @@ final class GameStore {
}
if context.hasChanges {
- try? context.save()
+ saveContext("mergeRemoteMoves")
}
onUnreadOtherMovesChanged?()
}
@@ -528,7 +540,7 @@ final class GameStore {
// resignations and drive the directed `.win` ping's body.
entity.completedBy = authorIDProvider()
entity.hasPendingSave = true
- try? context.save()
+ saveContext("markCompleted")
if let ckName = entity.ckRecordName {
onGameUpdated(ckName)
}
@@ -555,7 +567,7 @@ final class GameStore {
for entity in (try? context.fetch(NSFetchRequest<InviteEntity>(entityName: "InviteEntity"))) ?? [] {
context.delete(entity)
}
- try? context.save()
+ saveContext("resetLocalState")
currentGame = nil
currentMutator = nil
currentEntity = nil
@@ -632,7 +644,7 @@ final class GameStore {
}
guard entity.lastReadOtherMoveAt != readAt else { return false }
entity.lastReadOtherMoveAt = readAt
- try? context.save()
+ saveContext("updateReadAt")
onUnreadOtherMovesChanged?()
return true
}
@@ -642,7 +654,7 @@ final class GameStore {
guard isShared, let latest = entity.latestOtherMoveAt else { return }
if (entity.lastReadOtherMoveAt ?? .distantPast) < latest {
entity.lastReadOtherMoveAt = latest
- try? context.save()
+ saveContext("markOtherMovesRead")
onUnreadOtherMovesChanged?()
}
}
@@ -753,7 +765,7 @@ final class GameStore {
entity.hasPushPending = true
entity.hasPendingSave = true
entity.populateCachedSummaryFields(from: puzzle)
- try? context.save()
+ saveContext("upgradePuzzleSource")
if let ckName = entity.ckRecordName {
onGameUpdated(ckName)
}
@@ -769,7 +781,7 @@ final class GameStore {
request.fetchLimit = 1
guard let entity = try? context.fetch(request).first else { return }
entity.puzzleCmVersion = Int64(XD.currentCmVersion)
- try? context.save()
+ saveContext("bumpPuzzleCmVersion")
}
private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) {
@@ -819,7 +831,7 @@ final class GameStore {
private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) {
Self.applyCellCache(to: gameEntity, from: grid, in: context)
- try? context.save()
+ saveContext("updateCellCache")
}
/// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row
diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift
@@ -12,7 +12,10 @@ final class PersistenceController {
var viewContext: NSManagedObjectContext { container.viewContext }
- init(inMemory: Bool = false) {
+ private let eventLog: EventLog?
+
+ init(inMemory: Bool = false, eventLog: EventLog? = nil) {
+ self.eventLog = eventLog
container = NSPersistentContainer(
name: "CrossmateModel",
managedObjectModel: Self.sharedModel
@@ -71,7 +74,18 @@ final class PersistenceController {
let xd = try? XD.parse(source) else { continue }
entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
}
- if bg.hasChanges { try? bg.save() }
+ if bg.hasChanges {
+ do {
+ try bg.save()
+ } catch {
+ Task { @MainActor [weak self] in
+ self?.eventLog?.note(
+ "PersistenceController: backfillCachedSummaryFields save failed — \(error)",
+ level: "error"
+ )
+ }
+ }
+ }
}
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -103,12 +103,12 @@ final class AppServices {
init() {
let preferences = PlayerPreferences()
self.preferences = preferences
- let persistence = PersistenceController()
+ let eventLog = EventLog()
+ self.eventLog = eventLog
+ let persistence = PersistenceController(eventLog: eventLog)
self.persistence = persistence
let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence)
self.syncEngine = syncEngine
- let eventLog = EventLog()
- self.eventLog = eventLog
self.syncMonitor = SyncMonitor(log: eventLog)
self.nytAuth = NYTAuthService()
self.driveMonitor = DriveMonitor()
@@ -184,7 +184,8 @@ final class AppServices {
onGameDeleted: { [preferences] deletion in
guard preferences.isICloudSyncEnabled else { return }
onGameDeletedHandler(deletion)
- }
+ },
+ eventLog: eventLog
)
self.store = store
@@ -214,7 +215,8 @@ final class AppServices {
container: self.ckContainer,
persistence: persistence,
syncEngine: syncEngine,
- syncMonitor: self.syncMonitor
+ syncMonitor: self.syncMonitor,
+ eventLog: eventLog
)
self.cloudService = CloudService(
container: self.ckContainer,
@@ -1164,7 +1166,15 @@ final class AppServices {
if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) }
}
- if ctx.hasChanges { try? ctx.save() }
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ Task { @MainActor [weak self] in
+ self?.eventLog.note("AppServices: applyInvitePings save failed — \(error)", level: "error")
+ }
+ }
+ }
}
}
@@ -1183,7 +1193,13 @@ final class AppServices {
let invites = (try? ctx.fetch(req)) ?? []
guard !invites.isEmpty else { return }
for invite in invites { ctx.delete(invite) }
- if ctx.hasChanges { try? ctx.save() }
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ eventLog.note("AppServices: removePendingInvite save failed — \(error)", level: "error")
+ }
+ }
}
/// Accepts a pending game invite: fetches the share metadata, joins via
@@ -1242,7 +1258,13 @@ final class AppServices {
}
ctx.delete(invite)
}
- if ctx.hasChanges { try? ctx.save() }
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ eventLog.note("AppServices: deleteInviteAndPing save failed — \(error)", level: "error")
+ }
+ }
}
/// Blocks a collaborator: marks the friendship blocked and tears down the
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -19,17 +19,20 @@ final class FriendController {
private let persistence: PersistenceController
private let syncEngine: SyncEngine
private let syncMonitor: SyncMonitor?
+ private let eventLog: EventLog?
init(
container: CKContainer,
persistence: PersistenceController,
syncEngine: SyncEngine,
- syncMonitor: SyncMonitor? = nil
+ syncMonitor: SyncMonitor? = nil,
+ eventLog: EventLog? = nil
) {
self.container = container
self.persistence = persistence
self.syncEngine = syncEngine
self.syncMonitor = syncMonitor
+ self.eventLog = eventLog
}
enum FriendError: Error {
@@ -264,7 +267,11 @@ final class FriendController {
let zoneName = friend.friendZoneName
let ownerName = friend.friendZoneOwnerName
friend.isBlocked = true
- try? ctx.save()
+ do {
+ try ctx.save()
+ } catch {
+ eventLog?.note("FriendController: blockAndTeardown save failed — \(error)", level: "error")
+ }
// Make the block authoritative across the user's own devices. Written
// before the channel teardown so the durable fact survives even a
@@ -461,7 +468,11 @@ final class FriendController {
entity.friendZoneOwnerName = zoneOwnerName
entity.databaseScope = databaseScope
if entity.createdAt == nil { entity.createdAt = Date() }
- try? ctx.save()
+ do {
+ try ctx.save()
+ } catch {
+ eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error")
+ }
}
private func displayNameFromPlayer(gameID: UUID, authorID: String) -> String? {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -703,7 +703,11 @@ actor SyncEngine {
let entity = SyncStateEntity.current(in: ctx)
entity.ckPrivateEngineState = nil
entity.ckSharedEngineState = nil
- try? ctx.save()
+ do {
+ try ctx.save()
+ } catch {
+ trace("resetSyncState ctx.save failed — \(error)")
+ }
}
privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
@@ -751,7 +755,11 @@ actor SyncEngine {
} else {
entity.ckSharedEngineState = data
}
- try? ctx.save()
+ do {
+ try ctx.save()
+ } catch {
+ trace("saveEngineState ctx.save failed — \(error)")
+ }
}
}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -10,6 +10,7 @@ struct GameListView: View {
@Binding var navigationPath: NavigationPath
@Environment(\.managedObjectContext) private var viewContext
+ @Environment(EventLog.self) private var eventLog
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FetchRequest(
@@ -486,7 +487,11 @@ struct GameListView: View {
private func decline(_ invite: InviteEntity) {
invite.status = "declined"
- try? viewContext.save()
+ do {
+ try viewContext.save()
+ } catch {
+ eventLog.note("GameListView: decline invite save failed — \(error)", level: "error")
+ }
}
@ViewBuilder