crossmate

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

commit 4ea5c9f22df8c81341e0f7d2051ea6dbcba55624
parent e6c313e92b9fab577e61b8709ebbea420577e107
Author: Michael Camilleri <[email protected]>
Date:   Fri,  3 Jul 2026 10:54:18 +0900

Surface dropped puzzleSource assets in the diagnostics log

When an inbound Game record's puzzleSource asset is skipped for
exceeding the size cap, or its staged download file can't be read, the
game is left without playable content — and the only evidence was a bare
print, invisible in Production, where the on-device diagnostics log is
the sole observability. CKSyncEngine has already advanced its change
token by then, so the record never redelivers and the gap surfaces only
as a puzzle that mysteriously won't open.

This commit routes both messages through a new onDiagnostic callback on
applyGameRecord — the same optional-closure shape as its existing
onEngagementChange family — and both apply paths
(handleFetchedRecordZoneChanges and applyDirectRecordZoneChanges) feed
it into BatchEffects.traces, which they already flush to the event log
after each batch.

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

Diffstat:
MCrossmate/Sync/RecordApplier.swift | 3++-
MCrossmate/Sync/RecordSerializer.swift | 11++++++-----
MCrossmate/Sync/SyncEngine.swift | 3++-
MTests/Unit/RecordSerializerTests.swift | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 65 insertions(+), 7 deletions(-)

diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -89,7 +89,8 @@ extension SyncEngine { databaseScope: scopeValue, onEngagementChange: { effects.engagementChanged.insert($0) }, onCompletedTransition: { effects.completedTransitions.insert($0) }, - onContentKeyChange: { effects.contentKeysChanged.insert($0) } + onContentKeyChange: { effects.contentKeysChanged.insert($0) }, + onDiagnostic: { effects.traces.append($0) } ) if let id = entity.id { effects.rosterRelevant.insert(id) diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -782,7 +782,8 @@ enum RecordSerializer { databaseScope: DatabaseScope = .private, onEngagementChange: ((UUID) -> Void)? = nil, onCompletedTransition: ((UUID) -> Void)? = nil, - onContentKeyChange: ((UUID) -> Void)? = nil + onContentKeyChange: ((UUID) -> Void)? = nil, + onDiagnostic: ((String) -> Void)? = nil ) -> GameEntity { let recordName = record.recordID.recordName let entity = fetchOrCreate( @@ -900,8 +901,8 @@ enum RecordSerializer { // limit, so gate on the on-disk size first. if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > XD.maxSourceBytes { - print( - "RecordSerializer: puzzleSource asset for \(recordName) " + + onDiagnostic?( + "puzzleSource asset for \(recordName) " + "exceeds \(XD.maxSourceBytes) bytes (\(size)) — skipping" ) return entity @@ -928,8 +929,8 @@ enum RecordSerializer { // Surface the dropped puzzle source instead of silently // leaving the entity without playable content. let nsError = error as NSError - print( - "RecordSerializer: puzzleSource asset read failed for \(recordName) " + + onDiagnostic?( + "puzzleSource asset read failed for \(recordName) " + "— domain=\(nsError.domain) code=\(nsError.code) " + "\(nsError.localizedDescription)" ) diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1471,7 +1471,8 @@ actor SyncEngine { databaseScope: scope, onEngagementChange: { effects.engagementChanged.insert($0) }, onCompletedTransition: { effects.completedTransitions.insert($0) }, - onContentKeyChange: { effects.contentKeysChanged.insert($0) } + onContentKeyChange: { effects.contentKeysChanged.insert($0) }, + onDiagnostic: { effects.traces.append($0) } ) if let id = entity.id { effects.rosterRelevant.insert(id) diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -536,6 +536,61 @@ struct RecordSerializerTests { #expect(entity.title == "Test Puzzle") } + @Test("applyGameRecord skips an oversized puzzleSource asset and reports it via onDiagnostic") + @MainActor func applyGameRecordReportsOversizedAsset() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let recordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: RecordSerializer.zoneID(for: gameID) + ) + let oversized = String(repeating: "x", count: XD.maxSourceBytes + 1) + let (asset, tmpURL) = try makePuzzleAsset(source: oversized) + defer { try? FileManager.default.removeItem(at: tmpURL) } + let record = CKRecord(recordType: "Game", recordID: recordID) + record["puzzleSource"] = asset as CKRecordValue + + var diagnostics: [String] = [] + let entity = RecordSerializer.applyGameRecord( + record, + to: ctx, + onDiagnostic: { diagnostics.append($0) } + ) + + #expect((entity.puzzleSource ?? "").isEmpty) + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.contains("exceeds") == true) + } + + @Test("applyGameRecord reports a failed puzzleSource asset read via onDiagnostic") + @MainActor func applyGameRecordReportsFailedAssetRead() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let recordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: RecordSerializer.zoneID(for: gameID) + ) + // Delete the backing file before applying, simulating a CKAsset whose + // download staged file is gone by the time the record is applied. + let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource) + try FileManager.default.removeItem(at: tmpURL) + let record = CKRecord(recordType: "Game", recordID: recordID) + record["puzzleSource"] = asset as CKRecordValue + + var diagnostics: [String] = [] + let entity = RecordSerializer.applyGameRecord( + record, + to: ctx, + onDiagnostic: { diagnostics.append($0) } + ) + + #expect((entity.puzzleSource ?? "").isEmpty) + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.contains("read failed") == true) + } + @Test("populateGameRecord writes the title for an owner but not a participant") @MainActor func populateGameRecordGatesTitleOnOwnership() throws { let persistence = makeTestPersistence()