commit 4cf942d22fdf51a3dff8ceca0059e37389bad64e
parent 8c5ad79b9ddc9bbf32a303d0d1f26d59c25a755f
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 01:28:56 +0900
Stop blocking the main actor on background Core Data fetches
This commit converts several main-actor Core Data reads from
ctx.performAndWait to await ctx.perform. Each site created a background
context and then called performAndWait, which synchronously blocks the
main thread until the fetch returns. Against the small tables involved
the stall is imperceptible today, but it is a latent hang as a player's
library grows or on slower storage. Awaiting perform instead runs the
fetch on the context's private queue while the main actor suspends.
The affected reads are PlayerRoster.refresh, SessionCoordinator.pushPlan,
AccountPushCoordinator.friendEncryptionKeyTargets, the AppServices
startup directory rebuild and pending journal-upload sweep, and
PlayerNamePublisher's name-publish helpers. Where the leaf read was
synchronous the async signature threads up a short caller chain that was
already asynchronous.
PlayerNamePublisher's friendZoneTargets and upsertPlayerRecord were
nonisolated static helpers whose comment claimed main-actor isolation
did not apply, yet their callers invoked them synchronously from the
main actor, so performAndWait still blocked it; the comment is corrected
to match the new behaviour.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
5 files changed, 30 insertions(+), 27 deletions(-)
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -251,9 +251,11 @@ final class PlayerRoster {
}
self.localAuthorID = localAuthorID
- // Pull Core Data fields off a background context.
+ // Pull Core Data fields off a background context without blocking the
+ // main actor: `await perform` hops to the context's queue and suspends
+ // (vs. `performAndWait`, which stalls main until the fetch returns).
let ctx = persistence.container.newBackgroundContext()
- let fetched = ctx.performAndWait { () -> FetchedRoster in
+ let fetched = await ctx.perform { [gameID] () -> FetchedRoster in
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift
@@ -186,12 +186,12 @@ final class AccountPushCoordinator {
// The account-scoped sibling address carries no game credential.
var bindings = Set(result.bindings)
bindings.insert(PushAddressBinding(address: accountAddress))
- bindings.formUnion(friendEncryptionKeyBindings(localAuthorID: authorID))
+ bindings.formUnion(await friendEncryptionKeyBindings(localAuthorID: authorID))
pushClient.setAddresses(bindings)
}
- private func friendEncryptionKeyBindings(localAuthorID: String) -> Set<PushAddressBinding> {
- let friends = friendEncryptionKeyTargets(localAuthorID: localAuthorID)
+ private func friendEncryptionKeyBindings(localAuthorID: String) async -> Set<PushAddressBinding> {
+ let friends = await friendEncryptionKeyTargets(localAuthorID: localAuthorID)
var bindings = Set<PushAddressBinding>()
for friend in friends {
guard let payload = existingFriendEncryptionKey(pairKey: friend.pairKey) else { continue }
@@ -229,9 +229,9 @@ final class AccountPushCoordinator {
private func friendEncryptionKeyTargets(
localAuthorID: String
- ) -> [(pairKey: String, zoneID: CKRecordZone.ID, scope: Int16)] {
+ ) async -> [(pairKey: String, zoneID: CKRecordZone.ID, scope: Int16)] {
let ctx = persistence.container.newBackgroundContext()
- return ctx.performAndWait {
+ return await ctx.perform {
let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
req.predicate = NSPredicate(format: "isBlocked == NO")
return ((try? ctx.fetch(req)) ?? []).compactMap { friend in
@@ -254,8 +254,8 @@ final class AccountPushCoordinator {
private func friendEncryptionKeyTarget(
pairKey: String,
localAuthorID: String
- ) -> (zoneID: CKRecordZone.ID, scope: Int16)? {
- friendEncryptionKeyTargets(localAuthorID: localAuthorID)
+ ) async -> (zoneID: CKRecordZone.ID, scope: Int16)? {
+ await friendEncryptionKeyTargets(localAuthorID: localAuthorID)
.first { $0.pairKey == pairKey }
.map { ($0.zoneID, $0.scope) }
}
@@ -487,7 +487,7 @@ final class AccountPushCoordinator {
return
}
let pairKey = FriendZone.pairKey(authorID, friendAuthorID)
- if let target = friendEncryptionKeyTarget(pairKey: pairKey, localAuthorID: authorID) {
+ if let target = await friendEncryptionKeyTarget(pairKey: pairKey, localAuthorID: authorID) {
await ensureFriendInvitationKeyPublished(
pairKey: pairKey,
friendZoneID: target.zoneID,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -676,7 +676,7 @@ final class AppServices {
// crash or extension write skipped. Cheap: one fetch over the (small)
// friends table.
let nicknameCtx = persistence.container.newBackgroundContext()
- nicknameCtx.performAndWait {
+ await nicknameCtx.perform {
FriendEntity.rebuildNicknameDirectory(in: nicknameCtx)
// Heal the App Group content-key directory from the same context —
// covers the first run after the feature shipped and any rebuild a
@@ -1265,7 +1265,7 @@ final class AppServices {
guard let authorID = identity.currentID, !authorID.isEmpty else { return }
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let candidates: [UUID] = ctx.performAndWait {
+ let candidates: [UUID] = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "completedAt != nil AND journalUploaded == NO")
return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
@@ -1285,7 +1285,7 @@ final class AppServices {
// No local journal for these — this device never played them, so there
// is nothing to publish. Mark them done so the sweep stops reconsidering.
let toMark = nothingToUpload
- ctx.performAndWait {
+ await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id IN %@", toMark)
for game in (try? ctx.fetch(req)) ?? [] {
diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift
@@ -131,7 +131,7 @@ final class PlayerNamePublisher {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- guard Self.upsertPlayerRecord(
+ guard await Self.upsertPlayerRecord(
for: gameID,
in: ctx,
authorID: authorID,
@@ -151,7 +151,7 @@ final class PlayerNamePublisher {
authorID, name, version, RecordSerializer.accountZoneID, 0
)
let ctx = persistence.container.newBackgroundContext()
- for target in Self.friendZoneTargets(in: ctx) {
+ for target in await Self.friendZoneTargets(in: ctx) {
await enqueueNameDecision(
authorID, name, version, target.zoneID, target.scope
)
@@ -160,11 +160,12 @@ final class PlayerNamePublisher {
onFanOutForTesting?(name)
}
- /// Background-context work — main-actor isolation does not apply here.
+ /// Background-context work — runs on `ctx`'s queue via `await perform`, so
+ /// it never blocks the main actor even though the caller is `@MainActor`.
private nonisolated static func friendZoneTargets(
in ctx: NSManagedObjectContext
- ) -> [(zoneID: CKRecordZone.ID, scope: Int16)] {
- ctx.performAndWait {
+ ) async -> [(zoneID: CKRecordZone.ID, scope: Int16)] {
+ await ctx.perform {
let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
req.predicate = NSPredicate(format: "isBlocked == NO")
let friends = (try? ctx.fetch(req)) ?? []
@@ -188,8 +189,8 @@ final class PlayerNamePublisher {
in ctx: NSManagedObjectContext,
authorID: String,
name: String
- ) -> Bool {
- ctx.performAndWait {
+ ) async -> Bool {
+ await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
format: "id == %@ AND (ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO",
diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift
@@ -222,7 +222,7 @@ final class SessionCoordinator {
syncMonitor.note("push(nudge): skipped (no pushClient)")
return
}
- let plan = pushPlan(for: gameID, excluding: localAuthorID)
+ let plan = await pushPlan(for: gameID, excluding: localAuthorID)
guard plan.completedAt == nil else {
syncMonitor.note("push(nudge): skipped (game completed)")
return
@@ -272,7 +272,7 @@ final class SessionCoordinator {
syncMonitor.note("push(join): skipped (no pushClient)")
return
}
- let plan = pushPlan(for: gameID, excluding: localAuthorID)
+ let plan = await pushPlan(for: gameID, excluding: localAuthorID)
guard plan.completedAt == nil else {
syncMonitor.note("push(join): skipped (game completed)")
return
@@ -333,7 +333,7 @@ final class SessionCoordinator {
syncMonitor.note("push(pause): skipped (no pushClient)")
return
}
- let plan = pushPlan(for: gameID, excluding: localAuthorID)
+ let plan = await pushPlan(for: gameID, excluding: localAuthorID)
guard !plan.recipients.isEmpty else {
syncMonitor.note("push(pause): skipped (no recipients)")
return
@@ -436,7 +436,7 @@ final class SessionCoordinator {
syncMonitor.note("push(\(kindLabel)): skipped (no authorID)")
return
}
- let plan = pushPlan(for: gameID, excluding: localAuthorID)
+ let plan = await pushPlan(for: gameID, excluding: localAuthorID)
guard !plan.recipients.isEmpty else {
syncMonitor.note("push(\(kindLabel)): skipped (no recipients)")
return
@@ -476,7 +476,7 @@ final class SessionCoordinator {
syncMonitor.note("push(replay): skipped (no pushClient)")
return
}
- let plan = pushPlan(for: gameID)
+ let plan = await pushPlan(for: gameID)
guard !plan.recipients.isEmpty else {
syncMonitor.note("push(replay): skipped (no recipients)")
return
@@ -517,9 +517,9 @@ final class SessionCoordinator {
private func pushPlan(
for gameID: UUID,
excluding authorID: String? = nil
- ) -> PushPlan {
+ ) async -> PushPlan {
let ctx = persistence.container.newBackgroundContext()
- return ctx.performAndWait {
+ return await ctx.perform {
let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
gReq.fetchLimit = 1