Using Effect.run and receiving on the main thread

Due to Apple's AuthorizationService (Sign In with Apple) stuff using delegation, I wrote a wrapper that uses Combine to push the result of the sign in attempt when the delegation methods are called.

In my TCA reducer, when I receive the authenticate action I call the signIn method on my sign in wrapper, which returns a publisher.

According to the docs, I should be able to do something like:

            return .run { subscriber in
                let values = env.authProvider.signIn().values
                
                for try await value in values {
                    await subscriber.send(.succeeded(value))
                }
            }

I also have some cancellation logic as well, given that I really only care about the first value and not a stream of values here.

This all works, but I get runtime warnings that the UI is being updated off of the main thread, which makes sense. I have tried adding a .receive(on: env.mainQueue) to the signIn() call, just before the values part and that doesn't change anything.

The only thing that does work is the following:

            return .run { subscriber in
                let values = env.authProvider.signIn().values
                
                for try await value in values {
                    await subscriber.send(.succeeded(value))
                }
            }
            .receive(on: env.mainQueue)
            .eraseToEffect()

But that kind of defeats the purpose of using values as suggested in the eraseToEffect() deprecation message.

Is there a definitive way that this should be done?

The argument handed to Effect.run { ... } is automatically isolated to the @MainActor. That is why you have to await when calling send, and therefore it should not be necessary to have to do receive(on:).

Is the warning you get from SwiftUI or from the Composable Architecture? And are there multiple warnings, or just one? Can you post the message(s)?

Thanks. Here's more of the code and the console output.

My AuthReducer's authenticate action:

    case .authenticate:
        state.isAuthenticating = true
        
        return .run { subscriber in
            let values = env.authProvider.createAccount().values
            
            for try await value in values {
                await subscriber.send(.succeeded(value))
            }
        }
        .cancellable(id: AuthCancellationId.self, cancelInFlight: true)

    case .succeeded(let name):
        state.user = Participant.receiver
        state.isAuthenticating = false
        return .cancel(id: AuthCancellationId.self)

This pulls back to my AppReducer, which has the following:

        case .showHome(let user):
            state.homeState = .init(user: user)
            return .none
                        
        case .auth(.succeeded):
            guard let user = state.authState?.user else { return .none }
            
            return .init(value: .showHome(user))

The console output when performing this update is:

2022-09-26 14:54:10.995250-0400 Tower[23266:2159870] [ComposableArchitecture] An effect published an action on a non-main thread. …

  Effect published:
    AppAction.auth(.succeeded)

  Effect returned from:
    AppAction.auth(.authenticate)

Make sure to use ".receive(on:)" on any effects that execute on background threads to receive their output on the main thread.

The "Store" class is not thread-safe, and so all interactions with an instance of "Store" (including all of its scopes and derived view stores) must be done on the main thread.
2022-09-26 14:54:10.995600-0400 Tower[23266:2159870] [ComposableArchitecture] An effect completed on a non-main thread. …

  Effect returned from:
    AppAction.auth(.authenticate)

Make sure to use ".receive(on:)" on any effects that execute on background threads to receive their output on the main thread.

The "Store" class is not thread-safe, and so all interactions with an instance of "Store" (including all of its scopes and derived view stores) must be done on the main thread.
2022-09-26 14:54:10.995881-0400 Tower[23266:2159870] [ComposableArchitecture] An effect published an action on a non-main thread. …

  Effect published:
    AppAction.showHome

  Effect returned from:
    AppAction.auth(.succeeded)

Make sure to use ".receive(on:)" on any effects that execute on background threads to receive their output on the main thread.

The "Store" class is not thread-safe, and so all interactions with an instance of "Store" (including all of its scopes and derived view stores) must be done on the main thread.
2022-09-26 14:54:10.996073-0400 Tower[23266:2159870] [ComposableArchitecture] An effect completed on a non-main thread. …

  Effect returned from:
    AppAction.auth(.succeeded)

Make sure to use ".receive(on:)" on any effects that execute on background threads to receive their output on the main thread.

The "Store" class is not thread-safe, and so all interactions with an instance of "Store" (including all of its scopes and derived view stores) must be done on the main thread.
2022-09-26 14:54:10.997382-0400 Tower[23266:2159870] [ComposableArchitecture] An effect completed on a non-main thread. …

  Effect returned from:
    AppAction.showHome

Make sure to use ".receive(on:)" on any effects that execute on background threads to receive their output on the main thread.

The "Store" class is not thread-safe, and so all interactions with an instance of "Store" (including all of its scopes and derived view stores) must be done on the main thread.
2022-09-26 14:54:10.997531-0400 Tower[23266:2159870] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
2022-09-26 14:54:11.008838-0400 Tower[23266:2159870] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

Very strange. This shouldn't make a difference but let's try it anyway... can you try making this closure as @MainActor?

return .run { @MainActor subscriber in
  ...
}

That worked, however it did require one other change. By marking it @MainActor I had to remove the inner await in the loop, so it now looks like this:

        return .run { @MainActor subscriber in
            let values = env.authProvider.createAccount().values
            
            for try await value in values {
                subscriber.send(.succeeded(value))
            }
        }
        .cancellable(id: AuthCancellationId.self, cancelInFlight: true)

Not sure if there's any potential issues by removing that (I'm not well-versed in actors... yet.) but so far it's working.

One somewhat related question: for the Sign In with Apple stuff and delegation, is using Combine like this and then cancelling in the reducer something that makes sense? I can't think of another way to do this that isn't closure-based.

It is expected that you no longer have to await for sending now that the whole closure is marked as @MainActor, but it is troubling that doing that fixed things. Making send main actor should be enough.

