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:
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)
}