Actors conforming to protocols in default @MainActor isolation targets

I've come across some behavior in default @MainActor isolation targets that I can't explain. Consider the following protocol defined in a @MainActor module, and an actor that conforms to the protocol:

// Default main actor isolation module
protocol P {
  func perform() async
}
actor A: P {
  func perform() async {}
}

I was surprised this compiled. It seems that P is effectively @MainActor P, and so an arbitrary actor shouldn't be allowed to conform. And if I explicitly make P into @MainActor, that is what happens:

@MainActor protocol P {
  func perform() async
}
actor A: P {  // ๐Ÿ›‘ Actor 'A' cannot conform to global-actor-isolated protocol 'P'
  func perform() async {}
}

Now the first code snippet only compiles when perform is async, and I think for that reason there isn't really a soundness hole. If you were to erase an instance of A to an any P and then invoke perform(), you would be forced to await and so isolation to the actor can be guaranteed. If the code compiled with perform() being non-async, then there definitely would be a soundness problem since you would be able to invoke perform() synchronously from a main actor context. So luckily that is not the case.

But things get weird when you move A to a separate module:

// Module P, default main actor isolation
protocol P {
  func perform() async
}

// Module A, default isolation doesn't matter
import P
actor A: P::P {  // ๐Ÿ›‘ Actor 'A' cannot conform to global-actor-isolated protocol 'P'
  func perform() async {}
}

Now I do get the diagnostic I expected in the first code snippet. So I'm not sure how to make sense of this. Is this mentioned in any of the proposals about default main actor isolation?

Part of me thinks that the first code snippet should be rejected since I thought a protocol P in a main actor module would be equivalent to @MainActor protocol P, but that does not seem to be the case. But then the other part of me could accept that the first code snippet is legit because there is not a real soundness hole.

So, does this code work as expected, or is there something missing from default main actor isolation?

3 Likes

eek, I (think I) fixed this bug while lifting the prohibition on actors conforming to isolated protocols, but it hasn't made it into a release yet. It is a source breaking change for (wrong, mistakenly accepted) code like you show, so hopefully nobody is relying on this!

Your intuition is right that the async case is safe, that is what we will allow in the next release.

Let me know if I didn't read your post carefully enough and missed some nuance, but there are bugs where default isolation is applied to things, but "sneak past" the checking the compiler would do if it was written explicitly. There was a bug like this for protocols, where the protocol could get an isolation from the module default that could be inferred onto the actor, and actors were only prohibited from conforming to protocols that had their isolation written explicitly in source. When the default is inferred onto a protocol that is public, it is written into the interface, and the compiler sees it and forbids the conformance.

only your async example (godbolt) should compile with -default-isolation MainActor -language-mode 6. sync gets a compile error (godbolt).

Hi @aviva, thanks for the info!

That PR does seem related to this, but there's a chance that there is still some undefined behavior lurking.

This code snippet compiles fine in 6.4 (godbolt):

// Default main actor isolation module
protocol P {
  func perform() async
}
actor A: P {
  func perform() async {}
}

And it sounds like this is expected, correct?

This code snippet produces an error in 6.4 (godbolt):

// Default main actor isolation module
protocol P {
  func perform()
}
actor A: P {
  func perform() {}
}

And that definitely is expected because otherwise a data race would certainly be possible.

The part that confuses me is that this code does not compile in 6.4 (godbolt):

@MainActor protocol P {
  func perform() async
}
actor A: P {  // ๐Ÿ›‘ Actor 'A' cannot conform to global-actor-isolated protocol 'P'
  func perform() async {}
}

The only difference in this snippet form the compiling example is that P has been explicitly marked as @MainActor. But is protocol P not the same as @MainActor protocol P in a main actor module?

And further, this does not compile in Swift 6.4:

// Module P, default main actor isolation
protocol P {
  func perform() async
}

// Module A, default isolation doesn't matter
import P
actor A: P::P {  // ๐Ÿ›‘ Actor 'A' cannot conform to global-actor-isolated protocol 'P'
  func perform() async {}
}

The only difference here from the compiling example is that the protocol has been put in a separate conformance from the actor.

