crossmate

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

commit d63d53b9e3d9eae6f6bb86604be2a2d8058d3576
parent 467133e1f5018ebeb561670a3b899f54f2dce13b
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 16:35:44 +0900

Reap pending sends that can no longer be built

Prior to this commit, nextRecordZoneChangeBatch handed every pending
.saveRecord to CKSyncEngine.RecordZoneChangeBatch's record provider, which
returned buildRecord's result verbatim — including nil. CKSyncEngine persists
pendingRecordZoneChanges to disk, but a ping's body lives only in the in-memory
pendingPings dictionary, which is empty after any cold start (and is cleared by
reset()). So a ping enqueued but not sent before the app was suspended or
killed came back as a persisted pending change with no payload: buildRecord
returned nil, the provider returned nil, and when that was the only pending
change the batch initializer returned nil too. A nil batch means 'nothing to
send', not 'discard' — so the change stayed queued forever.

The record provider now removes the pending change when buildRecord can't
reconstruct the record — a lost ping payload, or a deleted game/moves/player
entity — so the queue self-heals on the next send cycle instead of stalling.
This mirrors Apple's CKSyncEngine reference implementation, which reaps
un-buildable changes inside the provider. The batch build moves into an
internal makeRecordZoneChangeBatch(for:) with a scope-routed test seam,
following the applyZoneOrphaning precedent, since the delegate entry point
can't be driven directly: CKSyncEngine.SendChangesContext has no public
initializer.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Sync/SyncEngine.swift | 37+++++++++++++++++++++++++++++++++++--
ATests/Unit/Sync/PendingChangeReapTests.swift | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 135 insertions(+), 2 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; + A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; }; AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; }; AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; @@ -192,6 +193,7 @@ B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; }; + BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; }; BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; }; BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; @@ -392,6 +394,7 @@ B766E872B12DC79ECCD80941 /* FriendModelTests.swift */, 800CCFBE90554F287E765755 /* FriendZoneTests.swift */, EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */, + BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */, A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */, @@ -541,6 +544,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, + A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */, 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -2749,12 +2749,45 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let pending = syncEngine.state.pendingRecordZoneChanges + await makeRecordZoneChangeBatch(for: syncEngine) + } + + /// Builds the next outbound batch for `engine`, reaping any pending + /// `.saveRecord` whose record can no longer be reconstructed. For a ping + /// that means its ephemeral `pendingPings` payload was lost across a + /// relaunch (CKSyncEngine persists pending changes; the ping body is + /// in-memory only); for game/moves/player it means the Core Data entity + /// was deleted. Either way the save can never succeed, so the change is + /// dropped instead of returning a nil batch that leaves it queued forever + /// — `Pending Changes` would never drain and no error is ever surfaced. + /// Mirrors Apple's CKSyncEngine reference implementation, which reaps + /// un-buildable changes inside the record provider. Internal-rather-than- + /// private so the test suite can drive it directly; the delegate entry + /// point can't be exercised because `CKSyncEngine.SendChangesContext` has + /// no public initializer. + func makeRecordZoneChangeBatch( + for engine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let pending = engine.state.pendingRecordZoneChanges guard !pending.isEmpty else { return nil } let pingSnapshot = pendingPings return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in guard let self else { return nil } - return self.buildRecord(for: recordID, pings: pingSnapshot) + if let record = self.buildRecord(for: recordID, pings: pingSnapshot) { + return record + } + engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil } } + + /// Test seam: drives `makeRecordZoneChangeBatch` for the given scope's + /// engine. Mirrors `pendingSaveRecordNames(scope:)`'s scope routing. + func makeRecordZoneChangeBatch( + forTestingScope scope: CKDatabase.Scope + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let engine = scope == .shared ? sharedEngine : privateEngine + guard let engine else { return nil } + return await makeRecordZoneChangeBatch(for: engine) + } } diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -0,0 +1,96 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the reap path in `makeRecordZoneChangeBatch`. A pending +/// `.saveRecord` whose record can't be reconstructed (a ping whose in-memory +/// `pendingPings` payload was lost across a relaunch, or a deleted Core Data +/// entity) must be dropped from the engine's persisted pending changes rather +/// than left queued forever. Without this the device showed a permanent +/// `Pending Changes: 1` that never drained and surfaced no error — every push +/// "succeeded" while silently sending nothing (see the stuck-ping log). +@Suite("PendingChangeReap", .serialized) +@MainActor +struct PendingChangeReapTests { + + private func makeEngine( + persistence: PersistenceController + ) async -> SyncEngine { + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + 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) + } + + @Test("An un-buildable pending save is reaped instead of queued forever") + func unbuildableSaveIsReaped() 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 before = await engine.pendingSaveRecordNames(scope: .private) + #expect(before.contains(zoneName)) + + // Delete the backing entity so `buildRecord` can no longer + // reconstruct the `game-` record — the same dead-end the engine hits + // for a ping whose payload didn't survive a relaunch. + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + let entity = try #require(try ctx.fetch(req).first) + ctx.delete(entity) + try ctx.save() + + _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) + + let after = await engine.pendingSaveRecordNames(scope: .private) + #expect(!after.contains(zoneName)) + } + + @Test("A buildable pending ping is preserved, not reaped") + func buildablePingIsPreserved() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let (gameID, _) = try makePrivateGame(in: ctx) + let engine = await makeEngine(persistence: persistence) + + await engine.enqueuePing( + kind: .win, + scope: nil, + gameID: gameID, + authorID: "_localAuthor", + playerName: "Local" + ) + let before = await engine.pendingSaveRecordNames(scope: .private) + let pingName = try #require(before.first { $0.hasPrefix("ping-") }) + + // The payload is still in `pendingPings`, so the record builds — the + // reap must not fire just because a record was materialized. + _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) + + let after = await engine.pendingSaveRecordNames(scope: .private) + #expect(after.contains(pingName)) + } +}