Implementing a Main Actor Protocol That’s Not @MainActor

This thread has been locked by a moderator; it no longer accepts new replies.

When adopting Swift 6, it’s common to encounter frameworks and libraries that haven’t been audited for sendability. I get pinged about this regularly, so I decided to write up my take on it.

If you have questions or comments, put them in a new thread. Use the Programming Languages > Swift subtopic and tag it with Concurrency; that way I’ll be sure to I see it.

IMPORTANT This is covered really well in the official documentation. Specifically, look at the Under-Specified Protocol section of Migrating to Swift 6. I wrote this up most as an excuse to get it all straight in my head.

Oh, one last thing: This is all based on the Swift 6 compiler in Xcode 16.0b4. Swift concurrency is evolving rapidly, so you might see different results in newer or older compilers.

Share and Enjoy

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


Implementing a Main Actor Protocol That’s Not @MainActor

Imagine you’re using the WaffleOMatic framework. It has a WaffleVarnisher class like this:

class WaffleVarnisher {

    weak var delegate: Delegate?

    protocol Delegate: AnyObject {
        func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle)
    }
}

class Waffle {
    var isGlossy: Bool = false
}

You are absolutely sure that the varnisher calls its delegate on the main thread, but the framework hasn’t been audited for sendability [1]. When you adopt it in a main-actor class, you hit this problem:

@MainActor
class WaffleState: WaffleVarnisher.Delegate {
    var lastWaffle: Waffle? = nil
    
    func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle) {
      // ^ Main actor-isolated instance method 'varnished(_:didVarnish:)'
      // cannot be used to satisfy nonisolated protocol requirement
        self.lastWaffle = waffle
    }
}

That error has three fix-its:

  1. Add 'nonisolated' to 'varnished(_:didVarnish:)' to make this instance method not isolated to the actor

  2. Add '@preconcurrency' to the 'Delegate' conformance to defer isolation checking to run time

  3. Mark the protocol requirement 'varnished(_:didVarnish:)' 'async' to allow actor-isolated conformances

I’ll discuss each in turn, albeit out of order.

[1] If it had, WaffleVarnisher.Delegate would be annotated with the @MainActor attribute.

Fix-it 3: Apply async

If you choose fix-it 3, Mark the protocol requirement 'varnished(_:didVarnish:)' 'async' to allow actor-isolated conformances, the compiler changes the varnished(_:didVarnish:) to be async:

class WaffleVarnisher {

    …

    protocol Delegate: AnyObject {
        func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle) async
    }
}

This is a non-starter because one of our assumptions is that you can’t change the WaffleOMatic framework [1].

[1] If you could, you’d add the @MainActor attribute to WaffleVarnisher.Delegate and this whole problem goes away.

Fix-it 1: Apply non-isolated

If you choose fix-it 1, Add 'nonisolated' to 'varnished(_:didVarnish:)' to make this instance method not isolated to the actor, you get this:

@MainActor
class WaffleState1: WaffleVarnisher.Delegate {
    var lastWaffle: Waffle? = nil
    
    nonisolated func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle) {
        self.lastWaffle = waffle
          // ^ Main actor-isolated property 'lastWaffle' can not be mutated from a non-isolated context
    }
}

It’s fixed the original error but now you have a new one. The protocol method is non-isolated, so it can’t access the main-actor-only lastWaffle property.

You can work around this with assumeIsolated(…), but this yields another error:

@MainActor
class WaffleState1: WaffleVarnisher.Delegate {
    var lastWaffle: Waffle? = nil
    
    nonisolated func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle) {
        // A
        MainActor.assumeIsolated {
            // B
            self.lastWaffle = waffle
                           // ^ Sending 'waffle' risks causing data races
        }
    }
}

You’re now passing the waffle object from a non-isolated context (A) to the main-actor-isolated context (B), and you can’t do that because that object is not sendable [1].

You can’t make Waffle sendable because you don’t own the WaffleOMatic framework. That leaves two options. The first is to extract sendable properties from waffle and pass them between the isolation contexts. For example, imagine that you only care about the isGlossy property of the last waffle. In that case, you might write code like this:

@MainActor
class WaffleState1: WaffleVarnisher.Delegate {
    var wasLastWaffleGlossy: Bool? = nil
    
    nonisolated func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle) {
        let wasGlossy = waffle.isGlossy
        MainActor.assumeIsolated {
            self.wasLastWaffleGlossy = wasGlossy
        }
    }
}

Problem solved!

The other option is to disable concurrency checking. There are a variety of ways you might do that. For example, you might apply @preconcurrency on the import, or use an @unchecked Sendable box to transport the waffle, or whatever. I’m not going to discuss these options in detail here because they run counter to the overall goal of Swift concurrency.

[1] Of course both of these contexts are the same!, that is, the main actor context. However, the Swift compiler doesn’t know that. Remember that the goal of Swift concurrency is to have your concurrency checked at compile time, so it’s critical to view errors like this from the perspective of the compiler.

Fix-it 2: Apply preconcurrency

If you choose fix-it 2, Add '@preconcurrency' to the 'Delegate' conformance to defer isolation checking to run time, you get this [1]:

@MainActor
class WaffleState3: @preconcurrency WaffleVarnisher.Delegate {
    var lastWaffle: Waffle? = nil
    
    func varnisher(_ varnisher: WaffleVarnisher, didVarnish waffle: Waffle) {
        self.lastWaffle = waffle
    }
}

This is the best solution to this problem IMO. In this context the @preconcurrency attribute [2] does two things:

  • It tells the compiler that it can assume that the WaffleVarnisher.Delegate methods are called in the appropriate isolation context for this type. In that case that means the main actor.

  • It inserts runtime checks to these delegate methods to verify that assumption.

The key advantage of fix-it 2 over fix-it 1 is that compiler knows that the delegate callback is isolated to the main actor, and so:

  • It doesn’t complain when you access main-actor-isolated constructs like lastWaffle.

  • It knows that you’re not smuggling waffles across state lines isolation contexts.

[1] Or it will, once we fix the fix-it (r. 132570262) (-:

[2] The @preconcurrency attribute has very different different meanings depending on the context!

Synchronous Results

The advantages of fix-it 2 increase when the delegate protocol includes methods that return a result synchronously. Imagine that the WaffleVarnisher.Delegate protocol has a second callback like this:

class WaffleVarnisher {
    
    …
    
    protocol Delegate: AnyObject {
        func varnisher(_ varnisher: WaffleVarnisher, shouldMakeGlossy waffle: Waffle) -> Bool
        …
    }
}

The fix-it 2 approach lets you implement that delegate using state that’s isolated to the main actor:

@MainActor
class WaffleState: @preconcurrency WaffleVarnisher.Delegate {
    var lastWaffle: Waffle? = nil
    
    func varnisher(_ varnisher: WaffleVarnisher, shouldMakeGlossy waffle: Waffle) -> Bool {
        return !(self.lastWaffle?.isGlossy ?? false)
    }
    
    …
}

In this case it’s possible to solve this problem with the fix-it 1 approach as well, but the code is uglier:

    nonisolated func varnisher(_ varnisher: WaffleVarnisher, shouldMakeGlossy waffle: Waffle) -> Bool {
        return MainActor.assumeIsolated {
            return !(self.lastWaffle?.isGlossy ?? false)
        }
    }

However, that doesn’t always work. If the delegate method returns a non-sendable type, this approach will fail with a does not conform to the 'Sendable' protocol error.

Boost
Implementing a Main Actor Protocol That’s Not @MainActor
 
 
Q