crossmate

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

commit 7e60b0fcee19c3b5613c5f0f9555a67b614857b8
parent 96034e196b0ff28a0105e14ae0db7997f8d1c982
Author: Michael Camilleri <[email protected]>
Date:   Sat,  6 Jun 2026 09:04:14 +0900

Add logging for foreign Player writes

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1879,6 +1879,7 @@ extension SyncEngine: CKSyncEngineDelegate { ) async -> CKSyncEngine.RecordZoneChangeBatch? { let pending = engine.state.pendingRecordZoneChanges guard !pending.isEmpty else { return nil } + await traceForeignPlayerWrites(in: pending) let pingSnapshot = pendingPings let decisionSnapshot = pendingDecisionPayloads return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in @@ -1895,6 +1896,56 @@ extension SyncEngine: CKSyncEngineDelegate { } } + /// Diagnostic for the recurring "ghost peer": flags any *peer's* Player + /// slot in our outbound batch. A participant must only ever write its own + /// `(game, authorID)` record — `enqueuePlayer` is keyed to the local + /// author. A foreign authorID here means this device is about to upload + /// someone else's presence, which `RecordBuilder` stamps with our own + /// game-wide `lastReadOtherMoveAt` (`RecordBuilder.swift:131`). If that + /// horizon is a live read lease, the peer is resurrected as present with + /// our future `readAt`. We log the value the build will send so a future + /// lease (the ghost) is distinguishable from a current-time close, and so + /// the next recurrence names the culprit in one line rather than leaving it + /// to inference. Silent in the normal case (own slot only), so it adds no + /// noise until the bug actually fires. + private func traceForeignPlayerWrites( + in pending: [CKSyncEngine.PendingRecordZoneChange] + ) async { + guard let localAuthorID = await currentLocalAuthorID() else { return } + var foreign: [(UUID, String)] = [] + for change in pending { + guard case .saveRecord(let recordID) = change, + let (gameID, authorID) = + RecordSerializer.parsePlayerRecordName(recordID.recordName), + authorID != localAuthorID + else { continue } + foreign.append((gameID, authorID)) + } + guard !foreign.isEmpty else { return } + let ctx = persistence.container.newBackgroundContext() + for (gameID, authorID) in foreign { + let readAt: Date? = ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + return (try? ctx.fetch(req).first)?.lastReadOtherMoveAt + } + let leaseDesc: String + if let readAt { + let delta = Int(readAt.timeIntervalSinceNow) + leaseDesc = "readAt=\(readAt.ISO8601Format()) " + + (delta > 0 ? "(future +\(delta)s)" : "(past \(-delta)s)") + } else { + leaseDesc = "readAt=nil" + } + await trace( + "‼️ OUTBOUND peer Player[\(gameID.uuidString.prefix(8))] " + + "author=\(authorID.prefix(8)) local=\(localAuthorID.prefix(8)) " + + "\(leaseDesc) — uploading a peer's slot" + ) + } + } + /// Test seam: drives `makeRecordZoneChangeBatch` for the given scope's /// engine. Mirrors `pendingSaveRecordNames(scope:)`'s scope routing. func makeRecordZoneChangeBatch(