crossmate

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

commit 31edc41ec90f4a21cf3171c787db4d8dccc013b8
parent 89e202971943b36aa1942ccd0689e6ffe661501d
Author: Michael Camilleri <[email protected]>
Date:   Fri, 24 Jul 2026 00:31:53 +0900

Time out the invite confirmation wait without discarding the Ping

This commit bounds the friend-invite send that waits for CloudKit to
confirm its Ping. That wait previously had no deadline, so a slow or
offline network left the invite picker on a spinner indefinitely — with
every other friend row disabled — even though the invite was already
durably queued.

Now the wait gives up after 30 seconds and reports deliveryPending. The
timeout is deliberately non-destructive: the Ping stays in the durable
outbox, its CKShare seat is left intact, retries continue, and the true
outcome still resolves later through the usual sent or failed delivery
update. The invite picker and Game List treat a timed-out invite as
queued rather than failed, so the row settles on 'Invited' instead of
surfacing an error.

InviteCoordinator's inline seat rollback now skips deliveryPending
alongside the existing deliveryFailed case, so a timeout can never
remove a seat whose invitation is still on its way to delivery.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 9+++++++++
MCrossmate/Services/InviteCoordinator.swift | 18+++++++++++-------
MCrossmate/Sync/SyncEngine.swift | 44++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Friends/FriendPickerView.swift | 9+++++++++
MCrossmate/Views/GameList/GameListView.swift | 8++++++++
MCrossmate/Views/GameList/GameShareItem.swift | 9+++++++++
MTests/Unit/Sync/PendingChangeReapTests.swift | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 161 insertions(+), 7 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1151,6 +1151,15 @@ final class AppServices { friendAuthorID: update.addressee, failure: failure ) + // An open invite sheet shows failures inline, so suppress the + // global banner while one is presented. Known edge case: if a + // confirmed-send wait first times out (`.deliveryPending`, which + // clears the sheet's local failure state) and CloudKit *then* + // rejects the Ping while the sheet is still open, the row + // re-enables via its `.failed` phase but neither this banner nor + // the inline callout reappears — the user must retry. Narrow + // window; closing it means having the callout observe the + // delivery store per-friend instead of local @State. if !self.inviteDeliveries.hasActivePresentation(for: update.gameID) { self.announcements.post(Announcement( id: InviteDeliveryStore.failureAnnouncementID( diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -132,16 +132,20 @@ final class InviteCoordinator { rollbackParticipantOnFailure: invitationShare.participantWasAdded ) } catch { - let pingDeliveryFailed: Bool - if let pingError = error as? SyncEngine.PingOutboxError, - case .deliveryFailed = pingError { - pingDeliveryFailed = true - } else { - pingDeliveryFailed = false + // Skip the inline seat rollback when the Ping is still in play: a + // terminal `.deliveryFailed` rolls back later via the async + // `.failed` delivery update, and a `.deliveryPending` timeout must + // not disturb a seat whose invite is still queued to deliver. + let pingStillOwnsRollback: Bool + switch error as? SyncEngine.PingOutboxError { + case .deliveryFailed, .deliveryPending: + pingStillOwnsRollback = true + case .syncEngineUnavailable, nil: + pingStillOwnsRollback = false } if invitationShare.participantWasAdded, !(error is CancellationError), - !pingDeliveryFailed { + !pingStillOwnsRollback { try? await shareController.removeFriendParticipant( fromGameID: gameID, userRecordName: friendAuthorID diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -93,6 +93,10 @@ actor SyncEngine { enum PingOutboxError: Error, Equatable, LocalizedError { case syncEngineUnavailable case deliveryFailed(code: Int?) + /// CloudKit did not confirm or reject the send within the caller's + /// patience window. The Ping stays durably queued and its CKShare seat + /// intact; this only unblocks the caller so the UI stops waiting. + case deliveryPending var errorDescription: String? { switch self { @@ -103,6 +107,8 @@ actor SyncEngine { return String(localized: InviteDeliveryFailure.quotaExceeded.body) case .deliveryFailed: return String(localized: InviteDeliveryFailure.other.body) + case .deliveryPending: + return "The invitation is queued and will keep trying." } } @@ -149,8 +155,15 @@ actor SyncEngine { private var pingDeliveryWaiters: [ String: CheckedContinuation<Void, Error> ] = [:] + private var pingDeliveryTimeouts: [String: Task<Void, Never>] = [:] private var scheduledPingRetries: Set<String> = [] + /// How long a `waitForServerConfirmation` send blocks before it stops + /// waiting and reports `.deliveryPending`. This is a UI-patience limit, not + /// a delivery deadline: the Ping remains queued and the durable outbox keeps + /// retrying, so a slow network no longer strands the caller on a spinner. + private var pingConfirmationTimeout: Duration = .seconds(30) + private lazy var pendingPingContext: NSManagedObjectContext = { let ctx = persistence.container.newBackgroundContext() ctx.automaticallyMergesChangesFromParent = true @@ -810,6 +823,7 @@ actor SyncEngine { await publishPingDeliveryUpdate(.sent, recordName: recordName, ping: ping) } await removePendingPing(recordName: recordName) + clearPingDeliveryTimeout(recordName: recordName) pingDeliveryWaiters.removeValue(forKey: recordName)?.resume() } @@ -826,6 +840,7 @@ actor SyncEngine { ) } await removePendingPing(recordName: recordName) + clearPingDeliveryTimeout(recordName: recordName) pingDeliveryWaiters.removeValue(forKey: recordName)? .resume(throwing: PingOutboxError.deliveryFailed(code: error?.code)) } @@ -834,6 +849,7 @@ actor SyncEngine { try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in pingDeliveryWaiters[recordName] = continuation + armPingDeliveryTimeout(recordName: recordName) } } onCancel: { Task { await self.cancelPingDeliveryWait(recordName: recordName) } @@ -841,10 +857,34 @@ actor SyncEngine { } private func cancelPingDeliveryWait(recordName: String) { + clearPingDeliveryTimeout(recordName: recordName) pingDeliveryWaiters.removeValue(forKey: recordName)? .resume(throwing: CancellationError()) } + /// Bounds a `waitForServerConfirmation` send so it never blocks forever. + /// On expiry the Ping is left queued (the durable outbox keeps retrying and + /// `confirmPendingPing`/`failPendingPing` still resolves its true outcome + /// later); only the caller's wait ends, with `.deliveryPending`. + private func armPingDeliveryTimeout(recordName: String) { + pingDeliveryTimeouts[recordName]?.cancel() + pingDeliveryTimeouts[recordName] = Task { [self] in + try? await Task.sleep(for: pingConfirmationTimeout) + guard !Task.isCancelled else { return } + timeoutPingDeliveryWait(recordName: recordName) + } + } + + private func clearPingDeliveryTimeout(recordName: String) { + pingDeliveryTimeouts.removeValue(forKey: recordName)?.cancel() + } + + private func timeoutPingDeliveryWait(recordName: String) { + pingDeliveryTimeouts.removeValue(forKey: recordName) + pingDeliveryWaiters.removeValue(forKey: recordName)? + .resume(throwing: PingOutboxError.deliveryPending) + } + private func restorePendingPings() async { let ctx = pendingPingContext pendingPings = await ctx.perform { @@ -3093,4 +3133,8 @@ extension SyncEngine: CKSyncEngineDelegate { func confirmPendingPingForTesting(recordName: String) async { await confirmPendingPing(recordName: recordName) } + + func setPingConfirmationTimeoutForTesting(_ duration: Duration) { + pingConfirmationTimeout = duration + } } diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -148,6 +148,15 @@ struct FriendPickerView: View { _ = invitedAuthorIDs.insert(authorID) isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 } + } catch SyncEngine.PingOutboxError.deliveryPending { + // CloudKit hasn't confirmed within the patience window, but the + // invite stays durably queued. Reflect it as invited rather than + // failed; the delivery update flips it to failed later if the send + // is ultimately rejected. + withAnimation(.snappy) { + _ = invitedAuthorIDs.insert(authorID) + isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 + } } catch { appActions.markInviteFailed( gameID: gameID, diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -365,6 +365,14 @@ struct GameListView: View { announcements.dismiss(id: Self.newGameInviteErrorID) do { try await appActions.inviteFriend(gameID: gameID, friendAuthorID: target.authorID) + } catch SyncEngine.PingOutboxError.deliveryPending { + // The invite is durably queued; CloudKit just hasn't confirmed yet. + // Don't raise a failure banner — the async delivery update surfaces + // one only if the send is ultimately rejected. + eventLog.note( + "new game friend invite queued game=\(gameID.uuidString) friend=\(target.authorID)", + level: "info" + ) } catch { announcements.dismiss( id: InviteDeliveryStore.failureAnnouncementID( diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -363,6 +363,15 @@ struct GameShareSheet: View { _ = invitedAuthorIDs.insert(authorID) isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 } + } catch SyncEngine.PingOutboxError.deliveryPending { + // CloudKit hasn't confirmed within the patience window, but the + // invite stays durably queued. Reflect it as invited rather than + // failed; the delivery update flips it to failed later if the send + // is ultimately rejected. + withAnimation(.snappy) { + _ = invitedAuthorIDs.insert(authorID) + isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 + } } catch { appActions.markInviteFailed( gameID: gameID, diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -291,4 +291,75 @@ struct PendingChangeReapTests { #expect(delivery.phase == .failed) #expect(delivery.failure == .quotaExceeded) } + + @Test("Confirmation timeout leaves the invite queued and non-destructive") + func inviteConfirmationTimeoutStaysQueued() async throws { + let persistence = makeTestPersistence() + let engine = await makeEngine(persistence: persistence) + await engine.setPingConfirmationTimeoutForTesting(.milliseconds(50)) + let deliveries = InviteDeliveryStore() + let gameID = UUID() + let friendAuthorID = "_friend" + let delivery = deliveries.delivery( + gameID: gameID, + friendAuthorID: friendAuthorID + ) + + 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, + failure: update.failure ?? .other + ) + } + } + + let zoneID = CKRecordZone.ID( + zoneName: "friend-timeout", + ownerName: friendAuthorID + ) + // The confirmed-send wait gives up after the (shortened) patience + // window and reports `.deliveryPending` rather than blocking forever. + await #expect(throws: SyncEngine.PingOutboxError.deliveryPending) { + try await engine.enqueueFriendZonePing( + kind: .invite, + gameID: gameID, + gameTitle: "Timeout", + authorID: "_localAuthor", + playerName: "Local", + addressee: friendAuthorID, + friendZoneID: zoneID, + friendZoneScope: .shared, + payload: #"{"gameShareURL":"https://example.com/share"}"#, + rollbackParticipantOnFailure: true, + waitForServerConfirmation: true + ) + } + + // Non-destructive: the Ping is still queued and never marked failed. + let recordName = try #require( + await engine.pendingPingRecordNamesForTesting().first + ) + #expect(delivery.phase == .queued) + + // The durable outbox still resolves the true outcome afterwards. + await engine.confirmPendingPingForTesting(recordName: recordName) + #expect(delivery.phase == .sent) + #expect(await engine.pendingPingRecordNamesForTesting().isEmpty) + } }