crossmate

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

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:
MCrossmate/Sync/SyncEngine.swift | 33++++++++++++++++++++-------------
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)