Soundness hole in isolated protocol conformances

From my reading of SE-470, the following code shows a soundness hole in isolated protocol conformances:

protocol Foo {
  func bar()
}

@MainActor struct S: @MainActor Foo {
  var count = 0
  func bar() {
    if !Thread.isMainThread {
      print(count, Thread.current)
      MainActor.assertIsolated()
    }
  }
}

@Test func test() {
  let s: Any = S()
  // This cast should fail since 's' is '@MainActor Foo'
  if let s = s as? any Foo {
    func open(_ foo: some Foo) { foo.bar() }
    open(s)
  } else {
    // Instead we should get into here, but we don't
  }
}

Here we are able to erase a @MainActor Foo type to Any, and then dynamically cast it as any Foo. This allows us to run bar on a background thread. We are even able to access count on a background thread without crashing, which seems like another soundness hole. The only way I could get this to crash is to perform an explicit MainActor.assumeIsolated(). This code compiles without warnings/errors in Swift 6 mode.

Here is the passage from SE-470 that makes me think this should not be allowed:

One last issue concerns dynamic casting. Generic code can query a conformance at runtime with a dynamic cast like this:

nonisolated func f(_ value: Any) {
  if let p = value as? any P {
    p.f()
  }
}

If the provided value is an instance of C , and this code is invoked off the main actor, allowing it to enter the if branch would introduce a data race. Therefore, dynamic casting will have to determine when the conformance it depends on is isolated to an actor and check whether the code is running on the executor for that actor.

I read this as saying the compiler "will have to determine", not the programmer. But maybe I'm wrong!

4 Likes

Smaller version of test function gets to crash it either for me:

func test() {
    let s = S()
    func open(_ foo: some Foo) {
        foo.bar()
    }
    open(s)
}

results in Fatal error: Incorrect actor executor assumption; Expected 'UnownedSerialExecutor(executor: (Opaque Value))' executor.

I think you're right that as? is missing the check required by the SE, but I also think there's another few holes in the SE:

Protocols that extend Sendable shouldn't be eligible for isolated conformances, else:

import Dispatch

protocol P: Sendable {
    func f()
}

@MainActor
struct S: @MainActor P {
    func f() {
        MainActor.preconditionIsolated()
    }
}

@MainActor
func example(_ s: any Sendable) {
    let p = s as! any P // on the main actor, so the cast is allowed dynamically
    DispatchQueue.global().async {
        p.f() // oops, sent it anyway
    }
}

example(S())
dispatchMain()

But even without Sendable, you can still get into trouble:

import Dispatch

protocol P {
    func f()
}

@MainActor
struct S: @MainActor P {
    func f() {
        MainActor.preconditionIsolated()
    }
}

@MainActor
func example(_ s: sending Any) {
    let p = s as! any P // cast is dynamically OK
    Task.detached {
        p.f() // oops, sending
    }
}

example(S())
dispatchMain()

For the second example, I don't see a solution except to have casts join the result to the region of the casting code?

(@Douglas_Gregor)

3 Likes

I went ahead and created an issue for this soundness hole: Soundness hole in isolated conformances · Issue #82539 · swiftlang/swift · GitHub

2 Likes

I've created an issue for the two knock-ons I found in this thread: Two soundness holes casting isolated conformances (SE-0470) · Issue #82550 · swiftlang/swift · GitHub

1 Like

Are you on an ABI-stable platform with an older runtime? If so, this is a known—and unpluggable—hole:

2 Likes

Ah, thank you for pointing out this extra info. Testing on a newer runtime does show the correct behavior.

And it's nice that this soundness hole is pointed out in the proposal under "ABI compatibility", but it would be even nicer to have a kind of "Important:" callout directly in the proposal where the stated information differs on older runtimes. Even something as simple as this.

4 Likes

Does this count as a soundness hole?

import Testing

protocol P {
  func f()
}

struct S: @MainActor P {
  @MainActor
  func f() {
    MainActor.assertIsolated()
  }
}

@Test @MainActor func main() async throws {
  let s = S()
  await takeP(s)
}

private func takeP(_ p: any P) async {
  _ = p.f()
}

This crashes in Swift 6 language mode and with the newest runtime (iOS 26). We are allowed to turn a @MainActor P into an any P and invoke a main actor bound method on a non-main thread.

I can’t tell if the proposal is allowing for this soundness hole, or if this is expected to be a compiler error.

1 Like

That should fail to compile on await takeP(s). This is Rule 1, and your example is very similiar to badFunc3 in the proposal.

Possible it's got confused because with nonisolated-nonsending-by-default, this would be safe? So the behavior of this check needs to depend on that other feature.

1 Like

Yeah, I think you’re right. This does look like a bug where @concurrent functions, and nonisolated functions when not using nonisolated(nonsending) by default, are not being checked as they should be.

cc @Douglas_Gregor

3 Likes

FWIW this is reproducible even with NonisolatedNonsendingByDefault enabled:

// iOS 26
// Swift 6.2
// NonisolatedNonsendingByDefault enabled
// nonisolated default isolation

import Testing

protocol P {
  func f()
}

struct S: @MainActor P {
  @MainActor
  func f() {
    MainActor.assertIsolated()
  }
}

@Test @MainActor func main() async throws {
  let s = S()
  await takeP(s)
}

@concurrent private func takeP(_ p: any P) async {
  _ = p.f()
}

This crashes because takeP can be called even though it is @concurrent.

It is even reproducible with NonisolatedNonsendingByDefault enabled and default main actor isolation:

// iOS 26
// Swift 6.2
// NonisolatedNonsendingByDefault enabled
// Default main actor isolation

import Testing

nonisolated protocol P {
  func f()
}

struct S: @MainActor P {
  @MainActor
  func f() {
    MainActor.assertIsolated()
  }
}

@Test @MainActor func main() async throws {
  let s = S()
  await takeP(s)
}

@concurrent private func takeP(_ p: any P) async {
  _ = p.f()
}

in the swift 6 language mode there appears to be a diagnostic warning about the risk when calling takeP() (godbolt) – are you seeing that with your configuration?

<source>:19:9: warning: sending 's' risks causing data races [#SendingRisksDataRace]
17 | @MainActor func main() async throws {
18 |   let s = S()
19 |   await takeP(s)
   |         |     `- note: isolated conformance to protocol 'P' can be introduced here
   |         |- warning: sending 's' risks causing data races [#SendingRisksDataRace]
   |         `- note: sending main actor-isolated 's' to @concurrent global function 'takeP' risks causing data races between @concurrent and main actor-isolated uses
20 | }
21 | 

[#SendingRisksDataRace]: <https://docs.swift.org/compiler/documentation/diagnostics/sending-risks-data-race>

Huh, no. There are no warnings or errors.

maybe double check that the language mode is set to 6? i think the issue here was intended to be diagnosed via the changes in this PR.

Yes, it is definitely compiling in Swift 6 language mode.