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:
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(