crossmate

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

commit d427dee4a032f9341164a5fa714086dda1a4d12b
parent 22d706ba645f55fe74cea0c9ed86863e48bd6267
Author: Michael Camilleri <[email protected]>
Date:   Tue, 21 Jul 2026 10:00:46 +0900

Make invite Ping delivery durable

Inviting a friend could show a checkmark after the share update even
though the Ping was only queued. An atomic Decision conflict could
reject the sibling Ping, and its memory-only payload could not be
reconstructed after relaunch, leaving the invitation stranded without
another user action.

This commit stores pending Ping payloads in a durable Core Data outbox
and reconciles them with CKSyncEngine at startup. Confirmed saves and
already-present records retire the outbox row, while explicit
cancellation or zone orphaning discards it. After a substantive
atomic-batch failure is resolved, only collateral .batchRequestFailed
records that remain reconstructable are re-enqueued.

The invite UI now progresses through Sending, Queued and Sent. A pulsing
clock identifies durable work waiting for CloudKit, and the checkmark is
reserved for CloudKit's acknowledgement that the Ping exists — it does
not claim the recipient has fetched it or received a push. Per-friend
delivery state also prevents late queued or failed updates from
regressing a confirmed send.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 10++++++++++
MCrossmate/Services/AppActions.swift | 15+++++++++++++++
MCrossmate/Services/AppServices.swift | 25+++++++++++++++++++++++++
ACrossmate/Services/InviteDeliveryStore.swift | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/CloudQuery.swift | 2+-
MCrossmate/Sync/FriendController.swift | 6+++---
MCrossmate/Sync/Presence.swift | 4++--
MCrossmate/Sync/SyncEngine.swift | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MCrossmate/Views/Components/FriendAvatarView.swift | 15+++++++++++++--
MCrossmate/Views/Friends/FriendPickerView.swift | 18+++++++++++++++---
MCrossmate/Views/GameList/GameShareItem.swift | 18+++++++++++++++---
MTests/Unit/Sync/PendingChangeReapTests.swift | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
13 files changed, 666 insertions(+), 63 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -246,6 +246,7 @@ F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */; }; FC4853B4261B06945D0D1470 /* PushPayloadCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */; }; + FE447FF4339EA0BF874B1114 /* InviteDeliveryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -316,6 +317,7 @@ 275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSyncVersion.swift; sourceTree = "<group>"; }; 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; + 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteDeliveryStore.swift; sourceTree = "<group>"; }; 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitorTests.swift; sourceTree = "<group>"; }; 298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; }; 2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; @@ -901,6 +903,7 @@ 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */, + 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, @@ -1216,6 +1219,7 @@ F8D37DBE75D7B3F039A8FAC8 /* ImportedBrowseView.swift in Sources */, 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */, + FE447FF4339EA0BF874B1114 /* InviteDeliveryStore.swift in Sources */, 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */, 13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */, 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */, diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -106,6 +106,16 @@ <attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> </entity> + <entity name="PendingPingEntity" representedClassName="PendingPingEntity" syncable="YES" codeGenerationType="class"> + <attribute name="databaseScope" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="payloadData" attributeType="Binary"/> + <attribute name="recordName" attributeType="String"/> + <attribute name="zoneName" attributeType="String"/> + <attribute name="zoneOwnerName" attributeType="String"/> + <fetchIndex name="byRecordName"> + <fetchIndexElement property="recordName" type="Binary" order="ascending"/> + </fetchIndex> + </entity> <entity name="FriendEntity" representedClassName="FriendEntity" syncable="YES" codeGenerationType="class"> <attribute name="authorID" attributeType="String"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift @@ -28,6 +28,21 @@ final class AppActions { try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID) } + func inviteDelivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery { + services.inviteDeliveries.delivery( + gameID: gameID, + friendAuthorID: friendAuthorID + ) + } + + func markInviteFailed(gameID: UUID, friendAuthorID: String) { + services.inviteDeliveries.markFailed( + recordName: nil, + gameID: gameID, + friendAuthorID: friendAuthorID + ) + } + func acceptInvite(shareURL: String, pingRecordName: String) async throws { _ = try await services.invites.acceptInvite( shareURL: shareURL, diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -325,6 +325,7 @@ final class AppServices { let importService: ImportService let engagementHost: EngagementHost let engagementStatus = EngagementStatus() + let inviteDeliveries = InviteDeliveryStore() private(set) lazy var appActions = AppActions(services: self) /// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/ /// lease-expiry timers, inbound channel events); see `EngagementLifecycle`. @@ -1117,6 +1118,30 @@ final class AppServices { await self.invites.presentPings(pings) } + await syncEngine.setOnPingDeliveryUpdate { [weak self] update in + guard let self else { return } + switch update.state { + case .queued: + self.inviteDeliveries.markQueued( + recordName: update.recordName, + gameID: update.gameID, + friendAuthorID: update.addressee + ) + case .sent: + self.inviteDeliveries.markSent( + recordName: update.recordName, + gameID: update.gameID, + friendAuthorID: update.addressee + ) + case .failed: + self.inviteDeliveries.markFailed( + recordName: update.recordName, + gameID: update.gameID, + friendAuthorID: update.addressee + ) + } + } + await syncEngine.setOnAccountChange { [weak self] in guard let self else { return } let previousID = self.identity.currentID diff --git a/Crossmate/Services/InviteDeliveryStore.swift b/Crossmate/Services/InviteDeliveryStore.swift @@ -0,0 +1,74 @@ +import Foundation +import Observation + +enum InviteDeliveryPhase: Equatable { + case idle + case queued + case sent + case failed +} + +/// Per-recipient delivery state observed by the invite UI. Each row gets a +/// stable, narrowly-scoped observable object, so one friend's acknowledgement +/// does not invalidate every other friend row. +@MainActor +@Observable +final class InviteDelivery { + let gameID: UUID + let friendAuthorID: String + private(set) var phase: InviteDeliveryPhase = .idle + + init(gameID: UUID, friendAuthorID: String) { + self.gameID = gameID + self.friendAuthorID = friendAuthorID + } + + fileprivate func setPhase(_ phase: InviteDeliveryPhase) { + self.phase = phase + } +} + +/// Bridges SyncEngine's record-level acknowledgements to stable invite-row +/// models. The record-name index closes the race where CloudKit confirms a +/// fast send before the inviting task itself has returned to the view. +@MainActor +final class InviteDeliveryStore { + private struct Key: Hashable { + let gameID: UUID + let friendAuthorID: String + } + + private var deliveries: [Key: InviteDelivery] = [:] + private var keyByRecordName: [String: Key] = [:] + + func delivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery { + let key = Key(gameID: gameID, friendAuthorID: friendAuthorID) + if let existing = deliveries[key] { return existing } + let delivery = InviteDelivery(gameID: gameID, friendAuthorID: friendAuthorID) + deliveries[key] = delivery + return delivery + } + + func markQueued(recordName: String, gameID: UUID, friendAuthorID: String) { + let key = Key(gameID: gameID, friendAuthorID: friendAuthorID) + keyByRecordName[recordName] = key + let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID) + guard delivery.phase != .sent else { return } + delivery.setPhase(.queued) + } + + func markSent(recordName: String, gameID: UUID, friendAuthorID: String) { + let key = keyByRecordName[recordName] + ?? Key(gameID: gameID, friendAuthorID: friendAuthorID) + keyByRecordName.removeValue(forKey: recordName) + delivery(gameID: key.gameID, friendAuthorID: key.friendAuthorID) + .setPhase(.sent) + } + + func markFailed(recordName: String?, gameID: UUID, friendAuthorID: String) { + if let recordName { keyByRecordName.removeValue(forKey: recordName) } + let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID) + guard delivery.phase != .sent else { return } + delivery.setPhase(.failed) + } +} diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -358,7 +358,7 @@ extension SyncEngine { guard RecordSerializer.isTrustedGameScopedRecord(record), let ping = Ping.parseRecord(record, fetchedFrom: scopeValue), ping.gameID == gameID else { continue } - deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue) + await deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue) await trace( "leave invite cleanup: deleting invite ping \(ping.recordName) " + "for \(gameID.uuidString)" diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -466,7 +466,7 @@ final class FriendController { } FriendZone.markInboxEstablished(pairKey: pairKey) FriendZone.markOutboxAccepted(pairKey: pairKey) - await syncEngine.enqueueFriendZonePing( + try await syncEngine.enqueueFriendZonePing( kind: .friend, gameID: Self.migrationSentinelGameID(pairKey: pairKey), gameTitle: "", @@ -543,7 +543,7 @@ final class FriendController { throw FriendError.payloadEncodingFailed } - await syncEngine.enqueueFriendZonePing( + try await syncEngine.enqueueFriendZonePing( kind: .invite, gameID: gameID, gameTitle: gameTitle, @@ -580,7 +580,7 @@ final class FriendController { guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: inviterAuthorID) - await syncEngine.enqueueFriendZonePing( + try await syncEngine.enqueueFriendZonePing( kind: .decline, gameID: gameID, gameTitle: gameTitle, diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -40,7 +40,7 @@ enum PeerPresence { /// `kind` field. Pings now cover durable bootstrap/side-channel events that /// do not need live APN timing. User-facing play events ride on the push /// worker, and simultaneous co-solving rides on engagement state. -enum PingKind: String, Sendable { +enum PingKind: String, Codable, Sendable { /// Legacy collaborator-joined notification. New clients no longer write /// or alert on this kind; it remains parseable for old records. case join @@ -66,7 +66,7 @@ enum PingKind: String, Sendable { /// zones this user joined. Plumbing between components traffics in this enum; /// `rawValue` converts at the Core Data storage boundary (entity attributes /// and predicates stay `Int16`). -enum DatabaseScope: Int16, Sendable { +enum DatabaseScope: Int16, Codable, Sendable { case `private` = 0 case shared = 1 diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -9,6 +9,19 @@ enum CompletionDurableRecordKind: String, Codable, Hashable, Sendable { case journal } +enum PingDeliveryState: Sendable { + case queued + case sent + case failed +} + +struct PingDeliveryUpdate: Sendable { + let recordName: String + let gameID: UUID + let addressee: String + let state: PingDeliveryState +} + extension EnvironmentValues { @Entry var syncEngine: SyncEngine? = nil } @@ -44,6 +57,10 @@ extension Notification.Name { /// replay them onto the `CellEntity` cache. /// - Notify the main actor so the in-memory `Game` stays current. actor SyncEngine { + enum PingOutboxError: Error { + case syncEngineUnavailable + } + let container: CKContainer let persistence: PersistenceController @@ -71,10 +88,18 @@ actor SyncEngine { /// second call a no-op. private var isStarting = false - /// In-memory map for Ping records pending send. Pings have no Core Data - /// backing — they're write-once-and-forget — so we stash the minimal data - /// here keyed by record name and look it up in `buildRecord`. + /// In-memory mirror of the durable PendingPingEntity outbox, keyed by + /// record name. CKSyncEngine persists the pending record ID but not the + /// body supplied by its record provider, so the outbox is what makes a + /// queued invite reconstructable after process termination. private var pendingPings: [String: PingPayload] = [:] + + private lazy var pendingPingContext: NSManagedObjectContext = { + let ctx = persistence.container.newBackgroundContext() + ctx.automaticallyMergesChangesFromParent = true + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + return ctx + }() /// Payloads for `Decision` records pending send, keyed by /// `decisionStateKey` (zone + record name — the same decision record can /// be pending in several zones at once, e.g. a name Decision fanned out @@ -119,7 +144,7 @@ actor SyncEngine { "\(recordID.zoneID.zoneName)/\(recordID.recordName)" } - struct PingPayload { + struct PingPayload: Codable { let gameID: UUID let authorID: String let deviceID: String @@ -129,6 +154,13 @@ actor SyncEngine { let kind: PingKind let payload: String? let addressee: String? + let zoneName: String + let zoneOwnerName: String + let databaseScope: DatabaseScope + + var recordZoneID: CKRecordZone.ID { + CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName) + } } /// Label for the in-flight fetch — surfaced in traces so the diagnostics @@ -167,6 +199,7 @@ actor SyncEngine { /// token refresh. var onRemoteCredentialsChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)? var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? + private var onPingDeliveryUpdate: (@MainActor @Sendable (PingDeliveryUpdate) -> Void)? private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? @@ -258,6 +291,12 @@ actor SyncEngine { onPings = cb } + func setOnPingDeliveryUpdate( + _ cb: @MainActor @Sendable @escaping (PingDeliveryUpdate) -> Void + ) { + onPingDeliveryUpdate = cb + } + func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) { onAccountChange = cb } @@ -337,8 +376,9 @@ actor SyncEngine { defer { isStarting = false } // CKSyncEngine restores its pending `.saveRecord` changes from the - // serialized state below; restore the matching Decision payloads so a - // pending decision rebuilds with its body instead of as a poison record. + // serialized state below; restore the matching Ping/Decision payloads + // so pending records rebuild with their bodies. + await restorePendingPings() restorePendingDecisionPayloads() restorePendingDecisionVersions() @@ -362,6 +402,12 @@ actor SyncEngine { delegate: self )) + // The outbox is authoritative. A process can terminate after its row + // commits but before CKSyncEngine serializes the matching pending ID, + // so re-add every durable Ping on startup. Record IDs are stable and + // CKSyncEngine deduplicates repeated pending saves. + reconcilePendingPingOutbox() + // CKSyncEngine's automatic subscription creation is unreliable in // practice — diagnostics on real devices showed both scopes with zero // subscriptions even after a healthy initial fetch and push, which @@ -635,11 +681,103 @@ actor SyncEngine { sendChangesDetached(on: privateEngine) } + private func storePendingPing( + _ ping: PingPayload, + recordName: String + ) async throws { + let data = try JSONEncoder().encode(ping) + let ctx = pendingPingContext + try await ctx.perform { + let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity") + req.predicate = NSPredicate(format: "recordName == %@", recordName) + req.fetchLimit = 1 + let entity = try ctx.fetch(req).first ?? PendingPingEntity(context: ctx) + entity.recordName = recordName + entity.zoneName = ping.zoneName + entity.zoneOwnerName = ping.zoneOwnerName + entity.databaseScope = ping.databaseScope.rawValue + entity.payloadData = data + try ctx.save() + } + pendingPings[recordName] = ping + await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping) + } + + private func removePendingPing(recordName: String) async { + pendingPings.removeValue(forKey: recordName) + let ctx = pendingPingContext + await ctx.perform { + let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity") + req.predicate = NSPredicate(format: "recordName == %@", recordName) + for entity in (try? ctx.fetch(req)) ?? [] { + ctx.delete(entity) + } + if ctx.hasChanges { try? ctx.save() } + } + } + + private func confirmPendingPing(recordName: String) async { + if let ping = pendingPings[recordName] { + await publishPingDeliveryUpdate(.sent, recordName: recordName, ping: ping) + } + await removePendingPing(recordName: recordName) + } + + private func restorePendingPings() async { + let ctx = pendingPingContext + pendingPings = await ctx.perform { + let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity") + let rows = (try? ctx.fetch(req)) ?? [] + var restored: [String: PingPayload] = [:] + for row in rows { + guard let name = row.recordName, + let data = row.payloadData, + let ping = try? JSONDecoder().decode(PingPayload.self, from: data) + else { + ctx.delete(row) + continue + } + restored[name] = ping + } + if ctx.hasChanges { try? ctx.save() } + return restored + } + for (recordName, ping) in pendingPings { + await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping) + } + } + + private func publishPingDeliveryUpdate( + _ state: PingDeliveryState, + recordName: String, + ping: PingPayload + ) async { + guard ping.kind == .invite, + let addressee = ping.addressee, + !addressee.isEmpty, + let onPingDeliveryUpdate + else { return } + await onPingDeliveryUpdate(PingDeliveryUpdate( + recordName: recordName, + gameID: ping.gameID, + addressee: addressee, + state: state + )) + } + + private func reconcilePendingPingOutbox() { + for (recordName, ping) in pendingPings { + let engine = ping.databaseScope == .shared ? sharedEngine : privateEngine + let recordID = CKRecord.ID(recordName: recordName, zoneID: ping.recordZoneID) + engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + } + } + /// Registers a Ping record as a pending send. Pings now only cover /// bootstrap kinds (`.join` / `.friend` / `.invite` / `.hail`) — the /// user-facing play events go through the push worker. Sender-only - /// state: the payload is stashed in `pendingPings` and only used to - /// build the outgoing `CKRecord`; nothing is persisted. + /// state: the payload is committed to a durable outbox before the + /// CKSyncEngine save is queued, then retained until CloudKit confirms it. func enqueuePing( kind: PingKind, gameID: UUID, @@ -684,7 +822,7 @@ actor SyncEngine { deviceID: deviceID, eventTimestampMs: eventTimestampMs ) - pendingPings[recordName] = PingPayload( + let ping = PingPayload( gameID: gameID, authorID: authorID, deviceID: deviceID, @@ -693,8 +831,17 @@ actor SyncEngine { eventTimestampMs: eventTimestampMs, kind: kind, payload: payload, - addressee: addressee + addressee: addressee, + zoneName: zoneAndTitle.info.zoneID.zoneName, + zoneOwnerName: zoneAndTitle.info.zoneID.ownerName, + databaseScope: zoneAndTitle.info.scope ) + do { + try await storePendingPing(ping, recordName: recordName) + } catch { + await trace("ping send: failed to persist outbox record \(recordName) — \(error)") + return + } let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) await trace( @@ -931,9 +1078,11 @@ actor SyncEngine { friendZoneID: CKRecordZone.ID, friendZoneScope: DatabaseScope, payload: String? = nil - ) { + ) async throws { let engine = friendZoneScope == .shared ? sharedEngine : privateEngine - guard let engine else { return } + guard let engine else { + throw PingOutboxError.syncEngineUnavailable + } let deviceID = RecordSerializer.localDeviceID let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) let recordName = RecordSerializer.recordName( @@ -942,7 +1091,7 @@ actor SyncEngine { deviceID: deviceID, eventTimestampMs: eventTimestampMs ) - pendingPings[recordName] = PingPayload( + let ping = PingPayload( gameID: gameID, authorID: authorID, deviceID: deviceID, @@ -951,8 +1100,12 @@ actor SyncEngine { eventTimestampMs: eventTimestampMs, kind: kind, payload: payload, - addressee: addressee + addressee: addressee, + zoneName: friendZoneID.zoneName, + zoneOwnerName: friendZoneID.ownerName, + databaseScope: friendZoneScope ) + try await storePendingPing(ping, recordName: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) sendChangesDetached(on: engine) @@ -1127,12 +1280,15 @@ actor SyncEngine { /// CKSyncEngine before the callback unwinds (same class as the /// friend-invite `fetchChanges` trap). Detaching keeps it off the /// callback's actor; the drain only needs to land eventually. - func deletePing(recordName: String, gameID: UUID) { + func deletePing(recordName: String, gameID: UUID) async { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } let engine = info.scope == .shared ? sharedEngine : privateEngine guard let engine else { return } - pendingPings.removeValue(forKey: recordName) + if let ping = pendingPings[recordName] { + await publishPingDeliveryUpdate(.failed, recordName: recordName, ping: ping) + } + await removePendingPing(recordName: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) sendChangesDetached(on: engine) @@ -1142,10 +1298,13 @@ actor SyncEngine { /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity /// may not exist before acceptance, so the caller supplies the friend-zone /// route directly. - func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) { + func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) async { let engine = databaseScope == .shared ? sharedEngine : privateEngine guard let engine else { return } - pendingPings.removeValue(forKey: recordName) + if let ping = pendingPings[recordName] { + await publishPingDeliveryUpdate(.failed, recordName: recordName, ping: ping) + } + await removePendingPing(recordName: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) sendChangesDetached(on: engine) @@ -1327,7 +1486,7 @@ actor SyncEngine { stateSerialization: nil, delegate: self )) - pendingPings = [:] + reconcilePendingPingOutbox() pendingDecisionPayloads = [:] persistPendingDecisionPayloads() pendingDecisionVersions = [:] @@ -1900,7 +2059,7 @@ actor SyncEngine { for record in event.savedRecords { let name = record.recordID.recordName if name.hasPrefix("ping-") { - pendingPings.removeValue(forKey: name) + await confirmPendingPing(recordName: name) } else if name.hasPrefix("decision-") { let stateKey = Self.decisionStateKey(record.recordID) pendingDecisionPayloads.removeValue(forKey: stateKey) @@ -1915,15 +2074,17 @@ actor SyncEngine { let pendingVersionsSnapshot = pendingDecisionVersions let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let (failureMessages, orphanedZones, resolvedDecisions, settledJournals, - resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves): - ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, + let (failureMessages, orphanedZones, resolvedDecisions, settledPings, settledJournals, + resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves, + batchFailedSaves, unresolvedFailureZones): + ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, [String], [(secret: String, version: Int64)], [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)], - [CKRecord.ID]) = await ctx.perform { + [CKRecord.ID], [CKRecord.ID], Set<CKRecordZone.ID>) = await ctx.perform { var messages: [String] = [] var orphaned = Set<CKRecordZone.ID>() var settledDecisions = Set<CKRecord.ID>() + var settledPings = Set<CKRecord.ID>() var settledJournals = Set<CKRecord.ID>() var accountAddresses: [String] = [] var accountSecrets: [(secret: String, version: Int64)] = [] @@ -1940,6 +2101,13 @@ actor SyncEngine { // metadata, completion) can strand on an idle game until its next // change. var recoveredSaves: [CKRecord.ID] = [] + // Atomic operations report the causal record's substantive error + // and `.batchRequestFailed` for otherwise-valid siblings. The + // latter have already fallen out of CKSyncEngine's pending state; + // retain them for an explicit retry after every causal record in + // their zone has been settled or recovered. + var batchFailedSaves: [CKRecord.ID] = [] + var unresolvedFailureZones = Set<CKRecordZone.ID>() for record in event.savedRecords { self.writeBackSystemFields(record: record, in: ctx) let savedName = record.recordID.recordName @@ -1962,11 +2130,25 @@ actor SyncEngine { // error and is pure noise next to the "settled" message. var settled = false if err.domain == CKErrorDomain, + err.code == CKError.batchRequestFailed.rawValue { + batchFailedSaves.append(failure.record.recordID) + settled = true + } else if err.domain == CKErrorDomain, err.code == CKError.zoneNotFound.rawValue { orphaned.insert(failure.record.recordID.zoneID) } else if !isPrivate, self.isInvalidSharedZoneOwnerError(err) { orphaned.insert(failure.record.recordID.zoneID) + } else if name.hasPrefix("ping-"), + err.domain == CKErrorDomain, + err.code == CKError.serverRecordChanged.rawValue { + // Pings are immutable and their retry keeps the original + // record ID. A conflict therefore means the outbox item is + // already durable (for example, the app terminated after + // CloudKit saved it but before processing the sent event). + settledPings.insert(failure.record.recordID) + settled = true + messages.append("send: ping \(name) already present — settled") } else if name.hasPrefix("decision-"), err.domain == CKErrorDomain, err.code == CKError.serverRecordChanged.rawValue { @@ -2032,11 +2214,13 @@ actor SyncEngine { messages.append("send: journal \(name) already present — settled") } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) { recoveredSaves.append(failure.record.recordID) + settled = true messages.append( "send: recovered stale system fields for \(name) from CloudKit server record" ) } guard !settled else { continue } + unresolvedFailureZones.insert(failure.record.recordID.zoneID) let userInfo = err.userInfo .map { "\($0.key)=\($0.value)" } .joined(separator: " | ") @@ -2054,8 +2238,9 @@ actor SyncEngine { ) } } - return (messages, orphaned, settledDecisions, settledJournals, - accountAddresses, accountSecrets, decisionWins, recoveredSaves) + return (messages, orphaned, settledDecisions, settledPings, settledJournals, + accountAddresses, accountSecrets, decisionWins, recoveredSaves, + batchFailedSaves, unresolvedFailureZones) } if !orphanedZones.isEmpty { await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate, source: "send") @@ -2065,17 +2250,7 @@ actor SyncEngine { // joined friend zone rides the shared one. let decisionEngine = isPrivate ? privateEngine : sharedEngine if !resolvedDecisions.isEmpty, let decisionEngine { - decisionEngine.state.remove( - pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) } - ) - for recordID in resolvedDecisions { - let stateKey = Self.decisionStateKey(recordID) - pendingDecisionPayloads.removeValue(forKey: stateKey) - pendingDecisionVersions.removeValue(forKey: stateKey) - decisionSystemFields.removeValue(forKey: stateKey) - } - persistPendingDecisionPayloads() - persistPendingDecisionVersions() + settleDecisionRecords(resolvedDecisions, on: decisionEngine) } // Adopt the server's tag for each versioned decision we're overwriting, // re-enqueue the save (the failed change is no longer pending), then @@ -2119,6 +2294,22 @@ actor SyncEngine { pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) } ) } + if !settledPings.isEmpty { + let engine = isPrivate ? privateEngine : sharedEngine + engine?.state.remove( + pendingRecordZoneChanges: settledPings.map { .saveRecord($0) } + ) + for recordID in settledPings { + await confirmPendingPing(recordName: recordID.recordName) + } + } + let retryableBatchSiblings = batchFailedSaves.filter { + !orphanedZones.contains($0.zoneID) + && !unresolvedFailureZones.contains($0.zoneID) + } + if !retryableBatchSiblings.isEmpty { + await recoverBatchFailedSaves(retryableBatchSiblings, isPrivate: isPrivate) + } for message in failureMessages { await trace(message) } @@ -2127,6 +2318,86 @@ actor SyncEngine { } } + private func settleDecisionRecords( + _ recordIDs: Set<CKRecord.ID>, + on engine: CKSyncEngine + ) { + engine.state.remove( + pendingRecordZoneChanges: recordIDs.map { .saveRecord($0) } + ) + for recordID in recordIDs { + let stateKey = Self.decisionStateKey(recordID) + pendingDecisionPayloads.removeValue(forKey: stateKey) + pendingDecisionVersions.removeValue(forKey: stateKey) + decisionSystemFields.removeValue(forKey: stateKey) + } + persistPendingDecisionPayloads() + persistPendingDecisionVersions() + } + + /// Re-adds only atomic-failure siblings whose records can still be built. + /// The caller invokes this after resolving all substantive failures in the + /// zone, preventing a blind retry of the original atomic batch. + private func recoverBatchFailedSaves( + _ recordIDs: [CKRecord.ID], + isPrivate: Bool + ) async { + guard let engine = isPrivate ? privateEngine : sharedEngine else { return } + let scope = DatabaseScope(isPrivate: isPrivate) + let pingSnapshot = pendingPings + let decisionSnapshot = pendingDecisionPayloads + let decisionVersionSnapshot = pendingDecisionVersions + let decisionSystemFieldsSnapshot = decisionSystemFields + let ctx = persistence.container.newBackgroundContext() + var recovered: [CKRecord.ID] = [] + for recordID in recordIDs { + guard buildRecord( + for: recordID, + in: ctx, + databaseScope: scope, + pings: pingSnapshot, + decisions: decisionSnapshot, + decisionVersions: decisionVersionSnapshot, + decisionSystemFields: decisionSystemFieldsSnapshot + ) != nil else { + await trace( + "send: atomic sibling \(recordID.recordName) is no longer reconstructable — dropped" + ) + continue + } + recovered.append(recordID) + } + guard !recovered.isEmpty else { return } + engine.state.add( + pendingRecordZoneChanges: recovered.map { .saveRecord($0) } + ) + await trace( + "send: re-enqueued \(recovered.count) reconstructable atomic-failure sibling" + + (recovered.count == 1 ? "" : "s") + ) + sendChangesDetached(on: engine) + } + + /// Test seam for the atomic-failure recovery sequence. CKSyncEngine's sent + /// event has no public initializer, so tests supply the causal Decision and + /// collateral record IDs directly after arranging their pending payloads. + func recoverAtomicBatchForTesting( + settledDecisionID: CKRecord.ID, + batchFailedRecordIDs: [CKRecord.ID], + scope: CKDatabase.Scope + ) async { + guard let engine = scope == .shared ? sharedEngine : privateEngine else { return } + let allIDs = [settledDecisionID] + batchFailedRecordIDs + engine.state.remove( + pendingRecordZoneChanges: allIDs.map { .saveRecord($0) } + ) + settleDecisionRecords([settledDecisionID], on: engine) + await recoverBatchFailedSaves( + batchFailedRecordIDs, + isPrivate: scope != .shared + ) + } + nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" } return values.contains { @@ -2167,11 +2438,14 @@ actor SyncEngine { } } - 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 orphanedPingNames = pendingPings.compactMap { name, ping in + zones.contains(ping.recordZoneID) ? name : nil + } + for name in orphanedPingNames { + if let ping = pendingPings[name] { + await publishPingDeliveryUpdate(.failed, recordName: name, ping: ping) } + await removePendingPing(recordName: name) } let ctx = persistence.container.newBackgroundContext() @@ -2379,10 +2653,9 @@ extension SyncEngine: CKSyncEngineDelegate { /// 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 + /// that means its durable outbox row was missing or unreadable; 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 @@ -2483,4 +2756,8 @@ extension SyncEngine: CKSyncEngineDelegate { func pendingPingRecordNamesForTesting() -> [String] { Array(pendingPings.keys).sorted() } + + func confirmPendingPingForTesting(recordName: String) async { + await confirmPendingPing(recordName: recordName) + } } diff --git a/Crossmate/Views/Components/FriendAvatarView.swift b/Crossmate/Views/Components/FriendAvatarView.swift @@ -8,7 +8,11 @@ struct FriendAvatarView: View { /// Invite is in flight: the glyph spins over to a paper plane, then /// rings broadcast outward from the avatar. case sending - /// Invite delivered: the glyph spins once and becomes a checkmark. + /// Invite is durable locally and waiting for CloudKit acknowledgement. + /// The clock keeps the broadcast rings active while delivery is queued. + case queued + /// CloudKit confirmed the Ping exists: the glyph spins once and becomes + /// a checkmark. This does not claim the recipient has fetched it. case sent } @@ -35,6 +39,8 @@ struct FriendAvatarView: View { switch invitePhase { case .sent: return "checkmark" + case .queued: + return "clock.fill" case .sending: return "paperplane.fill" case nil: @@ -86,7 +92,9 @@ struct FriendAvatarView: View { } .frame(width: size, height: size) .onAppear { - if invitePhase == .sending { ringsActive = true } + if invitePhase == .sending || invitePhase == .queued { + ringsActive = true + } } .onChange(of: invitePhase) { _, phase in ringsTask?.cancel() @@ -100,6 +108,9 @@ struct FriendAvatarView: View { guard !Task.isCancelled else { return } ringsActive = true } + case .queued: + ringsActive = true + withAnimation(glyphSpin) { revolutions += 1 } case .sent: ringsActive = false withAnimation(glyphSpin) { revolutions += 1 } diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -97,10 +97,21 @@ struct FriendPickerView: View { .disabled(authorID.isEmpty || invitingAuthorID != nil || invited || (isInviteLimitReached && !invited)) } - /// Maps the row's invite state to the avatar's animation phase. Sending - /// is checked first so the glyph keeps wiggling right up until the work - /// finishes, then resolves straight into the spin-to-checkmark. + /// Maps durable delivery ahead of the task-local sending state so the UI + /// can show a queued clock as soon as the outbox commits, then reserve the + /// checkmark for CloudKit's acknowledgement. private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { + if let delivery = appActions?.inviteDelivery( + gameID: gameID, + friendAuthorID: authorID + ) { + switch delivery.phase { + case .queued: return .queued + case .sent: return .sent + case .failed: return nil + case .idle: break + } + } if invitingAuthorID == authorID { return .sending } if invited { return .sent } return nil @@ -118,6 +129,7 @@ struct FriendPickerView: View { isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 } } catch { + appActions.markInviteFailed(gameID: gameID, friendAuthorID: authorID) if case ShareController.ShareError.collaborationLimitReached = error { withAnimation(.snappy) { isInviteLimitReached = true } } diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -318,10 +318,21 @@ struct GameShareSheet: View { .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || isLoadingExistingLink || (isInviteLimitReached && !wasInvited)) } - /// Maps the row's invite state to the avatar's animation phase. Sending - /// is checked first so the glyph keeps wiggling right up until the work - /// finishes, then resolves straight into the spin-to-checkmark. + /// Maps durable delivery ahead of the task-local sending state so the UI + /// can show a queued clock as soon as the outbox commits, then reserve the + /// checkmark for CloudKit's acknowledgement. private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { + if let delivery = appActions?.inviteDelivery( + gameID: gameID, + friendAuthorID: authorID + ) { + switch delivery.phase { + case .queued: return .queued + case .sent: return .sent + case .failed: return nil + case .idle: break + } + } if invitingAuthorID == authorID { return .sending } if invited { return .sent } return nil @@ -340,6 +351,7 @@ struct GameShareSheet: View { isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 } } catch { + appActions.markInviteFailed(gameID: gameID, friendAuthorID: authorID) if case ShareController.ShareError.collaborationLimitReached = error { withAnimation(.snappy) { isInviteLimitReached = true } } diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -6,9 +6,9 @@ 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 +/// `.saveRecord` whose record can't be reconstructed (a ping whose durable +/// outbox row is missing/unreadable, 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). @@ -56,7 +56,7 @@ struct PendingChangeReapTests { // 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. + // for a ping whose durable outbox row is missing or unreadable. let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) let entity = try #require(try ctx.fetch(req).first) @@ -92,4 +92,167 @@ struct PendingChangeReapTests { let after = await engine.pendingSaveRecordNames(scope: .private) #expect(after.contains(pingName)) } + + @Test("Atomic Decision conflict re-enqueues its reconstructable Ping sibling") + func atomicDecisionConflictRecoversPing() async throws { + let persistence = makeTestPersistence() + let engine = await makeEngine(persistence: persistence) + let zoneID = CKRecordZone.ID( + zoneName: "friend-atomic-recovery", + ownerName: "_friend" + ) + + let decisionName = RecordSerializer.decisionRecordName( + kind: RecordSerializer.nameDecisionKind, + key: "_localAuthor" + ) + let decisionID = CKRecord.ID(recordName: decisionName, zoneID: zoneID) + #expect(await engine.enqueueFriendDecision( + kind: RecordSerializer.nameDecisionKind, + key: "_localAuthor", + payload: "Local", + version: 1, + friendZoneID: zoneID, + friendZoneScope: .shared + )) + try await engine.enqueueFriendZonePing( + kind: .invite, + gameID: UUID(), + gameTitle: "Atomic", + authorID: "_localAuthor", + playerName: "Local", + addressee: "_friend", + friendZoneID: zoneID, + friendZoneScope: .shared, + payload: #"{"gameShareURL":"https://example.com/share"}"# + ) + + let pingName = try #require( + await engine.pendingPingRecordNamesForTesting().first + ) + let pingID = CKRecord.ID(recordName: pingName, zoneID: zoneID) + + // A sent atomic batch falls out of CKSyncEngine's pending state. The + // Decision's serverRecordChanged error is causal and settles because + // the write-once record already exists; the Ping receives only + // batchRequestFailed and must be added back explicitly afterward. + await engine.recoverAtomicBatchForTesting( + settledDecisionID: decisionID, + batchFailedRecordIDs: [pingID], + scope: .shared + ) + + let pending = await engine.pendingSaveRecordNames(scope: .shared) + #expect(!pending.contains(decisionName)) + #expect(pending.contains(pingName)) + #expect(await engine.pendingPingRecordNamesForTesting() == [pingName]) + + // The retry remains materializable rather than being reaped by the + // next record-provider pass. + _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .shared) + #expect(await engine.pendingSaveRecordNames(scope: .shared).contains(pingName)) + } + + @Test("Pending Ping outbox survives SyncEngine restart") + func pendingPingOutboxSurvivesRestart() async throws { + let persistence = makeTestPersistence() + let firstEngine = await makeEngine(persistence: persistence) + let zoneID = CKRecordZone.ID( + zoneName: "friend-durable-outbox", + ownerName: "_friend" + ) + try await firstEngine.enqueueFriendZonePing( + kind: .invite, + gameID: UUID(), + gameTitle: "Durable", + authorID: "_localAuthor", + playerName: "Local", + addressee: "_friend", + friendZoneID: zoneID, + friendZoneScope: .shared, + payload: #"{"gameShareURL":"https://example.com/share"}"# + ) + let recordName = try #require( + await firstEngine.pendingPingRecordNamesForTesting().first + ) + + // A new engine mirrors an app relaunch. Its CKSyncEngine state may or + // may not have serialized the enqueue before termination, so startup + // restores the outbox row and independently re-adds the stable ID. + let relaunchedEngine = await makeEngine(persistence: persistence) + #expect(await relaunchedEngine.pendingPingRecordNamesForTesting() == [recordName]) + #expect( + await relaunchedEngine.pendingSaveRecordNames(scope: .shared) + .contains(recordName) + ) + } + + @Test("Invite delivery advances from queued to CloudKit-confirmed sent") + func inviteDeliveryPhases() async throws { + let persistence = makeTestPersistence() + let engine = await makeEngine(persistence: persistence) + let deliveries = InviteDeliveryStore() + let gameID = UUID() + let friendAuthorID = "_friend" + let delivery = deliveries.delivery( + gameID: gameID, + friendAuthorID: friendAuthorID + ) + #expect(delivery.phase == .idle) + + await engine.setOnPingDeliveryUpdate { update in + switch update.state { + case .queued: + deliveries.markQueued( + recordName: update.recordName, + gameID: update.gameID, + friendAuthorID: update.addressee + ) + case .sent: + deliveries.markSent( + recordName: update.recordName, + gameID: update.gameID, + friendAuthorID: update.addressee + ) + case .failed: + deliveries.markFailed( + recordName: update.recordName, + gameID: update.gameID, + friendAuthorID: update.addressee + ) + } + } + + let zoneID = CKRecordZone.ID( + zoneName: "friend-delivery-phases", + ownerName: friendAuthorID + ) + try await engine.enqueueFriendZonePing( + kind: .invite, + gameID: gameID, + gameTitle: "Phases", + authorID: "_localAuthor", + playerName: "Local", + addressee: friendAuthorID, + friendZoneID: zoneID, + friendZoneScope: .shared, + payload: #"{"gameShareURL":"https://example.com/share"}"# + ) + let recordName = try #require( + await engine.pendingPingRecordNamesForTesting().first + ) + #expect(delivery.phase == .queued) + + await engine.confirmPendingPingForTesting(recordName: recordName) + #expect(delivery.phase == .sent) + #expect(await engine.pendingPingRecordNamesForTesting().isEmpty) + + // A late/replayed queued update cannot regress a confirmed send. + deliveries.markQueued( + recordName: recordName, + gameID: gameID, + friendAuthorID: friendAuthorID + ) + #expect(delivery.phase == .sent) + } }