Crashes because main actor isolated closures are called on a background thread with `DispatchGroup.notify`, but no compiler warnings

Hello! We are in the progress of migrating a large Swift 5.10 legacy code base over to use Swift 6.0 with Strict Concurrency checking.

We have already stumbled across a few weird edge cases where the "guaranteed" @MainActor isolation is violated (such as with @objc #selector methods used with NotificationCenter).

However, we recently found a new scenario where our app crashes accessing main actor isolated state on a background thread, and it was surprising that the compiler couldn't warn us.

Minimal reproducible example:

class ViewController: UIViewController {
    var isolatedStateString = "Some main actor isolated state"

    override func viewDidLoad() {
        exampleMethod()
    }

    /// Note: A `@MainActor` isolated method in a `@MainActor` isolated class.
    func exampleMethod() {
        testAsyncMethod() { [weak self] in
            // !!! Crash !!!
            MainActor.assertIsolated()

            // This callback inherits @MainActor from the class definition, but it is called on a background thread.
            // It is an error to mutate main actor isolated state off the main thread...
            self?.isolatedStateString = "Let me mutate my isolated state"
        }
    }

    func testAsyncMethod(completionHandler: (@escaping () -> Void)) {
        let group = DispatchGroup()
        let queue = DispatchQueue.global()

        // The compiler is totally fine with calling this on a background thread.
        group.notify(queue: queue) {
            completionHandler()
        }

        // The below code at least gives us a compiler warning to add `@Sendable` to our closure argument, which is helpful.
//        DispatchQueue.global().async {
//            completionHandler()
//        }
    }
}

The problem:

In the above code, the completionHandler implementation inherits main actor isolation from the UIViewController class.

However, when we call exampleMethod(), we crash because the completionHandler is called on a background thread via the DispatchGroup.notify(queue:).

If were to instead use DispatchQueue.global().async (snippet at the bottom of the sample), the compiler helpfully warns us that completionHandler must be Sendable.

Unfortunately, DispatchGroup's notify gives us no such compiler warnings. Thus, we crash at runtime.

So my questions are:

  1. Why can't the compiler warn us about a potential problem with DispatchGroup().notify(queue:) like it can with DispatchQueue.global().async?
  2. How can we address this problem in a holistic way in our app, as it's a very simple mistake to make (with very bad consequences) while we migrate off GCD?

I'm sure the broader answer here is "don't mix GCD and Concurrency", but unfortunately that's a little unavoidable as we migrate our large legacy code base! 🙂

Crashes because main actor isolated closures are called on a background thread with `DispatchGroup.notify`, but no compiler warnings
 
 
Q