commit 89bdb89447837912e1f48d8633f2977f7a4e088d
parent b099dd7c8eda40e10d1f96c9624dbc72eb2e4d4d
Author: Michael Camilleri <[email protected]>
Date: Fri, 8 May 2026 07:40:02 +0900
Stop CKSyncEngine from looping on ZoneNotFound
Prior to this commit, it is possible for the shared-DB engine to repeatedly
re-push player-...-_localAuthor with CKError zoneNotFound (26/2036), keeping
'Last Error: Failed to send changes' stuck on the diagnostics screen across
different cycles. Because handleSentRecordZoneChanges only logged failed saves
and tried recoverServerChangedSave, the pending change stayed in CKSyncEngine's
queue and is retried on every send.
This commit teaches handleSentRecordZoneChanges to recognise per-record
.zoneNotFound failures and route them to a new applyZoneOrphaning helper:
1. Pending record-zone changes (saveRecord and deleteRecord) targeting the
orphaned zone are removed from the engine's state so the framework stops
retrying them.
2. In-memory pendingPings entries for the affected game are dropped so they do
not get rebuilt against the dead zone.
3. The local GameEntity is hard-deleted on the private engine and marked
isAccessRevoked on the shared engine, mirroring the split landed for
fetch-side zone deletions in 04bfdfd.
4. onGameRemoved / onGameAccessRevoked callbacks fire so GameStore can clear the
open puzzle if it was the one that just disappeared. The revoked callback is
suppressed when the entity was already revoked, to avoid double-firing during
steady-state pushes.
ZoneOrphaningTests covers the private hard-delete, shared revocation,
already-revoked idempotency, and unknown-zone no-op paths. applyZoneOrphaning
is internal rather than private because CKSyncEngine.Event payloads have no
public initialiser, so the test cannot drive handleSentRecordZoneChanges
end-to-end.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
3 files changed, 260 insertions(+), 4 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -53,6 +53,7 @@
849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; };
8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
+ 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; };
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; };
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
@@ -160,6 +161,7 @@
9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; };
9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.swift; sourceTree = "<group>"; };
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; };
+ A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; };
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisherTests.swift; sourceTree = "<group>"; };
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
@@ -359,6 +361,7 @@
94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */,
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */,
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */,
+ A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */,
);
path = Sync;
sourceTree = "<group>";
@@ -512,6 +515,7 @@
6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */,
+ 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1158,9 +1158,11 @@ actor SyncEngine {
}
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let (savedSnapshotNames, failureMessages): ([String], [String]) = ctx.performAndWait {
+ let (savedSnapshotNames, failureMessages, orphanedZones):
+ ([String], [String], Set<CKRecordZone.ID>) = ctx.performAndWait {
var snapshotNames: [String] = []
var messages: [String] = []
+ var orphaned = Set<CKRecordZone.ID>()
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
let name = record.recordID.recordName
@@ -1170,12 +1172,15 @@ actor SyncEngine {
}
for failure in event.failedRecordSaves {
let name = failure.record.recordID.recordName
- if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
+ let err = failure.error as NSError
+ if err.domain == CKErrorDomain,
+ err.code == CKError.zoneNotFound.rawValue {
+ orphaned.insert(failure.record.recordID.zoneID)
+ } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
messages.append(
"send: recovered stale system fields for \(name) from CloudKit server record"
)
}
- let err = failure.error as NSError
let userInfo = err.userInfo
.map { "\($0.key)=\($0.value)" }
.joined(separator: " | ")
@@ -1193,7 +1198,10 @@ actor SyncEngine {
)
}
}
- return (snapshotNames, messages)
+ return (snapshotNames, messages, orphaned)
+ }
+ if !orphanedZones.isEmpty {
+ await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
}
if !savedSnapshotNames.isEmpty, let onSnapshotsSaved {
await onSnapshotsSaved(savedSnapshotNames)
@@ -1203,6 +1211,93 @@ 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.
+ /// 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
+ ) async {
+ let engine = isPrivate ? privateEngine : sharedEngine
+ if let engine {
+ let toRemove = engine.state.pendingRecordZoneChanges.filter { change in
+ switch change {
+ case .saveRecord(let id):
+ return zones.contains(id.zoneID)
+ case .deleteRecord(let id):
+ return zones.contains(id.zoneID)
+ @unknown default:
+ return false
+ }
+ }
+ if !toRemove.isEmpty {
+ engine.state.remove(pendingRecordZoneChanges: toRemove)
+ }
+ }
+
+ for (name, _) in pendingPings {
+ guard let gameID = gameID(fromRecordName: name) else { continue }
+ if zones.contains(where: { $0.zoneName == "game-\(gameID.uuidString)" }) {
+ pendingPings.removeValue(forKey: name)
+ }
+ }
+
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait {
+ var removed: [UUID] = []
+ var revoked: [UUID] = []
+ for zone in zones {
+ let zoneName = zone.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 {
+ if !entity.isAccessRevoked, let id = entity.id {
+ revoked.append(id)
+ }
+ entity.isAccessRevoked = true
+ }
+ }
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ let nsError = error as NSError
+ print(
+ "SyncEngine: orphan-zone ctx.save failed — domain=\(nsError.domain) " +
+ "code=\(nsError.code) \(nsError.localizedDescription)"
+ )
+ }
+ }
+ return (removed, revoked)
+ }
+
+ await trace(
+ "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) on send: " +
+ zones.map(\.zoneName).sorted().joined(separator: ", ")
+ )
+
+ for id in removedIDs {
+ if let cb = onGameRemoved { await cb(id) }
+ }
+ for id in revokedIDs {
+ if let cb = onGameAccessRevoked { await cb(id) }
+ }
+ }
+
/// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the
/// error payload often includes the current server record. Adopt only that
/// record's system fields so a retry can use the fresh change tag while
diff --git a/Tests/Unit/Sync/ZoneOrphaningTests.swift b/Tests/Unit/Sync/ZoneOrphaningTests.swift
@@ -0,0 +1,157 @@
+import CloudKit
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Pins down the recovery path for per-record `.zoneNotFound` failures
+/// discovered while pushing changes. Without it the engine retried the same
+/// record on every push and `Last Error` stayed stuck on
+/// `Failed to send changes` (see `iphone-reset.log`).
+@Suite("ZoneOrphaning", .serialized)
+@MainActor
+struct ZoneOrphaningTests {
+
+ private func makeEngine(
+ persistence: PersistenceController
+ ) async -> SyncEngine {
+ let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
+ let engine = SyncEngine(container: container, persistence: persistence)
+ await engine.start()
+ return engine
+ }
+
+ private func makePrivateGame(
+ in ctx: NSManagedObjectContext
+ ) throws -> (UUID, String) {
+ let id = UUID()
+ let zoneName = "game-\(id.uuidString)"
+ let entity = GameEntity(context: ctx)
+ entity.id = id
+ entity.title = "Private"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = zoneName
+ entity.ckZoneName = zoneName
+ entity.databaseScope = 0
+ try ctx.save()
+ return (id, zoneName)
+ }
+
+ private func makeSharedGame(
+ in ctx: NSManagedObjectContext
+ ) throws -> (UUID, String, String) {
+ let id = UUID()
+ let zoneName = "game-\(id.uuidString)"
+ let owner = "_someOtherUser"
+ let entity = GameEntity(context: ctx)
+ entity.id = id
+ entity.title = "Shared"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = zoneName
+ entity.ckZoneName = zoneName
+ entity.ckZoneOwnerName = owner
+ entity.databaseScope = 1
+ try ctx.save()
+ return (id, zoneName, owner)
+ }
+
+ @Test("Private orphaning hard-deletes the game and clears its pending sends")
+ func privateOrphaning() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let (gameID, zoneName) = try makePrivateGame(in: ctx)
+ let engine = await makeEngine(persistence: persistence)
+
+ await engine.enqueueGame(ckRecordName: zoneName)
+ let beforeNames = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(beforeNames.contains(zoneName))
+
+ var removed: [UUID] = []
+ await engine.setOnGameRemoved { id in removed.append(id) }
+
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
+ await engine.applyZoneOrphaning([zoneID], isPrivate: true)
+
+ let afterNames = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(!afterNames.contains(zoneName))
+ #expect(removed == [gameID])
+
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ #expect(try ctx.fetch(req).isEmpty)
+ }
+
+ @Test("Shared orphaning marks the game access-revoked and clears its pending sends")
+ func sharedOrphaning() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
+ let engine = await makeEngine(persistence: persistence)
+
+ await engine.enqueuePlayerRecord(gameID: gameID, authorID: "_localAuthor")
+ let beforeNames = await engine.pendingSaveRecordNames(scope: .shared)
+ let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor")
+ #expect(beforeNames.contains(playerRecordName))
+
+ var revoked: [UUID] = []
+ await engine.setOnGameAccessRevoked { id in revoked.append(id) }
+
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
+ await engine.applyZoneOrphaning([zoneID], isPrivate: false)
+
+ let afterNames = await engine.pendingSaveRecordNames(scope: .shared)
+ #expect(!afterNames.contains(playerRecordName))
+ #expect(revoked == [gameID])
+
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ let entity = try #require(try ctx.fetch(req).first)
+ ctx.refresh(entity, mergeChanges: true)
+ #expect(entity.isAccessRevoked == true)
+ }
+
+ @Test("Already-revoked shared game does not re-fire the callback")
+ func sharedOrphaningIsIdempotent() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
+ // Simulate the prior fetch-side path having already marked it revoked.
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ let entity = try #require(try ctx.fetch(req).first)
+ entity.isAccessRevoked = true
+ try ctx.save()
+
+ let engine = await makeEngine(persistence: persistence)
+
+ var revoked: [UUID] = []
+ await engine.setOnGameAccessRevoked { id in revoked.append(id) }
+
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
+ await engine.applyZoneOrphaning([zoneID], isPrivate: false)
+
+ #expect(revoked.isEmpty)
+ }
+
+ @Test("Unknown orphan zones are ignored without error")
+ func unknownZoneIsNoOp() async throws {
+ let persistence = makeTestPersistence()
+ let engine = await makeEngine(persistence: persistence)
+
+ var removed: [UUID] = []
+ await engine.setOnGameRemoved { id in removed.append(id) }
+
+ let zoneID = CKRecordZone.ID(
+ zoneName: "game-\(UUID().uuidString)",
+ ownerName: CKCurrentUserDefaultName
+ )
+ await engine.applyZoneOrphaning([zoneID], isPrivate: true)
+
+ #expect(removed.isEmpty)
+ }
+}