crossmate

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

commit e6c313e92b9fab577e61b8709ebbea420577e107
parent e330fa0221133d89dee7df5ad381f20890f47f28
Author: Michael Camilleri <[email protected]>
Date:   Fri,  3 Jul 2026 10:35:07 +0900

Convert SyncEngine's remaining performAndWait sites

Previous to this commit, the sync actor still ran the Core Data work on
its fetch, send, and enqueue paths through performAndWait, which blocks
a cooperative-pool thread for the duration of the context's queue work —
the same anti-pattern an earlier pass converted in MovesUpdater and
PlayerSelectionPublisher, deferred here because of the number of sites.

This commit converts the remaining thirteen to `await ctx.perform`, so
the actor suspends instead. enqueuePing, gameZoneIDs, and
saveEngineState became async in the process — every caller was already
async — and enqueuePing's `Task { await trace(…) }` wrappers, only there
because the function was synchronous, became direct awaits.

Suspending exposes two races the blocking had masked, both closed here.
Engine-state persistence (start, saveEngineState, resetSyncState) wrote
the singleton SyncStateEntity row through fresh per-call contexts; once
the per-scope saves can interleave, two of them could each
fetch-or-create the row — duplicating it on first launch — or fail on a
row-level conflict. All engine-state work now shares one dedicated
serial syncStateContext, with automatic merging so the database-reset
path's external delete of the row cannot strand a stale registered
object. And start()'s idempotence guard sits before suspension points,
so a concurrent second call could double-initialise the engines; it now
sets an isStarting flag inside the guard.

