Can a protocol imply nonisolated?

Hello, this is a question regarding the interaction of MainActor-by-default, isolated conformances, and libraries. In Swift 6.2.

I maintain a library that defines a protocol, let's call it LibProtocol.

This protocol is intended to be used across multiple isolation domains, even though it does not require Sendable. To be precise:

  • (REQ1) The protocol comes with static functions and properties that are invoked from various isolation domains.
  • (REQ2) Instances, even if not Sendable, are supposed to be sent across isolation domains. That's what the lib does (you can think downloading structs from internet, decoded in a background thread until they are given to the app on the main actor, or loading stuff from a database, etc.). The user may only use instances on the main actor, but the library has instances cross isolation domains. That's its very job.

In an app that uses MainActor by default (as the new default Xcode 26 template does), a user type that conforms to LibProtocol is MainActor-isolated, and its conformance is MainActor-isolated as well:

import Lib

// MainActor-isolated type
// MainActor-isolated conformance to LibProtocol
struct UserType: LibProtocol { }

This conflicts with how the protocol is intended to be used.

If the library declares LibProtocol as a sub-protocol of SendableMetaType, this solves REQ1 described above. But not REQ2:

The user type is still MainActor-isolated. I see plenty of other compiler errors regarding "Circular reference", "Conformance of 'UserType' to protocol 'Encodable' crosses into main actor-isolated code and can cause data races", "Conformance of 'UserType' to protocol 'LibProtocol' crosses into main actor-isolated code and can cause data races". Not a welcoming experience at all :exploding_head:

One way to solve those errors is for the user to specify that the type is nonisolated:

import Lib

nonisolated struct UserType: LibProtocol { }

I wish end users would not have to write this explicit nonisolated. Assuming my understanding is not too wrong, can the library declare that LibProtocol implies nonisolated? i.e. can the lib force an app to give up the default MainActor-isolation for types that conform to LibProtocol?

3 Likes

Declaring the protocol nonisolated seems to have the behavior you want, of changing the default isolation of the protocol conformance functions in the user actor:

public nonisolated protocol P {
  func p()
}

@MainActor
public struct Q: P {
  public init() {}
  public func ok() {
    let iso = String(describing: #isolation)
    print("\(#function) isolation: \(iso)")
  }
  public func p() {
    let iso = String(describing: #isolation)
    print("\(#function) isolation: \(iso)")
  }
}

  func testQ() async {
    let me = await Q()
    me.p() // no await required
    await me.ok()
  }

Result is

p() isolation: nil
ok() isolation: Optional(Swift.MainActor)

Oh thank you @wes1. I'll try and report :slight_smile:

Not according to my tests. To avoid compiler errors, the app has to specify nonisolated even if the protocol is nonisolated, as below. But the goal is that the app does not have to specify nonisolated explicitly, because, you know, the lib is supposed to have good ergonomics, even for apps that opt-in for MainActor-isolation by default, as all new projects created in Xcode.

// Lib code
public protocol LibProtocol: SendableMetatype { }
public nonisolated protocol NonIsolatedLibProtocol: SendableMetatype { }

// App code (MainActor-isolated by default)
struct UserType1 { }
extension UserType1: LibProtocol { }
extension UserType1: Codable { } // ❌ Circular reference
extension UserType1: Equatable { }

nonisolated struct UserType2 { }
extension UserType2: LibProtocol { }
extension UserType2: Codable { }
extension UserType2: Equatable { }

struct UserType3 { }
extension UserType3: NonIsolatedLibProtocol { }
extension UserType3: Codable { } // ❌ Circular reference
extension UserType3: Equatable { }

nonisolated struct UserType4 { }
extension UserType4: NonIsolatedLibProtocol { }
extension UserType4: Codable { }
extension UserType4: Equatable { }

It looks that there's something specific with Codable… No: Encodable

struct UserType5 { }
extension UserType5: LibProtocol { }
extension UserType5: Decodable { }
extension UserType5: Encodable { } // ❌ Circular reference

Full error log:

App.swift:4:1: error: circular reference
extension UserType5: Encodable { }
App.swift:4:1: note: through reference here
extension UserType5: Encodable { }
App.swift:1:8: note: through reference here
struct UserType5 { }
       ^
App.swift:4:1: note: through reference here
extension UserType5: Encodable { }
App.swift:4:22: error: conformance of 'UserType5' to protocol 'Encodable' crosses into main actor-isolated code and can cause data races
extension UserType5: Encodable { }
App.swift:4:22: note: isolate this conformance to the main actor with '@MainActor'
extension UserType5: Encodable { }
App.swift:4:22: note: turn data races into runtime errors with '@preconcurrency'
extension UserType5: Encodable { }
UserType5.encode:2:26: note: main actor-isolated instance method 'encode(to:)' cannot satisfy nonisolated requirement
@MainActor internal func encode(to encoder: any Encoder) throws}
                         ^

With the nonisolated protocol, the error is similar (note the "main actor-isolated instance method 'encode(to:)'" in the log):

struct UserType6 { }
extension UserType6: NonIsolatedLibProtocol { }
extension UserType6: Decodable { }
extension UserType6: Encodable { } // ❌ Circular reference
App.swift:4:1: error: circular reference
extension UserType6: Encodable { }
App.swift:4:1: note: through reference here
extension UserType6: Encodable { }
App.swift:1:8: note: through reference here
struct UserType6 { }
       ^
App.swift:4:1: note: through reference here
extension UserType6: Encodable { }
App.swift:4:22: error: conformance of 'UserType6' to protocol 'Encodable' crosses into main actor-isolated code and can cause data races
extension UserType6: Encodable { }
App.swift:4:22: note: isolate this conformance to the main actor with '@MainActor'
extension UserType6: Encodable { }
AppApp.swift:4:22: note: turn data races into runtime errors with '@preconcurrency'
extension UserType6: Encodable { }
UserType6.encode:2:26: note: main actor-isolated instance method 'encode(to:)' cannot satisfy nonisolated requirement
@MainActor internal func encode(to encoder: any Encoder) throws}
                         ^

