Even more strict concurrency fixes

This commit is contained in:
Shadowfacts 2024-01-27 15:48:58 -05:00
parent fc26c9fb54
commit 27d44340e8
24 changed files with 179 additions and 119 deletions

View File

@ -7,7 +7,7 @@
import Foundation
public struct StatusSource: Decodable {
public struct StatusSource: Decodable, Sendable {
public let id: String
public let text: String
public let spoilerText: String

View File

@ -49,7 +49,7 @@ public class InstanceSelector {
}
public extension InstanceSelector {
struct Instance: Codable {
struct Instance: Codable, Sendable {
public let domain: String
public let description: String
public let proxiedThumbnailURL: URL

View File

@ -51,6 +51,7 @@ class MastodonController: ObservableObject {
let instanceURL: URL
let accountInfo: UserAccountInfo?
@MainActor
private(set) var accountPreferences: AccountPreferences!
private(set) var client: Client!
@ -271,17 +272,6 @@ class MastodonController: ObservableObject {
}
}
func getOwnAccount(completion: (@Sendable (Result<AccountMO, any Error>) -> Void)? = nil) {
Task.detached {
do {
let account = try await self.getOwnAccount()
completion?(.success(account))
} catch {
completion?(.failure(error))
}
}
}
@MainActor
func getOwnAccount() async throws -> AccountMO {
if let account {
@ -307,15 +297,8 @@ class MastodonController: ObservableObject {
return MainThreadBox(value: accountMO)
}
}
}
if let account = account {
return account
} else {
return try await withCheckedThrowingContinuation({ continuation in
self.getOwnAccount { result in
continuation.resume(with: result)
}
})
fetchOwnAccountTask = task
return try await task.value.value
}
}

View File

@ -550,14 +550,19 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
}
}
}
// Can't capture vars in concurrently-executing closure
let hashtags = changedHashtags
let instances = changedInstances
let timelinePositions = changedTimelinePositions
let accountPrefs = changedAccountPrefs
DispatchQueue.main.async {
if changedHashtags {
if hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if changedInstances {
if instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
for id in changedTimelinePositions {
for id in timelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
@ -565,7 +570,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if changedAccountPrefs {
if accountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
}
}

View File

@ -30,6 +30,7 @@ extension StatusSwipeAction {
}
}
@MainActor
protocol StatusSwipeActionContainer: UIView {
var mastodonController: MastodonController! { get }
var navigationDelegate: any TuskerNavigationDelegate { get }

View File

@ -296,7 +296,7 @@ extension ConversationCollectionViewController {
case mainStatus
case childThread(firstStatusID: String)
}
enum Item: Hashable {
enum Item: Hashable, Sendable {
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator
@ -306,7 +306,7 @@ extension ConversationCollectionViewController {
case let (.status(id: a, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
return a == b && aPrev == bPrev && aNext == bNext
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
return a.count == b.count && zip(a, b).allSatisfy { $0.statusID == $1.statusID } && aInline == bInline
case (.loadingIndicator, .loadingIndicator):
return true
default:
@ -324,7 +324,7 @@ extension ConversationCollectionViewController {
case .expandThread(childThreads: let childThreads, inline: let inline):
hasher.combine(1)
for thread in childThreads {
hasher.combine(thread.status.id)
hasher.combine(thread.statusID)
}
hasher.combine(inline)
case .loadingIndicator:

View File

@ -9,15 +9,20 @@
import Foundation
import Pachyderm
@MainActor
class ConversationNode {
let statusID: String
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.statusID = status.id
self.status = status
self.children = []
}
}
@MainActor
struct ConversationTree {
let ancestors: [ConversationNode]
let mainStatus: ConversationNode

View File

@ -194,7 +194,7 @@ class ConversationViewController: UIViewController {
if let cached = mastodonController.persistentContainer.status(for: mainStatusID) {
// if we have a cached copy, display it immediately but still try to refresh it
Task {
await doLoadMainStatus()
_ = await doLoadMainStatus()
}
mainStatusLoaded(cached)
} else {

View File

@ -20,8 +20,11 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
var account: Account?
private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
private var accountImagesTask: Task<Void, Never>?
deinit {
accountImagesTask?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
@ -62,37 +65,35 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
noteTextView.setEmojis(account.emojis, identifier: account.id)
avatarImageView.image = nil
if let avatar = account.avatar {
avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
defer {
self?.avatarRequest = nil
}
guard let self = self,
let image = image,
self.account?.id == account.id else {
headerImageView.image = nil
accountImagesTask?.cancel()
accountImagesTask = Task {
await updateImages(account: account)
}
}
private nonisolated func updateImages(account: Account) async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
guard let avatar = account.avatar,
let image = await ImageCache.avatars.get(avatar).1 else {
return
}
DispatchQueue.main.async {
await MainActor.run {
self.avatarImageView.image = image
}
}
}
headerImageView.image = nil
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
defer {
self?.headerRequest = nil
}
guard let self = self,
let image = image,
self.account?.id == account.id else {
group.addTask {
guard let header = account.header,
let image = await ImageCache.headers.get(header).1 else {
return
}
DispatchQueue.main.async {
await MainActor.run {
self.headerImageView.image = image
}
}
await group.waitForAll()
}
}

View File

@ -95,7 +95,9 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
do {
let request = Client.getSuggestions(limit: 80)
@ -108,7 +110,9 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) })
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
state = .loaded
} catch {

View File

@ -107,7 +107,9 @@ class TrendingHashtagsViewController: UIViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.trendingTags])
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
do {
let request = self.request(offset: nil)
@ -115,10 +117,14 @@ class TrendingHashtagsViewController: UIViewController {
snapshot.deleteItems([.loadingIndicator])
snapshot.appendItems(hashtags.map { .tag($0) })
state = .loaded
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
} catch {
snapshot.deleteItems([.loadingIndicator])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Trending Tags", in: self) { [weak self] toast in
@ -140,7 +146,9 @@ class TrendingHashtagsViewController: UIViewController {
var snapshot = origSnapshot
if Preferences.shared.disableInfiniteScrolling {
snapshot.appendItems([.confirmLoadMore(false)])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
for await _ in confirmLoadMore.values {
break
@ -148,10 +156,14 @@ class TrendingHashtagsViewController: UIViewController {
snapshot.deleteItems([.confirmLoadMore(false)])
snapshot.appendItems([.confirmLoadMore(true)])
await dataSource.apply(snapshot, animatingDifferences: false)
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: false)
}
} else {
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
}
do {
@ -159,9 +171,13 @@ class TrendingHashtagsViewController: UIViewController {
let (hashtags, _) = try await mastodonController.run(request)
var snapshot = origSnapshot
snapshot.appendItems(hashtags.map { .tag($0) })
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
} catch {
await dataSource.apply(origSnapshot)
await MainActor.run {
dataSource.apply(origSnapshot)
}
let config = ToastConfiguration(from: error, with: "Error Loading More Tags", in: self) { [weak self] toast in
toast.dismissToast(animated: true)

View File

@ -128,7 +128,9 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
do {
let request = Client.getTrendingLinks()
@ -137,9 +139,13 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
snapshot.appendSections([.links])
snapshot.appendItems(links.map { .link($0) })
state = .loaded
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
} catch {
await dataSource.apply(NSDiffableDataSourceSnapshot())
await MainActor.run {
dataSource.apply(NSDiffableDataSourceSnapshot())
}
state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Trending Links", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
@ -161,7 +167,9 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
if Preferences.shared.disableInfiniteScrolling {
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.confirmLoadMore(false)], toSection: .loadingIndicator)
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
for await _ in confirmLoadMore.values {
break
@ -169,11 +177,15 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
snapshot.deleteItems([.confirmLoadMore(false)])
snapshot.appendItems([.confirmLoadMore(true)], toSection: .loadingIndicator)
await dataSource.apply(snapshot, animatingDifferences: false)
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: false)
}
} else {
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator)
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
}
do {
@ -181,9 +193,13 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
let (links, _) = try await mastodonController.run(request)
var snapshot = origSnapshot
snapshot.appendItems(links.map { .link($0) }, toSection: .links)
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
} catch {
await dataSource.apply(origSnapshot)
await MainActor.run {
dataSource.apply(origSnapshot)
}
let config = ToastConfiguration(from: error, with: "Erorr Loading More Links", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadOlder()

View File

@ -126,7 +126,9 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
} catch {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
toast.dismissToast(animated: true)
await self.loadTrendingStatuses()
@ -138,7 +140,9 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) })
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {

View File

@ -238,7 +238,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
}
isShowingTrends = shouldShowTrends
guard shouldShowTrends else {
await dataSource.apply(NSDiffableDataSourceSnapshot())
await apply(snapshot: NSDiffableDataSourceSnapshot())
return
}
@ -355,9 +355,9 @@ class TrendsViewController: UIViewController, CollectionViewController {
}
private func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool = true) async {
await Task { @MainActor in
await MainActor.run {
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}.value
}
}
@MainActor

View File

@ -49,7 +49,7 @@ class FastSwitchingAccountView: UIView {
private let instanceLabel = UILabel()
private let avatarImageView = UIImageView()
private var avatarRequest: ImageCache.Request?
private var avatarTask: Task<Void, Never>?
init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
self.orientation = orientation
@ -69,6 +69,10 @@ class FastSwitchingAccountView: UIView {
fatalError("init(coder:) has not been implemented")
}
deinit {
avatarTask?.cancel()
}
private func commonInit() {
usernameLabel.textColor = .white
usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline), size: 0)
@ -133,16 +137,13 @@ class FastSwitchingAccountView: UIView {
instanceLabel.text = account.instanceURL.host!
}
let controller = MastodonController.getForAccount(account)
controller.getOwnAccount { [weak self] (result) in
guard let self = self,
case let .success(account) = result,
let avatar = account.avatar else { return }
self.avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
guard let self = self, let image = image else { return }
DispatchQueue.main.async {
self.avatarImageView.image = image
}
avatarTask = Task {
guard let account = try? await controller.getOwnAccount(),
let avatar = account.avatar,
let image = await ImageCache.avatars.get(avatar).1 else {
return
}
self.avatarImageView.image = image
}
accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)"

View File

@ -105,8 +105,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
embedChild(loadingVC!)
imageRequest = cache.get(url, loadOriginal: true) { [weak self] (data, image) in
guard let self = self, let image = image else { return }
self.imageRequest = nil
DispatchQueue.main.async {
self.imageRequest = nil
self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data, image: image, url: self.url)
}

View File

@ -195,7 +195,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
do {
let (accounts, pagination) = try await results
@ -210,7 +212,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems(accounts.map { .account(id: $0.id) })
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
state = .loaded
} catch {
@ -221,7 +225,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
self.showToast(configuration: config, animated: true)
state = .unloaded
await dataSource.apply(.init())
await MainActor.run {
dataSource.apply(.init())
}
}
}
@ -236,7 +242,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
let origSnapshot = dataSource.snapshot()
var snapshot = origSnapshot
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
do {
let (accounts, pagination) = try await results
@ -250,7 +258,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
var snapshot = origSnapshot
snapshot.appendItems(accounts.map { .account(id: $0.id) })
await dataSource.apply(snapshot)
await MainActor.run {
dataSource.apply(snapshot)
}
state = .loaded
} catch {
@ -261,7 +271,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
self.showToast(configuration: config, animated: true)
state = .loaded
await dataSource.apply(origSnapshot)
await MainActor.run {
dataSource.apply(origSnapshot)
}
}
}

View File

@ -273,8 +273,11 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
}
}
private func dismissNotificationsInGroup(at indexPath: IndexPath) async {
guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else {
private nonisolated func dismissNotificationsInGroup(at indexPath: IndexPath) async {
let item = await MainActor.run {
dataSource.itemIdentifier(for: indexPath)
}
guard case .group(let group, let collapseState, let filterState) = item else {
return
}
let notifications = group.notifications
@ -295,7 +298,9 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
}
})
}
var snapshot = dataSource.snapshot()
var snapshot = await MainActor.run {
dataSource.snapshot()
}
if dismissFailedIndices.isEmpty {
snapshot.deleteItems([.group(group, collapseState, filterState)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {

View File

@ -311,7 +311,7 @@ extension InstanceSelectorTableViewController {
case selected
case recommendedInstances
}
enum Item: Equatable, Hashable {
enum Item: Equatable, Hashable, Sendable {
case selected(URL, InstanceV1)
case recommended(InstanceSelector.Instance)

View File

@ -32,20 +32,19 @@ struct LocalAccountAvatarView: View {
.resizable()
.frame(width: 30, height: 30)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
.onAppear(perform: self.loadImage)
.task {
await self.loadImage()
}
}
func loadImage() {
func loadImage() async {
let controller = MastodonController.getForAccount(localAccountInfo)
controller.getOwnAccount { (result) in
guard case let .success(account) = result,
let avatar = account.avatar else { return }
_ = ImageCache.avatars.get(avatar) { (_, image) in
DispatchQueue.main.async {
self.avatarImage = image
}
}
guard let account = try? await controller.getOwnAccount(),
let avatar = account.avatar,
let image = await ImageCache.avatars.get(avatar).1 else {
return
}
self.avatarImage = image
}
}

View File

@ -18,13 +18,12 @@ class MyProfileViewController: ProfileViewController {
title = "My Profile"
tabBarItem.image = UIImage(systemName: "person.fill")
mastodonController.getOwnAccount { (result) in
guard case let .success(account) = result else { return }
DispatchQueue.main.async {
self.accountID = account.id
self.setAvatarTabBarImage(account: account)
Task {
guard let account = try? await mastodonController.getOwnAccount() else {
return
}
self.accountID = account.id
self.setAvatarTabBarImage(account: account)
}
}

View File

@ -18,12 +18,14 @@ protocol MenuActionProvider: AnyObject {
var toastableViewController: ToastableViewController? { get }
}
@MainActor
protocol MenuPreviewProvider: AnyObject {
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders?
}
@MainActor
protocol CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController)
}
@ -478,7 +480,7 @@ extension MenuActionProvider {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
Task { @MainActor in
if let relationship = await relationship.value,
if let relationship = await relationship.value?.value,
let action = builder(relationship, mastodonController) {
elementHandler([action])
} else {
@ -612,20 +614,25 @@ extension MenuActionProvider {
}
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> MainThreadBox<RelationshipMO>? {
let req = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(req),
let r = relationships.first else {
return nil
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
continuation.resume(returning: mo)
DispatchQueue.main.async {
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
continuation.resume(returning: MainThreadBox(value: mo))
}
}
}
}
struct MenuPreviewHelper {
private init() {}
@MainActor
static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop

View File

@ -54,6 +54,7 @@ enum AppShortcutItem: String, CaseIterable {
}
extension AppShortcutItem {
@MainActor
static func createItems(for application: UIApplication) {
application.shortcutItems = allCases.map {
return UIApplicationShortcutItem(type: $0.rawValue, localizedTitle: $0.title, localizedSubtitle: nil, icon: $0.icon, userInfo: nil)

View File

@ -10,6 +10,7 @@ import Foundation
import OSLog
import Combine
@MainActor
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem: Sendable
@ -217,7 +218,7 @@ class TimelineLikeController<Item: Sendable> {
}
}
enum State: Equatable, CustomDebugStringConvertible {
enum State: Equatable, CustomDebugStringConvertible, Sendable {
case notLoadedInitial
case idle
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
@ -360,7 +361,7 @@ class TimelineLikeController<Item: Sendable> {
}
}
class LoadAttemptToken: Equatable {
final class LoadAttemptToken: Equatable, Sendable {
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
return lhs === rhs
}