Skip to content

Swift 6: complete concurrency checking #825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 32 commits into
base: develop
Choose a base branch
from
Open

Swift 6: complete concurrency checking #825

wants to merge 32 commits into from

Conversation

laevandus
Copy link
Contributor

@laevandus laevandus commented May 7, 2025

🔗 Issue Links

Resolves: IOS-736

🎯 Goal

  • Set Swift version to 6 and implement complete concurrency checking

📝 Summary

  • Set Swift version to 6 in StreamChatSwiftUI and demo app (includes SWIFT_STRICT_CONCURRENCY = complete)
  • Add Sendable conformance to many types
  • Many protocols and types related to UI get @preconcurrency @MainActor requirement (e.g. view models)
  • Use StreamConcurrency.onMain for jumping on the main actor from controller delegates and completion handlers
  • Upgrade Nuke to version 12.8 (otherwise we can't compile with complete concurrency checking)
    • LazyImage has breaking changes in Nuke (more testing for image scaling and gifs is needed)
  • Patch SwiftyGif for supporting complete concurrency checking (not beautiful)

🛠 Implementation

🎨 Showcase

🧪 Manual Testing Notes

Full manual regression testing round is needed.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

@laevandus laevandus requested a review from a team as a code owner May 7, 2025 12:08
@laevandus laevandus marked this pull request as draft May 7, 2025 12:08
Copy link

github-actions bot commented May 7, 2025

1 Warning
⚠️ Big PR
1 Message
📖 There seems to be app changes but CHANGELOG wasn't modified.
Please include an entry if the PR includes user-facing changes.
You can find it at CHANGELOG.md.

Generated by 🚫 Danger

Comment on lines -36 to -40
replaceDeclaration ' Image?' ' NukeImage?' $f
replaceDeclaration ' Image(' ' NukeImage(' $f
replaceDeclaration 'struct Image:' 'struct NukeImage:' $f
replaceDeclaration 'extension Image {' 'extension NukeImage {' $f
replaceDeclaration 'Content == Image' 'Content == NukeImage' $f
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuke was upgraded to 12.8 (supports Swift 6), some of the types are not there anymore

Comment on lines -32 to +33
static var `default`: Appearance = .init()
nonisolated(unsafe) static var `default`: Appearance = .init()
Copy link
Contributor Author

@laevandus laevandus May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appearance is a class in SwiftUI and its properties are accessible through InjectedValues.
@injected makes it impossible top make the Appearance itself @MainActor because @Injected(\.images) private var images can't ensure main actor isolation when a type/view X is created.
The other option would be to use a lock within Appearance to make sure everything is concurrency safe, but that feels like too much because the type itself is called from main anyway (if not mistakenly calling from background threads).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also fine to keep it like this, it anyway contains only UI stuff - shouldn't be called from a bg thread

@@ -66,7 +66,8 @@ jobs:
build-xcode15:
name: Build SDKs (Xcode 15)
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
#if: ${{ github.event.inputs.record_snapshots != 'true' }}
if: false
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Xcode 15 builds are going away, disabling it for now and a cleanup happens separately

Comment on lines +24 to 27
nonisolated(unsafe)
public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in
Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table)
}
Copy link
Contributor Author

@laevandus laevandus May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative could be making it @MainActor instead of opting out with nonisolated. But then all out generated L10n structs would need to be MainActor as well which will cause some troubles, since main actor isolation is not available everywhere where L10n is used. Probably fine to keep it unsafe for now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine

