Inference of isolated conformances

The following code works fine without InferIsolatedConformances (with all conformances being nonisolated), but breaks under InferIsolatedConformances (S: P and S: R are inferred as isolated, but S: Q as nonisolated).

protocol P {
    @MainActor func foo()
}
@MainActor protocol Q: P {}
protocol R: P {}

@MainActor struct S {
    nonisolated init() {}
    func foo() {}
}

extension S: Q {} // error: conformance of 'S' to protocol 'Q' crosses into main actor-isolated code and can cause data races 
extension S: R {} // ok

Reading the code, I see that conformance S: Q is not inferred to be isolated, because Q itself is actor-isolated, as implemented here.

I'm not sure what is the rationale for this, logic, but I guess it assumes that all requirements in actor-isolated protocol are also actor-isolated, and thus are not affected by isolation of the conformance.

But this assumption is not correct. Protocol can be actor-isolated, and still have nonisolated requirements. And vice versa! So instead of "protocol is non-isolated" the check should be "protocol has at least one nonisolated requirement".

// no need to isolate conformance
protocol A1 {}
@MainActor protocol A2 {}
protocol A3 {
    @MainActor func foo()
}
protocol A4: A3 {}

// need to consider isolating conformance
protocol B1 {
    func foo()
}
@MainActor protocol B2 {
    nonisolated func foo()
}
protocol B3 {
    @MainActor func foo()
    func bar()
}

Or maybe even skipping this pre-check step, and apply logic that "if there exists an isolated witness to nonisolated requirement" - then infer conformance to be isolated, if not - as nonisolated?

I'm not sure how to deal with @concurrent requirements. They are nonisolated, but they also have their own executor.

@Douglas_Gregor, what do you think?

I think I could do this change. Would it count as a bug fix, or does it warrant an evolution proposal?

1 Like

Thinking further about @concurrent requirements, I was able to construct an example with a data race which does not trigger any diagnostics:

class NS {
	var k: Int = 42
}

protocol P {
	@concurrent func foo(_ ns: NS) async
}

@MainActor
class C: @MainActor P {
	var objects: [NS] = []

	@MainActor func foo(_ ns: NS) async {
		ns.k += 1
		objects.append(ns)
	}
}

@concurrent func doIt<T: P>(_ x: T) async {
	let ns = NS()
	async let _ = await x.foo(ns) // appends to array on @MainActor
	ns.k += 1 // increments on generic executor
}

@MainActor func test() async {
	let c = C()
	await doIt(c)
	for ns in c.objects {
		ns.k += 1
	}
}
2 Likes

Actually the problem here is that compiler allows conformance C: P to escape from @MainActor to @concurrent isolation.

But to my surprise non-sendable objects are also allowed to escape this way. The following example compiles without diagnostics in Swift 6 mode using fresh master build and when executed produces different results:

class NS {
    var k: Int = 0
}

@MainActor func test() async {
    let ns = NS()
    let t = Task { @MainActor () -> Void in
        for _ in 0..<10000 {
            ns.k += 1
        }
    }
    await probe(ns)
    _ = await t.value
    print(ns.k)
}

@concurrent func probe(_ ns: NS) async {
    for _ in 0..<10000 {
        ns.k -= 1
    }
}

await test()

Re-reading SE-0414, I see that it it allows to temporary borrow disconnected regions by nonisolated functions, but I don't see anything that would allow passing connected regions to nonisolated functions. So this looks like yet another bug.

2 Likes

Now, assuming that the problem of escaping conformance is fixed, I'm trying to think again about @concurrent requirements.

Requirement must be a function, property or a subscript, because associated types cannot be @concurrent or actor-isolated.

If requirement is does not have arguments (other than self/Self) or results of non-sendable types, then actor-isolated witness can satisfy the requirement even with nonisolated conformance.

If there are non-sendable arguments or results, then either these requirements are not callable, because rules about isolated conformances prevent values and meta types from leaving the actor isolation. Or they are unsafe, and should not be accepted.

So, my conclusion is that @concurrent requirements should be treated by the isolated conformance inference and checking the same way as isolated ones - they don't make protocol eligible for inference, and isolation of the conformance does not alter their isolation.

1 Like

I can’t give you an answer, but very interesting thread. I did some of my own testing and found this:

protocol P { // `P` is by default nonisolated, but `f` is explicitly marked @MainActor isolated.
    @MainActor func f() async
}

struct Foo: P {}

extension Foo {
    func f() async { // Type is: () async -> Void
        MainActor.preconditionIsolated() // Crash
    }
}

@MainActor
protocol G { // `G` is by default MainActor isolated, `f` implicitly inherits static @MainActor isolation
    func f() async
} 

struct Bar: G {}

extension Bar {
    func f() async { // Type is: @MainActor () async -> Void
        MainActor.preconditionIsolated() // Fine
    }
}

Given how fragile this is, I assume it’s a bug.