crossmate

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

commit eccd53839b712d6ccd3150cc7c4224ab15b6d9f0
parent 1862bed02672e9869143441ecd5ce29066c11853
Author: Michael Camilleri <[email protected]>
Date:   Wed, 20 May 2026 03:16:05 +0900

Detach CKSyncEngine re-entry out of the onPings delegate callback

A TestFlight build crashed with EXC_BREAKPOINT —
CloudKit/CKSyncEngine.swift:293 'Cannot await a call into CKSyncEngine from
within a delegate callback'. applyFriendPing runs inside that callback
(handleFetchedRecordZoneChanges → onPings → presentPings → applyFriendPing) and
spawned the post-accept syncEngine.fetchChanges() in a plain Task {}. Because
FriendController is @MainActor the Task inherited the callback's actor, so it
could run fetchChanges at one of the callback's suspension points — before
handleEvent returned — re-entering CKSyncEngine while it still held the
delegate serialization guard. The earlier fix used a plain Task {} rather than
a detached one, which is why the trap recurred.

SyncEngine.deletePing carried the same un-detached Task { sendChanges() }, and
the directed-ping consume path (presentPings → consumeIfDirected) now reaches
it from the same delegate callback — the identical latent re-entry, one engine
method over.

Both now use Task.detached, capturing the engine (and monitor) explicitly so
the drain runs off the callback's actor after it unwinds. The accepted-share
and pending-delete state is still queued synchronously before the Task; only
the fetchChanges/sendChanges drain is deferred, and eventual consistency there
is acceptable. The change is scoped to these two functions — the other
Task { sendChanges() } enqueue sites are not reachable from a delegate
callback.

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

Diffstat:
MCrossmate/Sync/FriendController.swift | 10++++++++--
MCrossmate/Sync/SyncEngine.swift | 12+++++++++---
2 files changed, 17 insertions(+), 5 deletions(-)

diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -168,9 +168,15 @@ final class FriendController { // from within a delegate callback"). Detach the post-accept // refresh so the delegate callback returns first; it only needs // to land eventually. - Task { + // Detached (not a plain `Task {}`): a `Task {}` here inherits this + // @MainActor context and can run `fetchChanges()` at one of the + // delegate callback's own suspension points — before `handleEvent` + // returns — so CKSyncEngine's guard still trips. A detached task + // runs off this actor, after the callback unwinds. (`Task {}` was + // the original, insufficient fix; the trap recurred in 2026.310.) + Task.detached { [syncEngine, syncMonitor] in await syncMonitor?.run("friendship accept fetch") { - try await self.syncEngine.fetchChanges() + try await syncEngine.fetchChanges() } } } catch { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -705,8 +705,14 @@ actor SyncEngine { /// suppressed, or a duplicate). The deletion syncs through the game zone so /// this user's other devices withdraw any notification they showed for it. /// Deleting an absent record is benign (CloudKit reports it gone; the send - /// path does not retry-loop it). `sendChanges` is deferred via `Task` so - /// this is safe to call from a delegate callback (friend-invite re-entrancy). + /// path does not retry-loop it). The deletion is queued into `engine.state` + /// synchronously; only the `sendChanges` drain is deferred — and via + /// `Task.detached`, not a plain `Task {}`. The completion-ack consume path + /// (`presentPings` → `consumeIfDirected`) reaches this from inside the + /// `onPings` delegate callback, so an un-detached Task could re-enter + /// 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) { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } @@ -715,7 +721,7 @@ actor SyncEngine { pendingPings.removeValue(forKey: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) - Task { try? await engine.sendChanges() } + Task.detached { [engine] in try? await engine.sendChanges() } } private nonisolated static func notificationTitle(for entity: GameEntity?) -> String {