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
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™.