commit 3835f0f66a91a515ccf1a10142b7633df4c0fb56
parent 8b863f6f90450e743d3d2798952bdba95893136e
Author: Michael Camilleri <[email protected]>
Date: Tue, 9 Jun 2026 07:49:47 +0900
Converge the catch-up baseline regardless of cursor recency
The per-peer catch-up baseline rides an account's own
Player.sessionSnapshot so sibling devices adopt what the account
actually saw rather than recomputing from their own view. But that field
shipped behind the selection's updatedAt LWW guard in applyPlayerRecord,
and the baseline write did not bump updatedAt. A sibling holding a
fresher local cursor write (a live selection move, a push address sweep)
therefore failed the incomingIsFresher check and would drop the whole
record before reaching the snapshot, so the baseline never converged and
the same moves resurfaced as a duplicate catch-up banner on the next
open.
This commit hoists readAt, sessionSnapshot, and the onReadCursor
callback above the cursor LWW guard, keeping them under the etag
freshness guard. This is account-scoped 'what I've seen' convergence
state, not the live cursor, so it should not be gated on cursor recency.
The etag guard still rejects genuinely stale fetches, and the game-level
read cursor stays monotonic downstream in setReadCursor, so no
entity-level monotonicity is duplicated. name, selection, pushAddress,
and updatedAt stay behind the LWW guard, so a stale record still cannot
clobber a fresher local selection.
setSessionSnapshot deliberately still does not bump updatedAt: doing so
would make the leaving device's stale selection win the LWW and clobber
an active sibling's live cursor, which is the case the guard exists to
prevent.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 106 insertions(+), 11 deletions(-)
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -216,14 +216,33 @@ extension SyncEngine {
let oldUpdatedAt = entity.updatedAt
// Adopt the server's system fields — that's etag tracking and is
- // independent of which side has the freshest data. The value fields,
- // however, are only adopted when the incoming record is at least as
- // new as what we have locally; otherwise a stale-but-current server
- // record (e.g. our own pending writes haven't landed yet) would
- // clobber the user's live selection on every fetch.
+ // independent of which side has the freshest data.
entity.ckRecordName = ckName
entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
entity.authorID = authorID
+
+ // The read cursor and session snapshot are account-scoped convergence
+ // state — "what this account has already seen" — not the live cursor, so
+ // they are adopted ahead of (and independent of) the selection's
+ // `updatedAt` LWW below. A sibling commits the catch-up baseline on leave
+ // (`handlePuzzleLeft`), which advances the read cursor and writes the
+ // snapshot but does *not* bump `updatedAt`; the outbound record therefore
+ // ships a stale `updatedAt`. Gating these on it would let a device with a
+ // fresher local cursor drop the baseline and re-report the same moves as
+ // a duplicate catch-up banner. The etag guard above already rejects
+ // genuinely stale fetches, and the game-level cursor stays monotonic
+ // downstream (`setReadCursor`), so adopting here is safe.
+ let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record)
+ entity.readAt = incomingReadAt
+ entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record)
+ if authorID == localAuthorID, let readAt = incomingReadAt {
+ onReadCursor(gameID, readAt, entity.sessionSnapshot)
+ }
+
+ // The remaining value fields are only adopted when the incoming record
+ // is at least as new as what we have locally; otherwise a stale-but-
+ // current server record (e.g. our own pending writes haven't landed yet)
+ // would clobber the user's live selection on every fetch.
let localUpdatedAt = entity.updatedAt
let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true
guard incomingIsFresher else { return }
@@ -243,17 +262,11 @@ extension SyncEngine {
entity.selCol = nil
entity.selDir = nil
}
- let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record)
- entity.readAt = incomingReadAt
- entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record)
// Adopt the record's push address. For a peer this is how the sender
// learns where to address pushes; for our own record synced from a
// sibling device, it's how the account's devices converge on one
// per-game address (the LWW winner of this record).
entity.pushAddress = RecordSerializer.parsePlayerPushAddress(from: record)
- if authorID == localAuthorID, let readAt = incomingReadAt {
- onReadCursor(gameID, readAt, entity.sessionSnapshot)
- }
let isRemoteAuthor = authorID != localAuthorID && authorID != CKCurrentUserDefaultName
let hasSelection = entity.selRow != nil && entity.selCol != nil && entity.selDir != nil
if isRemoteAuthor,
diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift
@@ -64,6 +64,23 @@ struct PlayerRecordPresenceTests {
try ctx.save()
}
+ private func makeExistingLocalPlayer(
+ in ctx: NSManagedObjectContext,
+ game: GameEntity,
+ updatedAt: Date
+ ) throws {
+ let player = PlayerEntity(context: ctx)
+ player.game = game
+ player.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: localAuthorID
+ )
+ player.authorID = localAuthorID
+ player.name = "Local"
+ player.updatedAt = updatedAt
+ try ctx.save()
+ }
+
private func remotePlayerRecord(
updatedAt: Date,
selection: PlayerSelection?
@@ -157,6 +174,71 @@ struct PlayerRecordPresenceTests {
#expect(row.selDir == nil)
}
+ @Test("Stale-updatedAt local record still converges the catch-up baseline")
+ func staleLocalRecordStillAdoptsSessionSnapshot() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let game = try makeGame(in: ctx)
+ // This device already holds a fresher local cursor write on the
+ // account's own Player row (e.g. a live selection move).
+ try makeExistingLocalPlayer(
+ in: ctx,
+ game: game,
+ updatedAt: Date(timeIntervalSince1970: 20)
+ )
+ let engine = makeEngine(persistence: persistence)
+
+ // A sibling committed the catch-up baseline on leave: it ships the
+ // snapshot + read cursor but with a stale `updatedAt`, because leaving
+ // advances the read cursor and snapshot without bumping `updatedAt`.
+ let snapshot = Data("baseline".utf8)
+ let readAt = Date(timeIntervalSince1970: 15)
+ let record = RecordSerializer.playerRecord(
+ gameID: gameID,
+ authorID: localAuthorID,
+ name: "Local",
+ updatedAt: Date(timeIntervalSince1970: 10),
+ selection: PlayerSelection(row: 5, col: 5, direction: .across),
+ readAt: readAt,
+ sessionSnapshot: snapshot,
+ zone: zoneID,
+ systemFields: nil
+ )
+
+ var readCursors: [(UUID, Date, Data?)] = []
+ engine.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { _ in },
+ onPresenceChange: { _ in },
+ onReadCursor: { readCursors.append(($0, $1, $2)) }
+ )
+
+ // The baseline converges despite the stale `updatedAt`...
+ let fetched = try fetchLocalPlayer(in: ctx)
+ let row = try #require(fetched)
+ #expect(row.sessionSnapshot == snapshot)
+ #expect(readCursors.count == 1)
+ #expect(readCursors.first?.0 == gameID)
+ #expect(readCursors.first?.1 == readAt)
+ #expect(readCursors.first?.2 == snapshot)
+ // ...while the cursor LWW still shields the fresher local selection from
+ // the stale record.
+ #expect(row.selRow == nil)
+ #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
+ }
+
+ private func fetchLocalPlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ RecordSerializer.recordName(forPlayerInGame: gameID, authorID: localAuthorID)
+ )
+ req.fetchLimit = 1
+ return try ctx.fetch(req).first
+ }
+
private func fetchRemotePlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
req.predicate = NSPredicate(