crossmate

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

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:
MCrossmate/Services/InviteCoordinator.swift | 21+++++++++++----------
MCrossmate/Sync/FriendController.swift | 4++--
MCrossmate/Sync/GameArchiver.swift | 20++++++++++----------
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