I hope it's not a case of compiler passes that are not well-ordered… ;-)

May I ask how I should report an issue for maximum clarity and impact, @hborla, @Douglas_Gregor?

EDIT: Please Ignore this message, which took SendableMetatype as a library-defined type
instead of

https://docs.swift.org/compiler/documentation/diagnostics/sendable-metatypes/

Also, it seems isolation declarations on a protocol should operate on sub-protocols:
https://forums.swift.org/t/default-main-actor-isolation-and-protocol-inheritance/80419/3

Sorry for the noise.


Let's hope someone can look at this issue.

Your code is probably a thin slice from a more complicated situation, so I'm reluctant to extend the discussion based on it...

But if you want to investigate further yourself, would it help to try declaring the protocol itself non-isolated, instead of isolating the sub-protocol?

public nonisolated protocol SendableMetatype { ... }

Reason being: In the recent proposed documentation (https://forums.swift.org/t/rfc-data-race-safety-chapter-in-the-tspl-language-reference) protocols seem to convey actor isolation from the protocol to a conforming type only when declared directly.

Swift only infers isolation from protocols when you write the conformance at the primary declaration

When the isolation is declared in an extension, the declaration is conveyed for only at the level of the extra member requirements (i.e., not applied to the conforming type):

If you write the conformance in an extension, then isolation inference only applies to requirements that are implemented in the extension.

Perhaps the extension case is closest to your code above, declaring an isolated subprotocol extending a protocol (with undeclared isolation):

If this is like an actor-isolated extension, the non-isolated isolation might apply only to members declared in the sub-protocol (in the snippet above, none), resulting in some problems.

To fix that (assuming that explicit nonisolated isolation is conveyed in the same way as actor isolation), perhaps start with direct nonisolated for the (library) protocol and then directly declare conformance of the (user/application) type to the protocol. If that works, introduce indirection to see where that breaks things.

Also it's not clear if the problem is the compiler inference or a failure of stdlib protocols to declare their isolation as nonisolated. You could start by avoiding them to get code working as it should (if that's even possible when your protocols tangle with Codable or Equatable). If it works, that suggests the problem is the nondeclaration of nonisolation for stdlib protocols, or perhaps compiler assumptions about protocols external to the module in default @MainActor mode.

I created Library protocols can not overtake the default isolation of the importing module · Issue #82249 · swiftlang/swift · GitHub, and upvoted Codable conformance throws Circular reference compile error when default isolation is enabled · Issue #82200 · swiftlang/swift · GitHub