commit e1323759cdb316b03373e25575f359acad478de3
parent 58b19fde1e01df66c63e5347001b24c0e1ca76a0
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 13:56:38 +0900
Route fetched zone deletions through orphan cleanup
This commit makes fetched game-zone deletions use the same orphaning
path as send-side zoneNotFound failures. The previous fetch path deleted
private games or marked shared games revoked directly, so any pending
CKSyncEngine saves and queued ping payloads for that zone could stay
behind after CloudKit had already reported the zone gone.
Now fetched deletions call applyZoneOrphaning, which clears those
pending sends before applying the local delete or access-revoked state
and notifying observers. The orphaning log records whether the missing
zone came from fetch or send so diagnostics keep the two paths distinct.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 47 insertions(+), 35 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1355,10 +1355,10 @@ actor SyncEngine {
// create placeholder GameEntities for newly-joined shares.
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let (removedIDs, revokedIDs, rejoinedIDs, failureMessages):
- ([UUID], [UUID], [UUID], [String]) = ctx.performAndWait {
- var removed: [UUID] = []
- var revoked: [UUID] = []
+ let deletedGameZones = Set(event.deletions.map(\.zoneID).filter {
+ $0.zoneName.hasPrefix("game-")
+ })
+ let (rejoinedIDs, failureMessages): ([UUID], [String]) = ctx.performAndWait {
var rejoined: [UUID] = []
var messages: [String] = []
if !isPrivate {
@@ -1393,21 +1393,6 @@ actor SyncEngine {
}
}
}
- for deletion in event.deletions {
- let zoneName = deletion.zoneID.zoneName
- guard zoneName.hasPrefix("game-") else { continue }
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
- req.fetchLimit = 1
- guard let entity = try? ctx.fetch(req).first else { continue }
- if isPrivate {
- if let id = entity.id { removed.append(id) }
- ctx.delete(entity)
- } else {
- entity.isAccessRevoked = true
- if let id = entity.id { revoked.append(id) }
- }
- }
if ctx.hasChanges {
do {
try ctx.save()
@@ -1422,17 +1407,14 @@ actor SyncEngine {
)
}
}
- return (removed, revoked, rejoined, messages)
+ return (rejoined, messages)
}
for message in failureMessages {
await trace(message)
}
- for id in removedIDs {
- if let cb = onGameRemoved { await cb(id) }
- }
- for id in revokedIDs {
- if let cb = onGameAccessRevoked { await cb(id) }
+ if !deletedGameZones.isEmpty {
+ await applyZoneOrphaning(deletedGameZones, isPrivate: isPrivate, source: "fetch")
}
// enqueueDecisionDeletion defers its CKSyncEngine work via Task — it
// never awaits sync from this delegate callback's context. onGameJoined
@@ -1943,7 +1925,7 @@ actor SyncEngine {
accountAddresses, accountSecrets, decisionWins, recoveredSaves)
}
if !orphanedZones.isEmpty {
- await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
+ await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate, source: "send")
}
// Settle/retry against the engine this sent-event belongs to: account-
// zone decisions ride the private engine, but a name Decision in a
@@ -2017,19 +1999,20 @@ actor SyncEngine {
}
}
- /// Reacts to per-record `.zoneNotFound` failures discovered during a push
- /// by reflecting the missing-zone reality locally. The framework reports
- /// the same failure on every retry without ever clearing the queued change,
- /// so we drop pending sends that target the zone, mirror the fetch-side
- /// deletion handling on the local game (delete on private, mark
- /// access-revoked on shared), and notify upstream observers. Without this,
- /// `Last Error` stays stuck on `Failed to send changes` indefinitely.
+ /// Reflects missing game zones locally after CloudKit reports them gone.
+ /// The same cleanup applies whether the absence arrives as a fetched
+ /// database-zone deletion or as a `.zoneNotFound` send/query failure: drop
+ /// pending sends that target the zone, delete private games or mark shared
+ /// games access-revoked, and notify upstream observers. Without this,
+ /// queued changes can retry forever and `Last Error` stays stuck on
+ /// `Failed to send changes` indefinitely.
/// Internal-rather-than-private so the test suite can drive it directly;
/// `CKSyncEngine.Event` payloads have no public initializer so we cannot
/// exercise `handleSentRecordZoneChanges` end-to-end.
func applyZoneOrphaning(
_ zones: Set<CKRecordZone.ID>,
- isPrivate: Bool
+ isPrivate: Bool,
+ source: String = "sync"
) async {
let engine = isPrivate ? privateEngine : sharedEngine
if let engine {
@@ -2096,7 +2079,7 @@ actor SyncEngine {
await trace(message)
}
await trace(
- "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) on send: " +
+ "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) [src=\(source)]: " +
zones.map(\.zoneName).sorted().joined(separator: ", ")
)
@@ -2329,4 +2312,8 @@ extension SyncEngine: CKSyncEngineDelegate {
guard let engine else { return nil }
return await makeRecordZoneChangeBatch(for: engine)
}
+
+ func pendingPingRecordNamesForTesting() -> [String] {
+ Array(pendingPings.keys).sorted()
+ }
}
diff --git a/Tests/Unit/Sync/ZoneOrphaningTests.swift b/Tests/Unit/Sync/ZoneOrphaningTests.swift
@@ -86,6 +86,31 @@ struct ZoneOrphaningTests {
#expect(try ctx.fetch(req).isEmpty)
}
+ @Test("Private orphaning clears queued ping payloads for the zone")
+ func privateOrphaningClearsQueuedPings() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let (gameID, zoneName) = try makePrivateGame(in: ctx)
+ let engine = await makeEngine(persistence: persistence)
+
+ await engine.enqueuePing(
+ kind: .join,
+ gameID: gameID,
+ authorID: "_localAuthor",
+ playerName: "Local"
+ )
+ let beforeNames = await engine.pendingSaveRecordNames(scope: .private)
+ let pingRecordName = try #require(beforeNames.first { $0.hasPrefix("ping-") })
+ #expect(await engine.pendingPingRecordNamesForTesting() == [pingRecordName])
+
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
+ await engine.applyZoneOrphaning([zoneID], isPrivate: true)
+
+ let afterNames = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(!afterNames.contains(pingRecordName))
+ #expect(await engine.pendingPingRecordNamesForTesting().isEmpty)
+ }
+
@Test("Shared orphaning marks the game access-revoked and clears its pending sends")
func sharedOrphaning() async throws {
let persistence = makeTestPersistence()