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:
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 {