crossmate

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

commit 85511b54f005889749233a9592860403fe00f2e5
parent 8eb5441bae067f42ef9b10625a7d3cec9f959873
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 22:26:24 +0900

Expand diagnostic logging for summary banners

Diffstat:
MCrossmate/CrossmateApp.swift | 8++++++++
MCrossmate/Persistence/GameStore.swift | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Services/SessionCoordinator.swift | 14++++++++++++--
3 files changed, 108 insertions(+), 3 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -774,6 +774,10 @@ private struct PuzzleDisplayView: View { // whole board (the leave/background path below stamps it). guard let since = services.gameViewedStore.lastViewed(forGame: gameID) else { return [:] } + services.syncMonitor.note( + "recent changes[\(gameID.uuidString.prefix(8))] open diag: " + + store.recentChangesDiagnosticSummary(forGame: gameID, since: since) + ) return store.recentlyChangedCells(forGame: gameID, since: since) }, markPuzzleViewed: { stampPuzzleViewed() } @@ -1058,6 +1062,10 @@ private struct PuzzleDisplayView: View { guard let session, session.mutator.isShared, let since = services.gameViewedStore.lastViewed(forGame: gameID) else { return } + services.syncMonitor.note( + "recent changes[\(gameID.uuidString.prefix(8))] recapture diag: " + + store.recentChangesDiagnosticSummary(forGame: gameID, since: since) + ) session.recentChanges = store.recentlyChangedCells(forGame: gameID, since: since) } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -523,6 +523,7 @@ final class GameStore { /// argument: builds never overlap, so the upsert-by-position in /// `updatePeerChangeLedger` can't duplicate a row. private var ledgerRequests: AsyncStream<Set<UUID>>.Continuation? + private var peerChangeLedgerBuildSerial = 0 /// Fire-and-forget request to refresh the peer-change ledger for `gameIDs`. /// Returns immediately — the inbound-moves hot path must never wait on this @@ -530,6 +531,9 @@ final class GameStore { /// single consumer applies it when it gets there. func enqueuePeerChangeLedgerUpdate(for gameIDs: Set<UUID>) { guard !gameIDs.isEmpty else { return } + eventLog?.note( + "peer ledger enqueue: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]" + ) if ledgerRequests == nil { let (stream, continuation) = AsyncStream<Set<UUID>>.makeStream() ledgerRequests = continuation @@ -566,14 +570,24 @@ final class GameStore { /// and `await` it for determinism. func updatePeerChangeLedger(for gameIDs: Set<UUID>) async { guard !gameIDs.isEmpty else { return } + peerChangeLedgerBuildSerial += 1 + let serial = peerChangeLedgerBuildSerial + let startedAt = Date() + eventLog?.note( + "peer ledger build #\(serial) start: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]" + ) let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + var diagnostics: [String] = [] await ctx.perform { for gameID in gameIDs { let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) gameReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gameReq).first else { continue } + guard let game = try? ctx.fetch(gameReq).first else { + diagnostics.append("\(gameID.uuidString.prefix(8)) missing-game") + continue + } let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) @@ -586,6 +600,9 @@ final class GameStore { // late peer move can still arrive for a finished game. if game.completedAt != nil { for row in existingRows { ctx.delete(row) } + diagnostics.append( + "\(gameID.uuidString.prefix(8)) completed existing=\(existingRows.count) deleted" + ) continue } @@ -606,6 +623,12 @@ final class GameStore { recorded: recorded, seeding: recorded.isEmpty ) + diagnostics.append( + "\(gameID.uuidString.prefix(8)) existing=\(existingRows.count) " + + "moves=\(values.count) current=\(current.count) " + + "seeding=\(recorded.isEmpty) upserts=\(upserts.count) " + + Self.peerChangeSampleSummary(upserts) + ) for change in upserts { let row = rowByPosition[change.position] ?? PeerChangeEntity(context: ctx) row.gameID = gameID @@ -627,6 +650,11 @@ final class GameStore { } } } + let elapsed = Date().timeIntervalSince(startedAt) + eventLog?.note( + "peer ledger build #\(serial) end: elapsed=\(String(format: "%.3f", elapsed))s " + + diagnostics.joined(separator: " | ") + ) } /// Updates `latestOtherMoveAt` for each game whose Moves record was just @@ -1938,6 +1966,37 @@ final class GameStore { return RecentChanges.changes(in: entries, since: since, excludingAuthor: localAuthorID) } + /// Diagnostic snapshot for the catch-up banner and border-highlight reads. + /// This is intentionally read-only: it reports the ledger as it stands at + /// the instant the UI asks for recent changes, so a stale/asynchronous build + /// can be distinguished from a bad reduction. + func recentChangesDiagnosticSummary(forGame gameID: UUID, since: Date) -> String { + let localAuthorID = authorIDProvider() + let allReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") + allReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + let allRows = (try? context.fetch(allReq)) ?? [] + + let newerRows = allRows.filter { ($0.changedAt ?? .distantPast) > since } + let entries = newerRows.map { Self.peerChange(from: $0) } + let changes = localAuthorID.map { + RecentChanges.changes(in: entries, since: since, excludingAuthor: $0) + } ?? .empty + let counted = changes.counts.values.reduce(0) { $0 + $1.added + $1.cleared } + let byAuthor = changes.counts.keys.sorted().map { authorID in + let count = changes.counts[authorID] ?? RecentChanges.Count(added: 0, cleared: 0) + return "\(authorID.prefix(8))=+\(count.added)/-\(count.cleared)" + }.joined(separator: ",") + let newest = allRows.compactMap(\.changedAt).max() + + return "since=\(since.ISO8601Format()) " + + "local=\(Self.shortAuthorID(localAuthorID)) " + + "ledgerRows=\(allRows.count) newer=\(newerRows.count) " + + "counted=\(counted) cells=\(changes.cells.count) " + + "byAuthor=[\(byAuthor)] " + + "newest=\(newest?.ISO8601Format() ?? "nil") " + + Self.peerChangeEntitySampleSummary(newerRows) + } + /// Sender-side measurements describing *why* the pause-push counts for /// `(gameID, authorID)` came out as they did. Mirrors the set the count /// path (`mergedAuthorCells`) iterates, then breaks it down against the @@ -2298,6 +2357,34 @@ final class GameStore { ) } + private nonisolated static func peerChangeSampleSummary(_ changes: [PeerChange]) -> String { + guard !changes.isEmpty else { return "sample=[]" } + let sample = changes + .sorted { lhs, rhs in + if lhs.changedAt != rhs.changedAt { return lhs.changedAt < rhs.changedAt } + if lhs.position.row != rhs.position.row { return lhs.position.row < rhs.position.row } + return lhs.position.col < rhs.position.col + } + .prefix(5) + .map { + "r\($0.position.row)c\($0.position.col):" + + "\($0.letter.isEmpty ? "-" : $0.letter)" + + "@\($0.changedAt.ISO8601Format())" + + "#\(Self.shortAuthorID($0.authorID))" + } + .joined(separator: ",") + return "sample=[\(sample)]" + } + + private nonisolated static func peerChangeEntitySampleSummary(_ rows: [PeerChangeEntity]) -> String { + peerChangeSampleSummary(rows.map { peerChange(from: $0) }) + } + + private nonisolated static func shortAuthorID(_ authorID: String?) -> String { + guard let authorID else { return "nil" } + return String(authorID.prefix(8)) + } + private func inferredObservedCompletionAuthorID(for id: UUID) -> String? { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", id as CVarArg) diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -682,9 +682,19 @@ final class SessionCoordinator { // No baseline means a first-ever open: stay silent rather than flag the // whole grid, exactly as the border highlights do — the two surfaces // read this one cutoff so they always agree. - guard let since = gameViewedStore.lastViewed(forGame: gameID) else { return } + guard let since = gameViewedStore.lastViewed(forGame: gameID) else { + syncMonitor.note("session summary[\(gameID.uuidString.prefix(8))] \(reason): skipped (no baseline)") + return + } + syncMonitor.note( + "session summary[\(gameID.uuidString.prefix(8))] \(reason) diag: " + + store.recentChangesDiagnosticSummary(forGame: gameID, since: since) + ) let summaries = sessionMonitor.summaries(for: gameID, since: since) - guard !summaries.isEmpty else { return } + guard !summaries.isEmpty else { + syncMonitor.note("session summary[\(gameID.uuidString.prefix(8))] \(reason): skipped (no changes)") + return + } let detail = summaries.map { summary -> String in let who = summary.playerName.isEmpty ? String(summary.authorID.prefix(8))