ASAuthorizationAppleIDProvider() hang in async method

I have a bit of a tricky severe hang in my app launch processing code path.

Here is the detail:

I have a .task modifier from the main ContentView that calls into the signInWithAppleManager.checkUserAuth method, which is marked async.

I've tried wrapping the offending line in a Task block to get it off of the main thread, but it still hangs, and is still running on the main thread. Ironically, I found the hang after watching "Analyze Hangs With Instruments" from WWDC 23. However, at the point in the video towards the end where he discusses shared singletons, he mentions resolving a similar issue by making the shared singleton async, and then skips over how he would do it, kind of seemingly presenting a gap in analysis and debugging, while also explaining idle state ... kind of more irony.

Thanks in advance!

            Task {
                let appleIDProvider = ASAuthorizationAppleIDProvider()

Is there anything else that I can do to resolve this?

Here is the code:

public class SignInWithAppleManager: ObservableObject {
    @Published public private(set) var userAuthenticationState: AuthState = .undefined
    public static let shared = SignInWithAppleManager()
    private init() { }
    
    func signOutUser() async {
        KeychainItem.deleteUserIdentifierFromKeychain()
        await SignInWithAppleManager.shared.updateUserAuthenticationState(authState: .signedOut)
    }
    
    @MainActor
    func userAuthenticated() async {
        self.userAuthenticationState = .signedIn
    }
    
    @MainActor
    func userSignedOut() async {
        self.userAuthenticationState = .undefined
    }
    
    func simulateAuthenticated() async -> Bool {
        return false
    }
    
    public var isAuthenticated: Bool {
        return self.userAuthenticationState == .signedIn
    }
    
    @MainActor
    func updateUserAuthenticationState(authState: AuthState) async {
        debugPrint("Current authstate: \(self.userAuthenticationState) New auth state: \(authState)")
        self.userAuthenticationState = authState
    }
    
    public func checkUserAuth() async -> AuthState {
        debugPrint(#function)
        //completion handler defines authstate
        if KeychainItem.currentUserIdentifier == "" || KeychainItem.currentUserIdentifier == "simulator" {
            debugPrint("User identifier is empty string")
            await updateUserAuthenticationState(authState: .undefined)
            //userid is not defined in User defaults bc empty, something went wrong
        } else {
            await updateUserAuthenticationState(authState: .signedIn)
        }
        if await !self.simulateAuthenticated() {
 // HERE: ‼️ hangs for 2 seconds
            let appleIDProvider = ASAuthorizationAppleIDProvider() // HERE: ‼️ hangs for 2 seconds
            do {
                let credentialState = try await appleIDProvider.credentialState(forUserID: KeychainItem.currentUserIdentifier)
                switch credentialState {
                    case .authorized:
                        debugPrint("checkUserAuth:authorized")
                        // The Apple ID credential is valid. Show Home UI Here
                        await updateUserAuthenticationState(authState: .signedIn)
                        break
                    case .revoked:
                        debugPrint("checkUserAuth:revoked")
                        // The Apple ID credential is revoked. Show SignIn UI Here.
                        await updateUserAuthenticationState(authState: .undefined)
                        break
                    case .notFound:
                        debugPrint("checkUserAuth:notFound")
                        // No credential was found. Show SignIn UI Here.
                        await updateUserAuthenticationState(authState: .signedOut)
                        break
                    default:
                        debugPrint("checkUserAuth:undefined")
                        await updateUserAuthenticationState(authState: .undefined)
                        break
                }
            } catch {
                // Handle error
                debugPrint("checkUserAuth:error")
                debugPrint(error.localizedDescription)
                await updateUserAuthenticationState(authState: .undefined)
            }
        }
        return self.userAuthenticationState
    }
}

Hi cyclic,

you are right, the video only hints at the solution in the 2nd case but doesn't really explain how to do it. This was a trade-off between keeping the video a reasonable length and covering all possible scenarios. But happy to discuss it here.

A couple of things for your code:

I've tried wrapping the offending line in a Task block to get it off of the main thread, but it still hangs, and is still running on the main thread.

The "Analyze Hangs with Instruments" session explains why this doesn't work when discussing the .task{} modifier. Both Task {} and .task {} inherit the surrounding actor isolation, so if you are already on the main actor wrapping it in a Task {} doesn't help. If that isn't clear, let me know and I can try to explain it in more detail.

The part that surprises me about your code is that checkUserAuth runs on the main actor at all. Looking at your code, there is no explanation why that would happen, so I assume this is only an excerpt of the complete type. I'll add some guesses as to what's going on below, but first let's talk about the callsite:

I have a .task modifier from the main ContentView that calls into the signInWithAppleManager.checkUserAuth method, which is marked async.

Based on this, I assume you code looks something like this:

import SwiftUI
struct ContentView: View {
    var body: some View {
        viewStuff
            // [...] more implementation
            .task {
                let resultState = await SignInWithAppleManager.shared.checkUserAuth()
            }
    }
}

The await in front of checkUserAuth() above is important. If your code compiles without the await, something isn't right.

But I assume your code looks something like this. I also assume it was compiled with Swift 5.7 (released last year)or later (only since then are non-isolated async methods like checkUserAuth() executed on the concurrent thread pool by default).

If that's the case, the only explanation I can think of why checkUserAuth() executes on the main thread is that it somehow inherited @MainActor. As the containing type, SignInWithAppleManager, implements only one protocol and doesn't inherit from any other classes, and that one protocol (ObservableObject) does not declare the method checkUserAuth(), the "main-actor-ness" does not seem to come from the declaration of the method (here or in a superclass or protocol). That means the only way checkUserAuth() can be main-actor-isolated is by inheriting the isolation of its containing type, meaning that SignInWithAppleManager must be @MainActor somehow.

This is where it gets tricky. There is one other case that the session video skipped, as it would have taken too much time to explain in the video. The rules about all the ways actor-isolation can be inherited are complex. The detailed rules are here: SE-0316 - Global Actors - Global actor inference

I suspect, that the following rule is hitting you:

A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:

@propertyWrapper
struct UIUpdating<Wrapped> {
  @MainActor var wrappedValue: Wrapped
}

struct CounterView { // infers @MainActor from use of @UIUpdating
  @UIUpdating var intValue: Int = 0
}

So I suspect, that your SignInWithAppleManager has more properties that you didn't list here, one of which uses a main-actor-isolated property wrapper, like e.g. @StateObject or @Query, which would in turn isolate the overall type (SignInWithAppleManager) to the main actor and thus make all of its methods execute on the main actor, including checkUserAuth().

If that isn't the case for you, please let me know and we can take another look to figure out what's going on, but below I'm assuming that is the case and your type is (unintentionally?) @MainActor and discuss what to do now.

Get off of the main actor

To be able to execute off of the main actor and on the concurrent thread pool, a method needs to be async and "non-main-actor-isolated". Your method is already async but seems to be accidentally main-actor-isolated. The easiest way to fix this is to mark it explicitly nonisolated. This will override the default actor-isolation inference.

Like this:

    nonisolated public func checkUserAuth() async -> AuthState {
        // exisiting implementation can stay the same
    }

Other ways are:

  • Figure out why your type is @MainActor even if you didn't intend for it to be
  • Move the method to a non-@MainActor type
  • If you don't need to wait on the results, you can also wrap the contents of the methods in a Task.detached, but this doesn't seem to be applicable in your case and should only be used when there is no better option. Marking the method nonisolated should be enough for your case.

Make a shared singleton property async

Note: This shouldn't be necessary anymore in your case as the proper fix is likely marking your checkUserAuth() method nonisolated which won cause it to execute on the concurrent thread pool in which case it is fine for ASAuthorizationAppleIDProvider() or whatever other method to run for 2 seconds.

But if you do need it for another case, here is how you do it: Suppose you have a singleton like this:

class MySingleton {
    public static let shared = MySingleton()
}

then you can make the property async like this:

class MySingleton {
    private static let sharedStorage = MySingleton()
    public static var shared: MySingleton {
        get async {
            return sharedStorage
        }
    }
}
ASAuthorizationAppleIDProvider() hang in async method
 
 
Q