commit 294def3a17a24bc352872b13b34fd973a4080d96
parent b664fb99a593f9528a556fd75fffcadbda1509e8
Author: Michael Camilleri <[email protected]>
Date: Mon, 13 Apr 2026 06:39:49 +0900
Expand instrumentation of sync logic
Diffstat:
2 files changed, 87 insertions(+), 6 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -116,6 +116,11 @@ struct RootView: View {
store.applyRemoteChanges(changes)
}
+ // Wire sync engine traces → diagnostics view
+ await syncEngine.setTracer { [syncMonitor] message in
+ syncMonitor.note(message)
+ }
+
// Bootstrap and initial sync
await Self.run("bootstrap", monitor: syncMonitor) {
try await syncEngine.bootstrap()
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -21,6 +21,20 @@ actor SyncEngine {
onRemoteCellChanges = callback
}
+ /// Optional tracer for diagnostic messages emitted from inside the engine.
+ /// Wired up to `SyncMonitor.note` in CrossmateApp so traces appear in the
+ /// on-device diagnostics view.
+ private var tracer: (@MainActor @Sendable (String) -> Void)?
+
+ func setTracer(_ callback: @MainActor @Sendable @escaping (String) -> Void) {
+ tracer = callback
+ }
+
+ private func trace(_ message: String) async {
+ guard let tracer else { return }
+ await tracer(message)
+ }
+
init(container: CKContainer, persistence: PersistenceController) {
self.container = container
self.privateDatabase = container.privateCloudDatabase
@@ -114,8 +128,20 @@ actor SyncEngine {
func pushChanges() async throws {
let context = persistence.container.newBackgroundContext()
var serverWinsCellChanges: [RemoteCellChange] = []
+ var iteration = 0
+
+ await trace("push: enter")
while true {
+ iteration += 1
+ await trace("push[\(iteration)]: draining outbox")
+
+ let pendingCount: Int = context.performAndWait {
+ let req = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
+ return (try? context.count(for: req)) ?? -1
+ }
+ await trace("push[\(iteration)]: outbox count=\(pendingCount)")
+
let batch: [(recordName: String, recordType: String, payload: PendingChangePayload, systemFields: Data?)] =
context.performAndWait {
let pending = PendingChangeEntity.drain(limit: 400, in: context)
@@ -140,7 +166,16 @@ actor SyncEngine {
}
}
- guard !batch.isEmpty else { break }
+ await trace("push[\(iteration)]: batch size=\(batch.count)")
+
+ if batch.isEmpty && pendingCount > 0 {
+ await trace("push[\(iteration)]: WARNING outbox has \(pendingCount) but batch is empty (decode/drain mismatch)")
+ break
+ }
+ guard !batch.isEmpty else {
+ await trace("push[\(iteration)]: outbox empty, exiting")
+ break
+ }
// Build CKRecords
let records: [CKRecord] = batch.map { item in
@@ -160,8 +195,26 @@ actor SyncEngine {
}
}
+ for (i, item) in batch.enumerated() {
+ let kind = item.payload.recordType == .game ? "Game" : "Cell"
+ let hasSF = item.systemFields != nil ? "yes" : "no"
+ await trace("push[\(iteration)]: record[\(i)] \(kind) name=\(item.recordName) systemFields=\(hasSF)")
+ }
+
// Push via CKModifyRecordsOperation
- let perRecordResults = try await pushRecords(records)
+ await trace("push[\(iteration)]: calling pushRecords (\(records.count) records)")
+ let perRecordResults: [CKRecord.ID: Result<CKRecord, Error>]
+ do {
+ perRecordResults = try await pushRecords(records)
+ } catch {
+ await trace("push[\(iteration)]: pushRecords THREW: \(describe(error))")
+ throw error
+ }
+ await trace("push[\(iteration)]: pushRecords returned \(perRecordResults.count) result(s)")
+
+ var successes = 0
+ var failures = 0
+ var errorMessages: [String] = []
// Process results on the background context
context.performAndWait {
@@ -169,6 +222,7 @@ actor SyncEngine {
let recordName = recordID.recordName
switch result {
case .success(let savedRecord):
+ successes += 1
// Write ckSystemFields back to the entity
self.writeBackSystemFields(
record: savedRecord,
@@ -179,18 +233,35 @@ actor SyncEngine {
self.deletePendingChange(recordName: recordName, in: context)
case .failure(let error):
+ failures += 1
let changes = self.handlePushError(
error: error,
recordName: recordName,
- in: context
+ in: context,
+ errorSink: &errorMessages
)
serverWinsCellChanges.append(contentsOf: changes)
}
}
try? context.save()
}
+
+ await trace("push[\(iteration)]: processed \(successes) success / \(failures) failure")
+ for msg in errorMessages {
+ await trace("push[\(iteration)]: \(msg)")
+ }
+
+ if failures > 0 && successes == 0 {
+ // If every record in this batch failed, the next iteration
+ // will pull the same items and loop forever. Break out and
+ // surface the situation through the diagnostics view.
+ await trace("push[\(iteration)]: all-failure batch, aborting drain to avoid infinite loop")
+ break
+ }
}
+ await trace("push: exit")
+
// Route any server-wins cell changes through the single inbox
if let onRemoteCellChanges, !serverWinsCellChanges.isEmpty {
await onRemoteCellChanges(serverWinsCellChanges)
@@ -422,10 +493,12 @@ actor SyncEngine {
private nonisolated func handlePushError(
error: Error,
recordName: String,
- in context: NSManagedObjectContext
+ in context: NSManagedObjectContext,
+ errorSink: inout [String]
) -> [RemoteCellChange] {
guard let ckError = error as? CKError else {
- print("SyncEngine: non-CK error pushing \(recordName): \(error)")
+ let nsError = error as NSError
+ errorSink.append("\(recordName): non-CK error domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)")
return []
}
@@ -488,7 +561,10 @@ actor SyncEngine {
default:
// For other errors (network, throttle, etc.), leave the pending
// change in the outbox for the next push attempt.
- print("SyncEngine: error pushing \(recordName): \(ckError)")
+ let userInfo = ckError.userInfo
+ .map { "\($0.key)=\($0.value)" }
+ .joined(separator: " | ")
+ errorSink.append("\(recordName): CKError code=\(ckError.code.rawValue) \(ckError.localizedDescription) | \(userInfo)")
return []
}
}