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