So it seems that when P is used within its defining module it's not really interpreted as @MainActor P, but outside the module it is. Is that behavior expected or documented, or described in the proposal?

The PR I linked did not make it into 6.4 (afair); try your examples with the nightly compiler? It should do what you expect. 6.4 still had the bug I described.

Sorry, I should have downloaded the nightly and tried myself. It is now true that all 3 variations of my code compile:

// Default main actor isolation module
protocol P {
  func perform() async
}
actor A: P {
  func perform() async {}
}
@MainActor protocol P {
  func perform() async
}
actor A: P {
  func perform() async {}
}
// Module P, default main actor isolation
protocol P {
  func perform() async
}

// Module A, default isolation doesn't matter
import P
actor A: P::P {
  func perform() async {}
}

And so it's at least consistent! Thanks for the help.

My only last question is this documented? At first it doesn't seem to make much sense to conform an actor to a @MainActor protocol, after all a custom actor is definitely not the main actor. But if all the requirements happen to async, then that distinction can be blurred? And if one deals with an instance of the actor as an erased any P, then will Swift hop to the main actor and then instantly hop to the actor's isolation? It's definitely useful and greatly improves the ergonomics of main actor modules, but I just want to make sure I'm understanding the details correctly. Thanks!

Not documented anywhere, no. The Swift book is unfortunately pretty behind the details of Swift concurrency, but hopefully this can change! Maybe this forum thread will come up in search results.

Part of my motivation to fix that issue was to get rid of this userdoc, which espoused the idea of "isolated protocols": [Concurrency] Remove prohibition on actors conforming to isolated protocols by a-viv-a ยท Pull Request #91147 ยท swiftlang/swift ยท GitHub

In my opinion[1] there is no such thing as a @MainActor protocol or an isolated protocol. There are protocols, which can require particular isolations for their requirements, that their conformers are Sendable / SendableMetatype, and so on. But @MainActor protocol P does not describe an isolated protocol. It does the following things:

  • propagate to the requirements of the protocol (and its extensions, and refining protocols) unless a different isolation is specified
@MainActor protocol P {
  /* @MainActor */ func perform()
}
  • provide an inference hint, to give isolation to conformers on the primary declaration, that don't specify an isolation
/* @MainActor */ struct S: P { /* ... */ }

This isn't totally different from isolation on a type... @MainActor struct S can be overridden with a specified isolation on its members / methods in the same way. However, isolation on a type provides Sendable, applies to storage, and will run in the specified isolation. Compare that to an isolated requirement, where a witness can be nonisolated, or even have a different isolation for async requirements!

Rambling: I suspect if isolated conformances had been designed sooner, we may not have this inference or propagation behavior, and instead @MainActor protocol P (or some other, more clear syntax) would only suggest isolated conformance by default. Protocol requirements would have no isolation at all, and isolated conformances would be used instead of isolation on requirements. With isolated conformance inference (InferIsolatedConformances) I think there is a lot less motivation to put isolation on requirements, since global actor isolated types automatically have isolated conformances. Protocols that do know most uses are main actor would be better served by this behavior, since they could be used in "usually main actor" projects without precluding nonisolated conformances. This would be less expressive in some cases, but I think a lot simpler.

All of that to say... nothing wrong with actors conforming to protocols when they can satisfy the requirements! An instance of an actor has its own "instance" isolation whereas a global actor is one shared isolation, but when the requirement mandates the caller await the witness is free to hop to an arbitrary isolation.

protocol P {
  @MainActor func foo()
}

protocol Q {
  @MainActor func bar() async
}

struct S1: P, Q { // totally fine
  nonisolated func foo() { /* ... */ }
  nonisolated func bar() async { /* ... */ }
}

struct S2: Q { // also totally fine
  @OtherActor func bar() async { /* ... */ }
  // ^ call will await even if it is @MainActor,
  // so we can witness the req from any isolation
}

actor A: Q { // just as fine as S2
  /* "instance of A" isolated */ func bar() async { /* ... */ }
  // ^ fine to witness the req with this isolation too!
}

  1. There are probably other coherent ways to understand our system. I think this way makes the most sense though. โ†ฉ๏ธŽ

1 Like

Perfect, that clears up things. Thanks!