Effect of @MainActor inference on Obj-C completion callbacks

Let's say we have an Obj-C method taking a completion handler parameter:

@interface TestProcessor: NSObject
- (void) doProcessing: (void(^)(void)) completionHandler;
@end

The implementation of the method calls the completion handler on an arbitrary thread, simulated by the following implementation:

@implementation TestProcessor
- (void)doProcessing:(nonnull void (^)(void))completionHandler {
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
        completionHandler();
    });
}
@end

Now, let's write some Swift code that uses the above function, in a class that for some reason (such as a protocol conformance) is MainActor isolated, such as:

import UIKit

@MainActor var counter = 0
@MainActor let delegate = MyDelegate()

@main
class MyDelegate: UIResponder, UIApplicationDelegate {
    override init() {
        super.init()
        doProcessorSync() // <--- crashes during this call
        Task {
            await doProcessorAsync()
        }
    }
    func doProcessorSync() {
        let processor = TestProcessor()
        processor.doProcessing {
            // crash is actually here
            counter += 1
        }
    }
    func doProcessorAsync() async {
        let processor = TestProcessor()
        await processor.doProcessing() // <--- no crash here
        counter += 1
    }
}

For the purposes of this example, I've written out the code to call both translations of the Obj-C function: "synchronous" and "asynchronous".

In this example, doProcessorAsync executes without error, as does doProcessorSync in Swift 5 mode. However, in Swift 6 mode only, doProcessorSync crashes at a run-time sanity check because the code is (obviously) not running on the main thread, and the completion handler's closure was not Sendable and was therefore inferred to be MainActor isolated.

What's the actual explanation of this disparity, in both the historical and the semantic sense? For example:

  • Is this a bug in the Obj-C code, because that code's author failed to consider that Swift might be expecting the completion handler to really be isolated already? Or is it a bug in the Swift code? Or is it a bug in Swift 6? Or is it a bug in Swift 5, now fixed?
  • Did the introduction of concurrency, way back, introduce the inferred MainActor isolation, thus changing the meaning of Obj-C translations of callbacks that were essentially just synchronous calls back in pre-concurrency days? Or is that inference something that was added to Swift more recently?
  • What were the intended semantics of callback closure parameters for translated Obj-C imports? Were they always intended to exhibit isolation behavior? Did that change over historical time?
  • Are there some further compile or run time checks that the Swift compiler could have in this area, but doesn't for some pragmatic reason?
  • And so on.

Clearly, the author of the closure can avoid the problem by ensuring that the closure is Sendable, because that disables the MainActor inference on the closure. In that case, the closure just runs on the whatever thread it was called, and is safe if the closure doesn't depend or mess with assumptions about the caller's thread.

By comparison, in Swift 5 mode, the closure executes as-is but unsafely on the caller's thread, without the crash. That is, as far as the Swift compiler knew, the closure should have been called on the main thread, but it doesn't check, and so the closure may run by luck without any bad effects.

Anyone got any comments or perspective on this?

2 Likes

I'm not an expert on this matter, but I believe the reason the async version works is because it invokes a special function synthesized for you by the obj-c interoperability layer. It probably uses something like withCheckedContinuation under the hood, which is the preferred way to make classic completion handlers work with modern concurrency.

So the rule of thumb here is probably to not use classic completion handler directly and instead use either the synthesized async version or use a hand-written async alternative that ensure compatibility with modern concurrency.

See:

async version works because it works differently – it doesn't call to counter off main actor isolation (which causes crash):

func doProcessorAsync() async {
    let processor = TestProcessor()
    await processor.doProcessing() // called on an arbitrary queue
    counter += 1 // back to main isolation
}

I guess the main question if the crash is intended in Swift 6 mode? It has some meaning to it, as this mode aims to ensure safety at all cost, so it might favour runtime crashes for places where it is impossible to ensure this statically.

Sure, there are solutions. My questions are a bit more existential. What are the semantics of the non-async form, and did the semantics change, either in the Swift 5 -> 6 transition, or earlier in the transition into Swift concurrency?

Oh, it's intended, something like the termination when you try to access an array at an invalid index. The code should be executing on the main thread, but isn't. Again, the question is: At what point in Swift's history did that "should be executing" become true of code like this?