crossmate

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

commit c02a4eca9162c4aabd7fd1f2e11d8019a61c4659
parent 2b1abbf874af95eb1ea2e501178167e0758e5d47
Author: Michael Camilleri <[email protected]>
Date:   Sat, 27 Jun 2026 06:56:15 +0900

Ignore stale PlayerRoster share refreshes

Overlapping PlayerRoster refreshes could let an older refresh resume
after its CKShare fetch and apply post-share roster state after a newer
refresh had already started. That could replay the same presence
transition in the diagnostics log and made the roster trace harder to
trust.

This commit generation-gates the async share half of
PlayerRoster.refresh. The Core Data snapshot still publishes
immediately, but an older refresh no longer applies its post-share
roster, reschedules lease expiry, traces, or updates the cached share
once a newer refresh has taken over.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Models/PlayerRoster.swift | 18++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -141,6 +141,7 @@ final class PlayerRoster { private var cachedShare: CKShare? private var observationTasks: [Task<Void, Never>] = [] private var lastTracedSignature: String? + private var refreshGeneration = 0 /// One-shot recompute scheduled for the soonest peer lease expiry, so a /// departed peer's ghost cursor clears precisely when its lease lapses /// rather than waiting for an unrelated record to nudge a refresh. @@ -234,6 +235,8 @@ final class PlayerRoster { func refresh() async { guard !isStaticPreview else { return } + refreshGeneration += 1 + let generation = refreshGeneration // Without a known local authorID we can't classify any participant as // self vs. remote, so the only safe answer is an empty roster. The // next refresh (after AuthorIdentity populates) will do the real work. @@ -326,8 +329,10 @@ final class PlayerRoster { databaseScope: fetched.databaseScope, ckShareRecordName: fetched.ckShareRecordName, ckZoneName: fetched.ckZoneName, - ckZoneOwnerName: fetched.ckZoneOwnerName + ckZoneOwnerName: fetched.ckZoneOwnerName, + generation: generation ) + guard generation == refreshGeneration else { return } applyRoster(localAuthorID: localAuthorID, fetched: fetched, share: share) scheduleLeaseExpiryRecompute() // Trace only the post-fetch roster. The interim publish above always @@ -501,7 +506,8 @@ final class PlayerRoster { databaseScope: Int16, ckShareRecordName: String?, ckZoneName: String?, - ckZoneOwnerName: String? + ckZoneOwnerName: String?, + generation: Int ) async -> CKShare? { if let cached = cachedShare { return cached } guard let zoneName = ckZoneName else { return nil } @@ -511,12 +517,16 @@ final class PlayerRoster { if databaseScope == 0, let shareRecordName = ckShareRecordName { let shareID = CKRecord.ID(recordName: shareRecordName, zoneID: zoneID) let share = try await container.privateCloudDatabase.record(for: shareID) as? CKShare - cachedShare = share + if generation == refreshGeneration { + cachedShare = share + } return share } else if databaseScope == 1 { let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID) let share = try await container.sharedCloudDatabase.record(for: shareID) as? CKShare - cachedShare = share + if generation == refreshGeneration { + cachedShare = share + } return share } } catch {