static let queryIdentifiers = ChannelListQueryIdentifier.allCases.sorted(using: KeyPathComparator(\.title))
static var queryIdentifiers: [ChannelListQueryIdentifier] {
ChannelListQueryIdentifier.allCases.sorted(by: { $0.title < $1.title })
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was giving Sendable related error which did not feel right. Just switched to another sorting method.

@@ -42,7 +42,7 @@ class LoginViewModel: ObservableObject {
return
}

DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DispatchQueue gives Sendable error for self

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we should use Task going forward

@@ -6,7 +6,7 @@ import StreamChat
import SwiftUI

/// View model for the `AddUsersView`.
class AddUsersViewModel: ObservableObject {
@MainActor class AddUsersViewModel: ObservableObject {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since view model factory is MainActor and view models are used from view, then it made more than sense to make all the view models main actor.

guard let self = self else { return }
self.users = self.searchController.userArray
self.loadingNextUsers = false
MainActor.ensureIsolated { [weak self] in
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controllers do not ensure on the compilation level which actor is used. We can use any queue for controller completion handlers although the default is main. Therefore, we need to make sure that main is used. Applies to all the completion handlers and controller delegates.

@@ -172,7 +172,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
loadAdditionalUsers()
}

public func leaveConversationTapped(completion: @escaping () -> Void) {
public func leaveConversationTapped(completion: @escaping @MainActor() -> Void) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For avoiding actor isolation in the view. Better force main on the view model level.

@@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler {

private func searchAllUsers(for typingMention: String) -> Future<SuggestionInfo, Error> {
Future { promise in
nonisolated(unsafe) let unsafePromise = promise
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise is not sendable even when making SuggestionInfo sendable, added a unsafe check here for bypassing it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably refactor this to use async

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can happen in v5, currently the Future type gets exposed with public function:

    public func showSuggestions(
        for command: ComposerCommand
    ) -> Future<SuggestionInfo, Error> {
        showMentionSuggestions(
            for: command.typingSuggestion.text,
            mentionRange: command.typingSuggestion.locationRange
        )
    }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can use async/await under the hood, and just wrap the result in a Future?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anyway, low prio

@@ -22,7 +22,15 @@ public extension ChatClient {
config: config,
workerBuilders: [],
environment: .init(
apiClientBuilder: APIClient_Spy.init,
apiClientBuilder: {
APIClient_Spy(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sendable warning if not written out with all the arguments

@@ -21,7 +21,7 @@ open class StreamChatTestCase: XCTestCase {

public var streamChat: StreamChat?

override open func setUp() {
@MainActor override open func setUp() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many tests want to change main actor isolated types, therefore it is easier to require main actor in a single place

@@ -371,7 +371,7 @@ class MessageListPage {

enum ComposerMentions {
static var cells: XCUIElementQuery {
app.scrollViews["CommandsContainerView"].otherElements.matching(NSPredicate(format: "identifier LIKE 'MessageAvatarView'"))
app.scrollViews["CommandsContainerView"].images.matching(NSPredicate(format: "identifier LIKE 'MessageAvatarView'"))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuke's LazyImage changes causes it

@laevandus laevandus added 🤞 Ready for QA ⏫ Dependencies Update Pull requests that update a dependency file 🪧 Demo App An Issue or PR related to the Demo App ✅ Feature An issue or PR related to a feature labels May 9, 2025
Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few things we should discuss:

  • the usage of assumeIsolated
  • we still use preconcurrency in some VMs
  • some delegate methods are nonisolated

@@ -214,7 +214,7 @@ struct BlurredBackground: View {
}

struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static let defaultValue: CGFloat? = nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this causing issues somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreferenceKey's protocol requires the default value to be get, therefore this is fine to be defined with let over mutable var (which causes concurrency errors).
Docs

}
}
}

// MARK: - ChatUserSearchControllerDelegate

func controller(
nonisolated func controller(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid this being nonisolated in the LLC?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonisolated is required because I made the view model @MainActor which I feel like makes sense to have. Controllers have a design where I can set any queue to be the one used for delegate callbacks. In 99% of cases it is the default main queue (highly unlikely that many set their own queues). But because we support any queue for delegate callbacks, we can't make the delegate protocol @MainActor. We can't use @preconcurrency for the delegate conformance because then we would get runtime crash if anyone change the the default queue in the controller to be a background queue.
Ideally we would use our async-await state layer instead of delegates in the future, but that is out of scope.

Summary is that nonisolated can be avoided if view models are not @MainActor (I currenty changed them to be). That said, in the UIKit implementation view controllers are implementing delegates, so nonisolated is required there because UIViewController is @MainActor (similar situation). Similar issue with completion handlers where the calling thread can be any thread.

@@ -42,7 +42,7 @@ class LoginViewModel: ObservableObject {
return
}

DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we should use Task going forward

if granted {
DispatchQueue.main.async {
Task { @MainActor in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to test this part, I remember having crashes on video in the summer (probably it was Xcode bug, but still)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got the same crash and the recommendation was to make it explicitly @sendable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but customers will also have this - probably we should mention it in an migration guide.

Comment on lines +24 to 27
nonisolated(unsafe)
public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in
Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine

}
if newMembers.count > self.participants.count {
self.participants = newMembers
MainActor.ensureIsolated { [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this crashes if it's not on the main actor, right? Do we really need that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a custom addition (maybe we should rename it to be more clear about it) which makes sure the closure runs on the main thread. Note that one can set any queue to be the one used for completion handers (default is main). This custom addition just checks if we are currently on the main, if yes, just call the closure, otherwise jump on the main thread (since UI updates and MainActor view models require main).

The difference between:
Task { @MainActor in and MainActor.ensureIsolated is that the latter is quicker.

DispatchQueue.global().async {
            print("Simulate API calls and DB updates")
            DispatchQueue.main.async {
                print(Thread.isMainThread)
                Task { @MainActor in
                    print("Task main actor")
                }
                MainActor.ensureIsolated {
                    print("Ensure isolated")
                }
            }
        }

If this snippet runs, ensure isolated is called first.

I did some micro benchmarking just for fun and the difference is insignificant: we are talking about 1-10 microseconds. Therefore, I guess it makes sense to simplify this and get rid of the custom ensureIsolated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed the custom addition to StreamConcurrency.onMain. I tried to get rid of it, but loads of unit-tests will break because using a Task introduces slight delay.

@@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler {

private func searchAllUsers(for typingMention: String) -> Future<SuggestionInfo, Error> {
Future { promise in
nonisolated(unsafe) let unsafePromise = promise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably refactor this to use async

@@ -103,7 +103,12 @@ struct LazyGiphyView: View {
var body: some View {
LazyImage(imageURL: source) { state in
if let imageContainer = state.imageContainer {
NukeImage(imageContainer)
if imageContainer.type == .gif {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we change this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuke dropped their image type and gif rendering support in Nuke 12.0 (migration guide). This is their recommendation how to handle gif rendering.

@@ -119,3 +124,18 @@ struct LazyGiphyView: View {
.aspectRatio(contentMode: .fit)
}
}

private struct AnimatedGifView: UIViewRepresentable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you take this from Nuke or is it a new implementation?

Copy link
Contributor Author

@laevandus laevandus May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This I took from SwiftyGif's documentation as a recommended way for rendering gifs in SwiftUI. Not happy that I needed to touch gif rendering, but it is what it is.

Comment on lines 33 to 34
onDismiss: @escaping @MainActor() -> Void,
onError: @escaping @MainActor(Error) -> Void
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ouch, that's tricky, can we avoid it?

@laevandus laevandus marked this pull request as ready for review May 23, 2025 10:07
Comment on lines +302 to +303
nonisolated(unsafe) let unsafeOnError = onError
nonisolated(unsafe) let unsafeOnFinish = onFinish
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opting out for avoiding breaking changes in the public API (can be fixed properly in v5)

laevandus added 3 commits June 5, 2025 09:29
# Conflicts:
#	.github/workflows/smoke-checks.yml
#	StreamChatSwiftUI.xcodeproj/project.pbxproj
# Conflicts:
#	StreamChatSwiftUI.xcodeproj/project.pbxproj
# Conflicts:
#	StreamChatSwiftUI.xcodeproj/project.pbxproj
#	StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift
Copy link

sonarqubecloud bot commented Jul 7, 2025

Quality Gate Failed Quality Gate failed

Failed conditions
42.6% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-party dependencies have the most changes here - we should thoroughly test it. Performance impact is also something we should check.

if granted {
DispatchQueue.main.async {
Task { @MainActor in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but customers will also have this - probably we should mention it in an migration guide.

@@ -7,7 +7,7 @@ import StreamChat
import SwiftUI

// View model for the `ChatChannelInfoView`.
public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate {
@preconcurrency @MainActor public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, makes sense - we should also test this case directly - to see if there would be any issues.

self?.scrolledId = toJumpId
self?.loadingMessagesAround = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, toJumpId] in
StreamConcurrency.onMain { [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it on main here already?

@@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler {

private func searchAllUsers(for typingMention: String) -> Future<SuggestionInfo, Error> {
Future { promise in
nonisolated(unsafe) let unsafePromise = promise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can use async/await under the hood, and just wrap the result in a Future?

@@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler {

private func searchAllUsers(for typingMention: String) -> Future<SuggestionInfo, Error> {
Future { promise in
nonisolated(unsafe) let unsafePromise = promise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anyway, low prio

///
/// - Important: It is safe to call from any thread. It does not deadlock if we are already on the main thread.
/// - Important: Prefer Task { @MainActor if possible.
static func onMain<T>(_ action: @MainActor @Sendable() throws -> T) rethrows -> T where T: Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we make the one from LLC public and use only that one?

@@ -44,8 +44,8 @@ extension ViewFactory {
public func makeMoreChannelActionsView(
for channel: ChatChannel,
swipedChannelId: Binding<String?>,
onDismiss: @escaping () -> Void,
onError: @escaping (Error) -> Void
onDismiss: @escaping @MainActor() -> Void,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would be a breaking change, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🪧 Demo App An Issue or PR related to the Demo App ⏫ Dependencies Update Pull requests that update a dependency file ✅ Feature An issue or PR related to a feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants