Request authorization for the notification center crash iOS app on Swift 6

Hey all!

During the migration of a production app to swift 6, I've encountered a problem: when hitting the UNUserNotificationCenter.current().requestAuthorization the app crashes.

If I switch back to Language Version 5 the app works as expected.

The offending code is defined here

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        FirebaseApp.configure()
        FirebaseConfiguration.shared.setLoggerLevel(.min)

        UNUserNotificationCenter.current().delegate = self

        let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
        UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in }

        application.registerForRemoteNotifications()

        Messaging.messaging().delegate = self

        return true
    }
}

The error is depicted here: I have no idea how to fix this.

Any help will be really appreciated

thanks in advance

Answered by DTS Engineer in 807248022
I’m gonna do some more research about this (FB15294185)

I spent some time talking this over with various colleagues, just to make sure I fully understand what’s going on. The high-level summary is:

  • Swift 6 has inserted a run-time check to catch a concurrency issue that Swift 5 did not.

  • This isn’t caught at compile time because of a Swift / Objective-C impedance mismatch.

If you want to know more, follow me down the rabbit hole!


Consider this simplified version of Artecoop’s test code:

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    
    func applicationDidFinishLaunching(_ application: UIApplication) {
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .sound, .badge]) { success, error in
            print(success)
            assert(Thread.isMainThread)
        }
    }
}

The entire did-finish-launching method should run on the main actor. That’s because:

  • The method itself is part of the implementation of the UIApplicationDelegate protocol, and that’s declared @MainActor.

  • The requestAuthorization(…) method doesn’t do anything to tell the Swift compiler that it will call the completion handler on a secondary thread (more on that below).

If you build this program in Swift 6 mode, on running it you trap before the print(…) call, in a main actor check inserted by the Swift compiler. That’s the central complaint of the original post.

However, consider what happens if you build this in Swift 5 mode. This time it traps in the assert(…). So this trap is clearly an improvement: Swift 6 mode is consistently detecting a problem that could otherwise cause a data race.


However, that explanation leaves a number of unanswered questions. Let’s start with fixes.

The fix I described above, calling the async version of the method, works because the Swift compiler implements the async method with code that kinda looks something like this:

extension UNUserNotificationCenter {

    func requestNotificationQQQ1(
        options: UNAuthorizationOptions
    ) async throws -> Bool {
        try await withCheckedThrowingContinuation { cont in
            self.requestAuthorization(options: options) { success, error in
                if success {
                    cont.resume(returning: true)
                } else {
                    cont.resume(throwing: error!)
                }
            }
        }
    }
}

In this case the completion handler isn’t bound to the main actor and thus the compiler doesn’t add an assert. And the CheckedContinuation type is thread safe, so calling its methods from any thread is fine.

The other fix is to make the closure as @Sendable:

center.requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable success, error in
    print(success)
    // print(self.window)
}

This disconnects the closure from the main actor and thus there’s no main actor check. However, it also means that you can’t access main actor state in the closure. If you uncomment the access to self.window, the compiler complains that Main actor-isolated property 'window' can not be referenced from a Sendable closure.


Finally, let’s come back to why the compiler doesn’t know that the completion handler can be called on any thread. That’s tied to the Objective-C declaration, which is imported into Swift as:

func requestAuthorization(
    options: UNAuthorizationOptions = [],
    completionHandler: @escaping (Bool, (any Error)?) -> Void
)

Imagine you wrote your own (very bad :-) [1] version of this in Swift:

extension UNUserNotificationCenter {

    func requestAuthorizationQQQ2(
        options: UNAuthorizationOptions = [],
        completionHandler: @escaping (Bool, (any Error)?) -> Void
    ) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
            completionHandler(true, nil)
         // ^ Capture of 'completionHandler' with non-sendable type '(Bool, (any Error)?) -> Void' in a `@Sendable` closure
        }
    }
}

The compiler complains that the closure is being sent across isolation domains even though it’s not sendable. That’s obviously bad.

The thing to note here is that this is exactly what Objective-C is doing, and it’s why you’re running into the problem.

The most straightforward way to fix the requestAuthorizationQQQ2(…) method is to replace @escaping with @Sendable. And the equivalent of doing that in Objective-C is to add NS_SWIFT_SENDABLE to the completion handler parameter of the -requestAuthorizationWithOptions:completionHandler: method [2].

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] To start, I’m not following my own advice here: Avoid Dispatch Global Concurrent Queues

[2] It wouldn’t surprise me if that were the final resolution of FB15294185, but that’s still in The Future™.

This crashes all the time, right?

If so, please generate a crash report and post it here. See Posting a Crash Report for advice on how to do that.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Here the .ips crash report, with .txt extension as suggested in your post.

Thanks for the crash report.

Consider this snippet:

@MainActor var counter = 0

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions …) -> Bool {
        counter += 1
        let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
        UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in
            counter += 1
       }
        return true
    }
    …
}

I’m compiling this with Xcode 16.0 in the Swift 6 language mode.

The increments of counter show that Swift thinks that both application(_:didFinishLaunchingWithOptions:) and the closure are supposed to be running on the main actor. However, the closure called by requestAuthorization(options:completionHandler:) is documented to not run there. I’d expect that the compiler would insert code to ‘bounce’ to the main actor, but instead it inserted code to trap if it’s not on the main actor.

I’m not sure why it thinks that in this context. Oh, and I checked with the latest Xcode 16.1 beta, and it has the same issue.

I’m gonna do some more research about this (FB15294185) but, for the moment, you can avoid the crash by calling the Swift async function variant of the API:

let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
Task {
    do {
        _ = try await UNUserNotificationCenter.current().requestAuthorization(options: authOptions)
        print("here")
    } catch {
        print("there")
    }
}

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I can confirm that your workaround is working and the app is not crashing anymore. I'll check for future resolution of the problem.

Thanks Quinn!

FWIW, I was experiencing the same phenomenon (crashes related to multi threading issues) in a few other places:

  • LAContext.evaluatePolicy(_:localizedReason:reply:)
  • UNUserNotificationCenter.getNotificationSettings(completionHandler:)
  • UNUserNotificationCenter.add(_:withCompletionHandler:)

In all of these cases, the workaround suggested by Quinn did the trick of avoiding the crash.

Best Regards, Viktor

I’m gonna do some more research about this (FB15294185)

I spent some time talking this over with various colleagues, just to make sure I fully understand what’s going on. The high-level summary is:

  • Swift 6 has inserted a run-time check to catch a concurrency issue that Swift 5 did not.

  • This isn’t caught at compile time because of a Swift / Objective-C impedance mismatch.

If you want to know more, follow me down the rabbit hole!


Consider this simplified version of Artecoop’s test code:

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    
    func applicationDidFinishLaunching(_ application: UIApplication) {
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .sound, .badge]) { success, error in
            print(success)
            assert(Thread.isMainThread)
        }
    }
}

The entire did-finish-launching method should run on the main actor. That’s because:

  • The method itself is part of the implementation of the UIApplicationDelegate protocol, and that’s declared @MainActor.

  • The requestAuthorization(…) method doesn’t do anything to tell the Swift compiler that it will call the completion handler on a secondary thread (more on that below).

If you build this program in Swift 6 mode, on running it you trap before the print(…) call, in a main actor check inserted by the Swift compiler. That’s the central complaint of the original post.

However, consider what happens if you build this in Swift 5 mode. This time it traps in the assert(…). So this trap is clearly an improvement: Swift 6 mode is consistently detecting a problem that could otherwise cause a data race.


However, that explanation leaves a number of unanswered questions. Let’s start with fixes.

The fix I described above, calling the async version of the method, works because the Swift compiler implements the async method with code that kinda looks something like this:

extension UNUserNotificationCenter {

    func requestNotificationQQQ1(
        options: UNAuthorizationOptions
    ) async throws -> Bool {
        try await withCheckedThrowingContinuation { cont in
            self.requestAuthorization(options: options) { success, error in
                if success {
                    cont.resume(returning: true)
                } else {
                    cont.resume(throwing: error!)
                }
            }
        }
    }
}

In this case the completion handler isn’t bound to the main actor and thus the compiler doesn’t add an assert. And the CheckedContinuation type is thread safe, so calling its methods from any thread is fine.

The other fix is to make the closure as @Sendable:

center.requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable success, error in
    print(success)
    // print(self.window)
}

This disconnects the closure from the main actor and thus there’s no main actor check. However, it also means that you can’t access main actor state in the closure. If you uncomment the access to self.window, the compiler complains that Main actor-isolated property 'window' can not be referenced from a Sendable closure.


Finally, let’s come back to why the compiler doesn’t know that the completion handler can be called on any thread. That’s tied to the Objective-C declaration, which is imported into Swift as:

func requestAuthorization(
    options: UNAuthorizationOptions = [],
    completionHandler: @escaping (Bool, (any Error)?) -> Void
)

Imagine you wrote your own (very bad :-) [1] version of this in Swift:

