commit 35441f5d9f1002487dcb538b6e59636641639025
parent aec26a1b155a86cf1b268ff9d840ac187006085c
Author: Michael Camilleri <[email protected]>
Date: Fri, 3 Jul 2026 13:00:08 +0900
Convert additional main thread performAndWait sites
GameArchiver, InviteCoordinator, and FriendController are @MainActor
classes, so their performAndWait calls stalled the main thread while a
background context did its queue work — the same anti-pattern an earlier
pass converted in PlayerRoster, SessionCoordinator, and friends, but
never swept in these files. InviteCoordinator's sites sit on the invite
and ping paths behind a user-facing tap; GameArchiver's run at
completion and on the cold-launch reconcile sweep.
This commit converts all nine sites to `await ctx.perform`, so the main
actor suspends instead. markArchived and applyInvitePings became async
in the process — each has only async callers — and applyInvitePings'
save-failure logging moved from a Task hop inside the closure to a
collect-then-log after the await.
FriendController's site was different in kind: migrateToMailboxes
fetched on the viewContext, whose queue is the main queue, so converting
the call in place would have changed nothing. Its migration fetch now
runs on a background context like the rest.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 23 insertions(+), 22 deletions(-)
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -144,7 +144,7 @@ final class InviteCoordinator {
else { return }
let ctx = persistence.container.newBackgroundContext()
- let candidates: [(gameID: UUID, remoteAuthorID: String)] = ctx.performAndWait {
+ let candidates: [(gameID: UUID, remoteAuthorID: String)] = await ctx.perform {
var result: [(UUID, String)] = []
for gameID in gameIDs {
let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -199,7 +199,7 @@ final class InviteCoordinator {
/// invite's Ping is re-fetched on every cold start, but its row already
/// exists by then, so it is absent from this set and isn't re-surfaced.
@discardableResult
- private func applyInvitePings(_ pings: [Ping]) -> Set<String> {
+ private func applyInvitePings(_ pings: [Ping]) async -> Set<String> {
let candidates = pings.filter {
$0.kind == .invite &&
$0.authorID != identity.currentID &&
@@ -224,7 +224,7 @@ final class InviteCoordinator {
guard !invites.isEmpty else { return [] }
let ctx = persistence.container.newBackgroundContext()
- return ctx.performAndWait {
+ let (inserted, saveError): (Set<String>, Error?) = await ctx.perform {
var insertedPingRecordNames: Set<String> = []
for ping in invites {
guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
@@ -268,14 +268,15 @@ final class InviteCoordinator {
} catch {
// The rows didn't persist, so treat none as "freshly
// recorded" — a later fetch will re-create and notify.
- insertedPingRecordNames.removeAll()
- Task { @MainActor [weak self] in
- self?.eventLog.note("InviteCoordinator: applyInvitePings save failed — \(error)", level: "error")
- }
+ return ([], error)
}
}
- return insertedPingRecordNames
+ return (insertedPingRecordNames, nil)
}
+ if let saveError {
+ eventLog.note("InviteCoordinator: applyInvitePings save failed — \(saveError)", level: "error")
+ }
+ return inserted
}
/// Drops the pending invite row(s) for `gameID`. Called when the game's
@@ -550,7 +551,7 @@ final class InviteCoordinator {
let currentAuthorID = identity.currentID
let ctx = persistence.container.newBackgroundContext()
- let staleNames: Set<String> = ctx.performAndWait {
+ let staleNames: Set<String> = await ctx.perform {
Self.staleInviteRecordNames(
among: candidates,
in: ctx,
@@ -619,7 +620,7 @@ final class InviteCoordinator {
guard !claimed.isEmpty else { return }
let pings = await consumeStaleInvites(claimed)
guard !pings.isEmpty else { return }
- let newlyInvited = applyInvitePings(pings)
+ let newlyInvited = await applyInvitePings(pings)
// Reflect any newly-stored pending invite in the app-icon badge now —
// before the notification-authorization guard — so the badge updates
// even when the banner is suppressed or unauthorized.
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -417,8 +417,8 @@ final class FriendController {
!localAuthorID.isEmpty
else { return }
- let ctx = persistence.viewContext
- let pairs: [(pairKey: String, authorID: String)] = ctx.performAndWait {
+ let ctx = persistence.container.newBackgroundContext()
+ let pairs: [(pairKey: String, authorID: String)] = await ctx.perform {
let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
return ((try? ctx.fetch(req)) ?? []).compactMap { friend in
guard let pairKey = friend.pairKey, let authorID = friend.authorID
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -56,8 +56,8 @@ final class GameArchiver {
/// available snapshot.
func archiveIfNeeded(gameID: UUID) async {
let ctx = persistence.container.newBackgroundContext()
- let local: Archive.Snapshot? = ctx.performAndWait {
- guard shouldArchive(gameID: gameID, in: ctx) else { return nil }
+ let local: Archive.Snapshot? = await ctx.perform {
+ guard Self.shouldArchive(gameID: gameID, in: ctx) else { return nil }
return Archive.snapshot(forGameID: gameID, in: ctx)
}
guard let local else { return }
@@ -90,7 +90,7 @@ final class GameArchiver {
// cloud archive already holds every device we'd write — only the
// completeness marker might still need flipping.
if let existing, present.isSubset(of: Set(existing.journal.map(\.key))) {
- if shouldFinalize { markArchived(originalGameID: local.originalGameID) }
+ if shouldFinalize { await markArchived(originalGameID: local.originalGameID) }
return
}
await write(snapshot, markComplete: shouldFinalize)
@@ -104,7 +104,7 @@ final class GameArchiver {
/// best available archive so legacy/incomplete games stop retrying forever.
func reconcileUnarchived() async {
let ctx = persistence.container.newBackgroundContext()
- let ids: [UUID] = ctx.performAndWait {
+ let ids: [UUID] = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
format: "databaseScope == 1 AND completedAt != nil AND archivedAt == nil AND isAccessRevoked == NO"
@@ -123,7 +123,7 @@ final class GameArchiver {
now.timeIntervalSince(completedAt) >= archiveRetryWindow
}
- private nonisolated func shouldArchive(gameID: UUID, in ctx: NSManagedObjectContext) -> Bool {
+ private nonisolated static func shouldArchive(gameID: UUID, in ctx: NSManagedObjectContext) -> Bool {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
@@ -144,7 +144,7 @@ final class GameArchiver {
let package = try Archive.recordPackage(from: snapshot)
try await save(package.record)
removeTemporaryArchiveFiles(package.temporaryAssetFileURLs)
- if markComplete { markArchived(originalGameID: snapshot.originalGameID) }
+ if markComplete { await markArchived(originalGameID: snapshot.originalGameID) }
} catch {
syncMonitor?.recordError("archive game", error)
eventLog?.note(
@@ -183,9 +183,9 @@ final class GameArchiver {
return Archive.payload(from: record)
}
- private func markArchived(originalGameID: UUID) {
+ private func markArchived(originalGameID: UUID) async {
let ctx = persistence.container.newBackgroundContext()
- ctx.performAndWait {
+ await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", originalGameID as CVarArg)
req.fetchLimit = 1
@@ -210,7 +210,7 @@ final class GameArchiver {
/// reached — e.g. a game that completed and was revoked entirely offline.
func promoteRevoked(gameID: UUID) async {
let ctx = persistence.container.newBackgroundContext()
- let local: Archive.Snapshot? = ctx.performAndWait {
+ let local: Archive.Snapshot? = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
@@ -232,7 +232,7 @@ final class GameArchiver {
}
let promoteCtx = persistence.container.newBackgroundContext()
- promoteCtx.performAndWait {
+ await promoteCtx.perform {
// Only retire the revoked original once its replacement exists.
// materialize returns nil when the payload's puzzleSource is empty —
// e.g. a cloud Archive whose puzzleSource CKAsset failed to download