Default Main Actor isolation and protocol inheritance

I’ve been trying to understand how library code may work with new application targets that use main actor isolation and Swift 6 language mode and came across the following behavior.

Given library code with a basic protocol hierarchy:

protocol P1 { func f() }
protocol P2: P1 {}

And an app using main actor isolation and Swift 6 language mode:

struct S1: P1 { func f() }
struct S2: P2 { func f() }

S1 compiles fine, while S2 fails with:

Conformance of 'S' to protocol 'P2' crosses into main actor-isolated code and can cause data races

I’ve uploaded a project that reproduces the behavior here.

The error can be worked around with an explicit @MainActor:

struct S2: @MainActor P2 { func f() }

But I would hope that would be inferred automatically, especially since an extension macro can apply a conformance but can’t know whether or not it should do so with a specific isolation.

A similar behavior can be demonstrated with a standard library protocol like Collection:

struct MyCollection: Collection {
  var startIndex: Int { 0 }
  var endIndex: Int { 0 }
  func index(after i: Int) -> Int { i + 1 }
  subscript(offset: Int) -> String { "" }
  // Comment the following in and it builds:
  // func makeIterator() -> AnyIterator<String> {
  //   fatalError()
  // }
}

Conformance of 'MyCollection' to protocol 'Sequence' crosses into main actor-isolated code and can cause data races

This can also be worked around with explicit @MainActor, but curiously it can also be worked around with a manual makeIterator implementation.

Is all of this expected behavior? Are they bugs that should be reported?

4 Likes

Opened an issue here: Default main actor isolation fails to deeply apply to some protocol hierarchies · Issue #82222 · swiftlang/swift · GitHub

/cc @hborla (I've seen you tagged in some other threads for default main actor conversation if it helps to flag. If it does not help let me know and I'll refrain in the future :smile:)

2 Likes

Thank you for the report! I agree that this is a compiler bug, and a main actor isolated conformance should be inferred through protocol refinement.

2 Likes

@stephencelis (cc: @hborla) Found a potentially related issue that isn't addressed with the @MainActor workaround.

Example:

struct S1 {
	let description: String
}

extension S1: LocalizedError {
	var errorDescription: String? {
		description
	}
}

That generates "Conformance of 'S1' to protocol 'LocalizedError' crosses into main actor-isolated code and can cause data races"

If prefixing the LocalizedError conformance with @MainActor, you then get a "Main actor-isolated conformance of 'S1' to 'Error' cannot satisfy conformance requirement for a 'Sendable' type parameter 'Self'" error.

In turn, then making S1 conform to Sendable generates the same error.

The only way I've found to remove the error is to prefix with @preconcurrency:

struct S1 {
	let description: String
}

extension S1: @preconcurrency LocalizedError {
	var errorDescription: String? {
		description
	}
}

That generates "Conformance of 'S1' to protocol 'LocalizedError' crosses into main actor-isolated code and can cause data races"

LocalizedError in this case conforms to Error, which is Sendable. So we want to allow the witness of LocalizedError, errorDescription to be called from any concurrency domain, which is why you can't conform to this protocol with a main-actor isolated description. One way to address this is to mark S1 with nonisolated to decouple it from the main actor.

That being said, it is not great that the suggested fix-it to mark the conformance with @MainActor is not helpful here.

I just tried marking the conforming type as nonisolated but that led to a cascading set of compile errors.

Interestingly, If I instead conform S1 to @preconcurrency Error, it gives a warning of "'@preconcurrency' on conformance to 'Error' has no effect"

And dropping @preconcurrency leads to no compile errors.

In this particular case, I only need to conform to Error, so can end up not having any workarounds in place. Especially happy to no longer have any @preconcurrency usages.

Anyone know when Apple will include this fix in Xcode? Since the PR for this has been merged to Swift 6.2 main, two Xcode beta releases have been issued (3 and 4). But the problem still persists, so my assumption is that it hasn't yet been folded into what is shipped with Xcode.

Actually, this is an issue with my Xcode project (which was first created many years ago).

The same exact code compiles with no errors when added to a brand new project. And even though in the old project, I enabled default MainActor isolation as well as approachable concurrency, diffing the project files shows some XCBuildConfiguration blocks as not having those enabled (perhaps these got orphaned somehow?)

So I plan to re-create all new projects.

1 Like

I came across this issue and thought I'd share my example.

protocol `Just a Protocol` {
    var something: String { get }
}

@MainActor
protocol `Main Actor Protocol`: @MainActor `Just a Protocol` {}
// The way I understand this is: here's a protocol, 
// confined to the `MainActor`, that refines another protocol,
// only on the `MainActor`

extension `Main Actor Protocol` {
    var something: String { "Hello, world!" }
}
// Refined protocol implements the parent protocol

@MainActor
struct Foo: `Main Actor Protocol` {}
// ERROR: Conformance of 'Foo' to protocol 'Just a Protocol' 
// crosses into main actor-isolated code and can cause data races

struct Bar: @MainActor `Main Actor Protocol` {}
// Compiles

Note how the compiler is smart enough to know that Bar is already confined to the MainActor, since it does not complain that it is not marked as @MainActor.

To me, it's a bit redundant to have to chase all types that conform to Main Actor Protocol to yet again tell the compiler that everything is happening on the MainActor (except for Just a Protocol).

What's the point of putting @MainActor on top of a type declaration then?