commit d08ec8bc081c088ac8f10995332f5a894c1a2928
parent a924cf520146eb9edf6a80580dab74b8282b6c81
Author: Michael Camilleri <[email protected]>
Date: Wed, 20 May 2026 14:38:36 +0900
Detach CKSyncEngine send drains from enqueue paths
The latest TestFlight crash symbolicated to
SyncEngine.enqueuePlayerRecord(gameID:authorID:), where the Player.readAt scene
transition path queued a Player record and kicked CKSyncEngine with a plain
Task { sendChanges() }. That can still inherit delegate-callback execution
context and re-enter CKSyncEngine before its callback unwinds, tripping
CloudKit's serialization guard with EXC_BREAKPOINT.
This commit adds a single sendChangesDetached(on:) helper and routes every
outbound enqueue drain through it. Pending CKSyncEngine state is still recorded
synchronously; only the best-effort send drain moves to a detached task,
matching the previous friend-ping crash fixes and covering the remaining
enqueue surfaces.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
1 file changed, 20 insertions(+), 13 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -333,6 +333,15 @@ actor SyncEngine {
}
}
+ /// Kicks CKSyncEngine's outbound drain after pending state has been queued.
+ /// This must be detached: several enqueue paths are reachable from
+ /// CKSyncEngine delegate callbacks, and a plain `Task {}` can inherit the
+ /// callback's executor and re-enter CKSyncEngine before the callback
+ /// unwinds, tripping CloudKit's serialization guard.
+ private func sendChangesDetached(on engine: CKSyncEngine) {
+ Task.detached { [engine] in try? await engine.sendChanges() }
+ }
+
// MARK: - Outbound
/// Registers each game's local-device Moves record as a pending save and
@@ -372,10 +381,10 @@ actor SyncEngine {
}
}
if kickPrivate, let engine = privateEngine {
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
if kickShared, let engine = sharedEngine {
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
}
@@ -411,7 +420,7 @@ actor SyncEngine {
let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine
guard let engine else { return }
engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
/// Registers a Ping record as a pending send. Pings cover join, win,
@@ -489,7 +498,7 @@ actor SyncEngine {
"zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)"
)
}
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
/// Registers an `.opened` Ping for cross-device notification dismissal.
@@ -572,7 +581,7 @@ actor SyncEngine {
)
let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
/// Registers a durable `Decision` record into the account zone so the fact
@@ -590,21 +599,19 @@ actor SyncEngine {
let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
/// Deletes a durable `Decision` record (account zone) so a fact that no
/// longer holds stops propagating — e.g. a `left` decision is voided when
/// the user re-joins that game. Deleting an absent record is benign
- /// (CloudKit reports it gone; the send path does not retry-loop it). The
- /// `sendChanges` is deferred via `Task` so this is safe to call from a
- /// CKSyncEngine delegate callback (see the friend-invite re-entrancy fix).
+ /// (CloudKit reports it gone; the send path does not retry-loop it).
func enqueueDecisionDeletion(kind: String, key: String) {
guard let engine = privateEngine else { return }
let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
let recordID = CKRecord.ID(recordName: name, zoneID: RecordSerializer.accountZoneID)
engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
/// Consume-deletes a single Ping the local account has handled (shown,
@@ -627,7 +634,7 @@ actor SyncEngine {
pendingPings.removeValue(forKey: recordName)
let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
- Task.detached { [engine] in try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
private nonisolated static func notificationTitle(for entity: GameEntity?) -> String {
@@ -650,7 +657,7 @@ actor SyncEngine {
let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
/// Registers a Game record as a pending send and ensures its zone is
@@ -665,7 +672,7 @@ actor SyncEngine {
engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))])
let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
- Task { try? await engine.sendChanges() }
+ sendChangesDetached(on: engine)
}
// MARK: - Explicit sync triggers (called by AppServices / diagnostics view)