crossmate

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

commit 4843509c8f5639e8ee7a3f9f8882c959e7f96f7f
parent 0b706df02ee4a6a85a65e8c854abbec37f972f2b
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 12:27:37 +0900

Stop awaiting CKSyncEngine from inside delegate callback

FriendController.applyFriendPing runs inside presentPings, which is the onPings
handler CKSyncEngine awaits from within its event-delivery delegate callback.
After accepting the friend-zone share, applyFriendPing then did `await
syncEngine.fetchChanges()`, re-entering CKSyncEngine from inside that callback.
CKSyncEngine forbids this — it cannot guarantee it delivers delegate callbacks
serially if one awaits back into it — and traps the process (EXC_BREAKPOINT;
CloudKit/CKSyncEngine.swift:293). The friend-invite bootstrap reliably hit
this: an inbound .friend Ping accepts the share and immediately refreshes, so a
collaborator joining crashed the app.

recordSuccess("accept friendship") now fires once the share is accepted and the
FriendEntity is persisted — the friendship is established at that point. The
follow-up fetchChanges() moves into an unstructured Task, so applyFriendPing
(and therefore the onPings callback) returns without awaiting back into
CKSyncEngine; the refresh lands a beat later, which is all this path needs.
This mirrors the existing `Task { try? await engine.sendChanges() }` detach
used by the enqueue paths. An audit confirmed this was the only awaited
CKSyncEngine re-entry reachable from a delegate callback: applyOpenedPing makes
no engine calls, and the onRemotePlayersUpdated → reconcileFriendships →
establishIfOwner path only uses enqueuePing, which already detaches.

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

Diffstat:
MCrossmate/Sync/FriendController.swift | 15++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -160,10 +160,19 @@ final class FriendController { zoneOwnerName: zoneID.ownerName, databaseScope: 1 ) - await syncMonitor?.run("friendship accept fetch") { - try await self.syncEngine.fetchChanges() - } syncMonitor?.recordSuccess("accept friendship") + // `applyFriendPing` runs inside the `onPings` CKSyncEngine + // delegate callback. Awaiting a call back into CKSyncEngine from + // there trips its serialization guard and crashes + // (CKSyncEngine.swift:293: "Cannot await a call into CKSyncEngine + // from within a delegate callback"). Detach the post-accept + // refresh so the delegate callback returns first; it only needs + // to land eventually. + Task { + await syncMonitor?.run("friendship accept fetch") { + try await self.syncEngine.fetchChanges() + } + } } catch { syncMonitor?.recordError("accept friendship", error) }