extension UNUserNotificationCenter {

    func requestAuthorizationQQQ2(
        options: UNAuthorizationOptions = [],
        completionHandler: @escaping (Bool, (any Error)?) -> Void
    ) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
            completionHandler(true, nil)
         // ^ Capture of 'completionHandler' with non-sendable type '(Bool, (any Error)?) -> Void' in a `@Sendable` closure
        }
    }
}

The compiler complains that the closure is being sent across isolation domains even though it’s not sendable. That’s obviously bad.

The thing to note here is that this is exactly what Objective-C is doing, and it’s why you’re running into the problem.

The most straightforward way to fix the requestAuthorizationQQQ2(…) method is to replace @escaping with @Sendable. And the equivalent of doing that in Objective-C is to add NS_SWIFT_SENDABLE to the completion handler parameter of the -requestAuthorizationWithOptions:completionHandler: method [2].

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] To start, I’m not following my own advice here: Avoid Dispatch Global Concurrent Queues

[2] It wouldn’t surprise me if that were the final resolution of FB15294185, but that’s still in The Future™.

Man, that's an explanation!

Thanks for sharing the insight of the problem, so me and everyone that will hit this post can understand and fix its own code.

Thanks again Quinn

Man, an explanation!

Yep. Mostly I write this stuff down as an excuse to get it straight in my own head (-:

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hi,

I tried to apply the solution of Quinn to UNUserNotificationCenter.getNotificationSettings(completionHandler:) and get the error "Non-sendable type 'UNNotificationSettings' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary"

It would be great if someone could help me to fix it.

func getNotificationSettings() {
        let center = UNUserNotificationCenter.current()
        Task {
            let settings = await center.notificationSettings() // here I get the error "Non-sendable type 'UNNotificationSettings' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary"
            let authorizationStatus = settings.authorizationStatus
        }
    }

Thank you!

Hi

in the meantime I solved it like this. Maybe this is helpful for someone.

    func getUserNotificationPermission(completion: @Sendable @escaping (String) -> Void) {
        DispatchQueue.global(qos: .userInteractive).async {
            UNUserNotificationCenter.current().getNotificationSettings { settings in
                if settings.authorizationStatus == .authorized {
                    completion("authorized")
                }
                
                if settings.authorizationStatus == .provisional {
                    completion("authorized")
                }
                
                if settings.authorizationStatus == .ephemeral {
                    completion("authorized")
                }
                
                if settings.authorizationStatus == .denied {
                    completion("denied")
                }
                
                if settings.authorizationStatus == .notDetermined {
                    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
                        if success {
                            completion("authorized")
                        } else {
                            completion("denied")
                        }
                    }
                }
            }
        }
    }

I can call this function via a Button from a SwiftUI View:

struct ContentView: View {
    var body: some View {
        VStack {
            Button("getUserNotificationPermission") {
                getUserNotificationPermission { permission in
                    if permission == "authorized" {
                        print("permission: \(permission)")
                    }
                }
            }
        }
    }
}


I presume that getNotificationSettings() is a method on an main-actor-isolated type, like your app delegate. If it isn’t, you don’t get this error.

If so, the easiest fix is to mark getNotificationSettings() as nonisolated.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I can attest that AVCaptureDevice.requestAccess suffered from this same problem, i.e. crashing with a thread/queue violation when built under Swift 6.

Original code:

AVCaptureDevice.requestAccess(for: AVMediaType.video, completionHandler: { granted in
  if (granted)
  {
      DispatchQueue.main.async { [weak self] in
        // do something
   }
  }
})

Modified code:

Task {
    let granted = await AVCaptureDevice.requestAccess(for: .video)
    if (granted) {
        Task { @MainActor in
            // do something
        }
    }
}

This code pops a permission dialog for using the camera, and as soon as the Allow button is clicked the app would crash.

Thank you Eskimo for the detail explanation, that helps a lot. And and @Sendable closure solution solved my issue.

And I want to mention something related. I'm using the async version before, just like the one you mention before:

let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
Task {
    do {
        _ = try await UNUserNotificationCenter.current().requestAuthorization(options: authOptions)
        print("here")
    } catch {
        print("there")
    }
}

But recently I detect a memory leak in the Instruments, that's why I need to find a way to go back to the completion callback version. Could you find out why the async version has that memory leak? BTW I'm using Xcode 16.1.0. And here is the screenshot in Instruments:

Your can easily reproduce it in Instruments with the code you provide.

Request authorization for the notification center crash iOS app on Swift 6
 
 
Q