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