crossmate

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

commit 294def3a17a24bc352872b13b34fd973a4080d96
parent b664fb99a593f9528a556fd75fffcadbda1509e8
Author: Michael Camilleri <[email protected]>
Date:   Mon, 13 Apr 2026 06:39:49 +0900

Expand instrumentation of sync logic

Diffstat:
MCrossmate/CrossmateApp.swift | 5+++++
MCrossmate/Sync/SyncEngine.swift | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
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 [] } }