The nonisolated zone-lookup helpers (zoneInfo, knownZones) keep their
internal performAndWait: called from inside a perform block on the same
context it is reentrant and runs immediately, and converting them
properly means touching their callers across CloudQuery and friends — a
separate pass.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 117+++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
1 file changed, 66 insertions(+), 51 deletions(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -64,6 +64,27 @@ actor SyncEngine { var privateEngine: CKSyncEngine? var sharedEngine: CKSyncEngine? + /// Dedicated serial context for the singleton `SyncStateEntity` row. Both + /// scopes' engine state persists through this one context: `await + /// ctx.perform` does not hold the actor the way `performAndWait` did, so + /// per-call fresh contexts could otherwise race each other — two + /// concurrent saves could each fetch-or-create the row (duplicating it on + /// first launch) or fail on a row-level conflict. Automatic merging keeps + /// the long-lived context coherent if the row is deleted externally (the + /// database reset path). + private lazy var syncStateContext: NSManagedObjectContext = { + let ctx = persistence.container.newBackgroundContext() + ctx.automaticallyMergesChangesFromParent = true + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + return ctx + }() + + /// True while `start()` is between its idempotence guard and the engine + /// assignments. The body suspends (engine-state read, state decode), so + /// the `privateEngine == nil` guard alone no longer makes a concurrent + /// second call a no-op. + private var isStarting = false + /// In-memory map for Ping records pending send. Pings have no Core Data /// backing — they're write-once-and-forget — so we stash the minimal data /// here keyed by record name and look it up in `buildRecord`. @@ -300,7 +321,9 @@ actor SyncEngine { /// `services.start()` and a scene-active foreground sync can't double- /// initialise the engines or re-fire subscription setup. func start() async { - guard privateEngine == nil, sharedEngine == nil else { return } + guard privateEngine == nil, sharedEngine == nil, !isStarting else { return } + isStarting = true + defer { isStarting = false } // CKSyncEngine restores its pending `.saveRecord` changes from the // serialized state below; restore the matching Decision payloads so a @@ -308,11 +331,12 @@ actor SyncEngine { restorePendingDecisionPayloads() restorePendingDecisionVersions() - let bgCtx = persistence.container.newBackgroundContext() - - let privateData: Data? = bgCtx.performAndWait { - SyncStateEntity.current(in: bgCtx).ckPrivateEngineState + let ctx = syncStateContext + let (privateData, sharedData): (Data?, Data?) = await ctx.perform { + let entity = SyncStateEntity.current(in: ctx) + return (entity.ckPrivateEngineState, entity.ckSharedEngineState) } + let privateState = await decodeEngineState(privateData, label: "private") privateEngine = CKSyncEngine(CKSyncEngine.Configuration( database: container.privateCloudDatabase, @@ -320,9 +344,6 @@ actor SyncEngine { delegate: self )) - let sharedData: Data? = bgCtx.performAndWait { - SyncStateEntity.current(in: bgCtx).ckSharedEngineState - } let sharedState = await decodeEngineState(sharedData, label: "shared") sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( database: container.sharedCloudDatabase, @@ -463,11 +484,11 @@ actor SyncEngine { // happened. let localAuthorID = await currentLocalAuthorID() let ctx = persistence.container.newBackgroundContext() - let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = ctx.performAndWait { + let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = await ctx.perform { var privateIDs: [CKRecord.ID] = [] var sharedIDs: [CKRecord.ID] = [] for gameID in gameIDs { - guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } + guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { continue } let isShared = info.scope == .shared let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") if let localAuthorID, !localAuthorID.isEmpty { @@ -521,7 +542,7 @@ actor SyncEngine { func enqueueUnconfirmedMoves() async -> Int { let localAuthorID = await currentLocalAuthorID() let ctx = persistence.container.newBackgroundContext() - let gameIDs: Set<UUID> = ctx.performAndWait { + let gameIDs: Set<UUID> = await ctx.perform { let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") if let localAuthorID, !localAuthorID.isEmpty { req.predicate = NSPredicate( @@ -585,9 +606,9 @@ actor SyncEngine { playerName: String, payload: String? = nil, addressee: String? = nil - ) { + ) async { let ctx = persistence.container.newBackgroundContext() - let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait { + let zoneAndTitle: (info: ZoneInfo, title: String)? = await ctx.perform { guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil } let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -597,25 +618,21 @@ actor SyncEngine { return (info, title) } guard let zoneAndTitle else { - Task { - await trace( - "ping send: SKIPPED kind=\(kind.rawValue) " + - "game=\(gameID.uuidString) " + - "— no zone info (game not yet synced/shared on this device)" - ) - } + await trace( + "ping send: SKIPPED kind=\(kind.rawValue) " + + "game=\(gameID.uuidString) " + + "— no zone info (game not yet synced/shared on this device)" + ) return } let engine = zoneAndTitle.info.scope == .shared ? sharedEngine : privateEngine guard let engine else { - Task { - await trace( - "ping send: SKIPPED kind=\(kind.rawValue) " + - "game=\(gameID.uuidString) " + - "— no CKSyncEngine for " + - "\(zoneAndTitle.info.scope == .shared ? "shared" : "private") scope" - ) - } + await trace( + "ping send: SKIPPED kind=\(kind.rawValue) " + + "game=\(gameID.uuidString) " + + "— no CKSyncEngine for " + + "\(zoneAndTitle.info.scope == .shared ? "shared" : "private") scope" + ) return } let deviceID = RecordSerializer.localDeviceID @@ -639,14 +656,12 @@ actor SyncEngine { ) let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) - Task { - await trace( - "ping send: enqueued kind=\(kind.rawValue) " + - "game=\(gameID.uuidString) " + - "target=\(zoneAndTitle.info.scope == .shared ? "shared" : "private") " + - "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)" - ) - } + await trace( + "ping send: enqueued kind=\(kind.rawValue) " + + "game=\(gameID.uuidString) " + + "target=\(zoneAndTitle.info.scope == .shared ? "shared" : "private") " + + "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)" + ) sendChangesDetached(on: engine) } @@ -833,9 +848,9 @@ actor SyncEngine { return deleted } - private func gameZoneIDs(forScope scope: DatabaseScope) -> [CKRecordZone.ID] { + private func gameZoneIDs(forScope scope: DatabaseScope) async -> [CKRecordZone.ID] { let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { + return await ctx.perform { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate( format: "databaseScope == %d AND isAccessRevoked == NO", @@ -1248,15 +1263,15 @@ actor SyncEngine { /// Pending records already in CloudKit are unaffected. Locally-unconfirmed /// moves are re-enqueued so the new engines push them on the next cycle. func resetSyncState() async { - let ctx = persistence.container.newBackgroundContext() - ctx.performAndWait { + let ctx = syncStateContext + await ctx.perform { let entity = SyncStateEntity.current(in: ctx) entity.ckPrivateEngineState = nil entity.ckSharedEngineState = nil do { try ctx.save() } catch { - trace("resetSyncState ctx.save failed — \(error)") + self.trace("resetSyncState ctx.save failed — \(error)") } } privateEngine = CKSyncEngine(CKSyncEngine.Configuration( @@ -1316,9 +1331,9 @@ actor SyncEngine { private func saveEngineState( _ serialization: CKSyncEngine.State.Serialization, isPrivate: Bool - ) { - let ctx = persistence.container.newBackgroundContext() - ctx.performAndWait { + ) async { + let ctx = syncStateContext + await ctx.perform { guard let data = try? JSONEncoder().encode(serialization) else { return } let entity = SyncStateEntity.current(in: ctx) if isPrivate { @@ -1329,7 +1344,7 @@ actor SyncEngine { do { try ctx.save() } catch { - trace("saveEngineState ctx.save failed — \(error)") + self.trace("saveEngineState ctx.save failed — \(error)") } } } @@ -1357,7 +1372,7 @@ actor SyncEngine { let deletedGameZones = Set(event.deletions.map(\.zoneID).filter { $0.zoneName.hasPrefix("game-") }) - let (rejoinedIDs, failureMessages): ([UUID], [String]) = ctx.performAndWait { + let (rejoinedIDs, failureMessages): ([UUID], [String]) = await ctx.perform { var rejoined: [UUID] = [] var messages: [String] = [] if !isPrivate { @@ -1655,7 +1670,7 @@ actor SyncEngine { } if effects.friendNamesChanged { let mirrorCtx = persistence.container.newBackgroundContext() - mirrorCtx.performAndWait { + await mirrorCtx.perform { FriendEntity.rebuildNicknameDirectory(in: mirrorCtx) } } @@ -1786,7 +1801,7 @@ actor SyncEngine { ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, [String], [(secret: String, version: Int64)], [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)], - [CKRecord.ID]) = ctx.performAndWait { + [CKRecord.ID]) = await ctx.perform { var messages: [String] = [] var orphaned = Set<CKRecordZone.ID>() var settledDecisions = Set<CKRecord.ID>() @@ -2039,7 +2054,7 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let (removedIDs, revokedIDs, failureMessages): ([UUID], [UUID], [String]) = ctx.performAndWait { + let (removedIDs, revokedIDs, failureMessages): ([UUID], [UUID], [String]) = await ctx.perform { var removed: [UUID] = [] var revoked: [UUID] = [] var messages: [String] = [] @@ -2176,7 +2191,7 @@ extension SyncEngine: CKSyncEngineDelegate { let isPrivate = syncEngine === privateEngine switch event { case .stateUpdate(let e): - saveEngineState(e.stateSerialization, isPrivate: isPrivate) + await saveEngineState(e.stateSerialization, isPrivate: isPrivate) case .accountChange(let e): await trace("account change: \(e.changeType)") @@ -2280,7 +2295,7 @@ extension SyncEngine: CKSyncEngineDelegate { guard !foreign.isEmpty else { return } let ctx = persistence.container.newBackgroundContext() for (gameID, authorID) in foreign { - let readAt: Date? = ctx.performAndWait { + let readAt: Date? = await ctx.perform { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1