What does createAccount() look like?

Im happy to share the project. It's a hobby project that Im putting together so if you'd like to take a closer look at the reducers, maybe there's something I did that I'm not sure I need to mention. I don't think so though.

As for the createAccount method:

public protocol IdentityProvider {
    func createAccount() -> AnyPublisher<String, Error>
    func login() -> AnyPublisher<String, Error>
    func twoFactor() -> AnyPublisher<String, Error>
}

public class AppleIdentityProvider: NSObject, IdentityProvider {
    private var createAccountSubject = PassthroughSubject<String, Error>()
    private var loginSubject = PassthroughSubject<String, Error>()
    private var twoFactorSubjust = PassthroughSubject<String, Error>()
    
    public func createAccount() -> AnyPublisher<String, Error> {
        return createAccountSubject
            .handleEvents(receiveSubscription: { [weak self] _ in
                let provider = ASAuthorizationAppleIDProvider()
                let request = provider.createRequest()
                
                request.requestedScopes = [.fullName, .email]
                
                let controller = ASAuthorizationController(authorizationRequests: [request])
                controller.delegate = self
                controller.performRequests()
            })
            .eraseToAnyPublisher()
    }
}

extension AppleIdentityProvider: ASAuthorizationControllerDelegate {
    public func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        switch authorization.credential {
        case let appleIDCredential as ASAuthorizationAppleIDCredential:
            let fullName = appleIDCredential.fullName
            let name = (fullName?.givenName ?? "") + (" ") + (fullName?.familyName ?? "")
            
            createAccountSubject.send(name)
            
        default:
            print("Received something other than an Apple ID Credential...weird...")
        }
    }
    
    public func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        print(error.localizedDescription)
    }
}

I stripped some unused methods for brevity.

I really appreciate it. :bowing_man:

Ok, my guess is the problem is inside createAccount. That is called on a background thread (because Effect.run is run in the cooperative pool), and so handleEvents is called on a background thread, and that would explain why marking the run closure with @MainActor fixed things. You might be able to tack on a .receive(on: DispatchQueue.main) before handleEvents to fix things and then you could remove @MainActor in your reducer.

But also, more generally, this may be a precarious way to structure things. The way it is now, if you ever call createAccount twice then you run the risk of requesting multiple authorizations every time createAccountSubject emits.

It's hard for me to recommend the correct way of doing this, but it may be worth thinking of other ways sometime soon.

First, yeah I'm really not a fan of how this is structured. I've been trying to figure out less awkward ways but the delegating back to the auth provider makes it difficult to do anything "modern". Gonna keep digging into it and see what I can come up with.

Second, I did try the following, but had the same warnings in the console.

    public func createAccount() -> AnyPublisher<String, Error> {
        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()
        
        request.requestedScopes = [.fullName, .email]
        
        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.performRequests()
        
        return createAccountSubject.eraseToAnyPublisher()
    }

That has any logic moved out of the subscription and just does it more "blindly", but still see the warnings.

The only thing working for me so far is the @MainActor mark.

Yeah that code would have the same problem because createAccount is called on a background thread. If you put a breakpoint in it or do print(Thread.current) you will see that. You need to make sure that all of the Apple ID APIs are only called on the main thread.

Gotcha. Sounds good. Thanks for all of your help. Happy to know the issue is in my code, as at least that I can fix with enough tinkering.

All the best!

For anyone else that might be experiencing this / want a better solution than using Combine, you can actually use async/await if you introduce your own wrapper that leverages Continuations.

My entire SIWA wrapper now looks like this and the original issue is no longer presenting.

public final class AppleIdentityProvider: NSObject, IdentityProvider {
    private typealias AppleIdentityCheckedThrowingContinuation = CheckedContinuation<String, Error>
    
    private var checkedThrowingContinuation: AppleIdentityCheckedThrowingContinuation?
    
    public func createAccount() async throws -> String {
        return try await withCheckedThrowingContinuation({ (continuation: AppleIdentityCheckedThrowingContinuation) in
            checkedThrowingContinuation = continuation
            
            let provider = ASAuthorizationAppleIDProvider()
            let request = provider.createRequest()
            
            request.requestedScopes = [.fullName, .email]
            
            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.performRequests()
        })
    }
    
    public func signIn() async throws -> String {
       // ...
    }
    
    public func twoFactor() async throws -> String {
        // ...
    }
}

extension AppleIdentityProvider: ASAuthorizationControllerDelegate {
    public func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        switch authorization.credential {
        case let appleIDCredential as ASAuthorizationAppleIDCredential:
            let userIdentifier = appleIDCredential.user
            
            checkedThrowingContinuation?.resume(returning: userIdentifier)
            checkedThrowingContinuation = nil
            
        default:
            checkedThrowingContinuation?.resume(throwing: IdentityError.invalidProvider)
            checkedThrowingContinuation = nil
        }
    }
    
    public func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        checkedThrowingContinuation?.resume(throwing: error)
        checkedThrowingContinuation = nil
    }
}

One thing to note is that checked/unsafe continuations do not participate in cooperative cancellation, so if you want this async work to be cancellable from the outside, you should use a AsyncThrowingStream<Never, Error>'s completion, instead.

Hmm, that actually brings up an interesting point. Is it better to have a stream that you cancel out of? I only ever expect to use the first value that is returned, but what if I want to cancel the in-flight call for some reason?

Though I can't picture a scenario where I would need to cancel an in-flight call, and a cancel from Apple's Auth Service SDK throws an error to the Continuation, I would definitely love to understand this better so that I know when to implement this.