Why no compiler error for this Swift Concurrency/Combine code?

Hi there,

The sample code below compiles fine in both Swift 5 and Swift 6 language modes with no errors or warnings. I would expect it to fail with an error or warning because the sink closure attempts to call the receiveValue method which as a global actor isolated method should not be possible from a synchronous, non-isolated context (which has also been requested to be dynamically called on the main queue).

If I run this code in Swift 5 with the thread sanitiser, I get an error as expected since there is a race reading/writing copyValue from multiple threads.

If I run this code in Swift 6 (even without the thread sanitiser), I get a runtime exception: "Incorrect actor executor assumption".

All of that makes sense, but why does is the compiler not able to determine this statically? I tried building my own code which receives a closure similarly to Combine, and in this context, the issue is correctly flagged at compile time with an error (or warning in Swift 5). To get it to build, I had to make the closure @Sendable.

I checked the Combine API and I can't see any sign of the @Sendable annotation on the sink closure, nor can I see any instances of @unchecked Sendable or @preconcurrency which might be an "escape hatch".

So this leaves the question about what is special about Combine? Is there a special hack in the compiler to avoid breaking existing pre-concurrency code?

Thanks
Matt.

import SwiftUI
import Foundation
import Combine

@globalActor actor MyActor {
    static var shared: some Actor = MyActor()
}

@MyActor
class MyClass {
    var sub: AnyCancellable?
    var value = CurrentValueSubject<Int, Never>(1)
    var copyValue: Int = 0

    nonisolated init() {
        Task {
            await setup()
        }
    }

    func setup() {
        sub = value
            .receive(on: DispatchQueue.main)
            .sink {
                self.receiveValue(value: $0)
            }
    }

    func receiveValue(value: Int) {
        print("receiveValue thread = \(Thread.current)")
        copyValue = value
    }

    func set(value: Int) {
        print("set thread = \(Thread.current)")
        self.value.send(value)
        for _ in 0..<1000 {
            print(copyValue)
        }
    }

}

struct ContentView: View {
    @State var obj = MyClass()
    var body: some View {
        VStack {
            Button("Go") {
                Task {
                    await obj.set(value: await obj.copyValue + 1)
                    print("Value = \(await obj.copyValue)")
                }
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Looks like an issue in diagnostics to me. Worth filing an issue on a GitHub.

Combine's swiftinterface doesn't contain anything tricky :)

Hi @mjholgate, this is discussed in this part of the Swift 6 migration guide. It is a sharp edge when migrating, but it's just how things are right now. Because MyClass is isolated to @MyActor, the sink trailing closure is automatically inferred to be @MyActor too, even though it is not. If you mark the trailing closure as @Sendable, then you will get a compiler error forcing you to provide isolation to access self.

This doesn't have anything to do with Combine, and affects all pre-concurrency code out there.

3 Likes

@mbrandonw just to check my understanding here then, this sharp edge exists because Combine is a Swift 5 module and is being called from a Swift 6 module?

So, I guess if Combine were recompiled by Apple in Swift 6 mode, then it simply wouldn't build in its current form because Swift would reason that it was trying to use a non-Sendable closure across isolation domains?

i.e. to get full static concurrency checking, the entire project and its dependencies needs to be Swift 6 (and not Swift 5/Obj-C, presumably?).

I did a modification to my test code to prove this out using two modules, and it seems to hold true:

Module (Swift 5) (a trivial replacement for Combine)

public class MySubject {
    public var value: Int
    public var closure: ((Int) -> ())?
    
    public init(_ value: Int) {
        self.value = value
    }
    
    public func send(_ value: Int) {
        self.value = value
        DispatchQueue.main.async {
            self.closure?(value)
        }
    }
}

App (Swift 6)

@globalActor actor MyActor {
    static var shared: some Actor = MyActor()
}

@MyActor
class MyClass {
    var sub: AnyCancellable?
    let value = MySubject(0)
    var copyValue: Int = 0

    nonisolated init() {
        Task {
            await setup()
        }
    }

    func setup() {
        value.closure = {
            self.copyValue = $0
            print($0)
        }
    }

    func set(value: Int) {
        self.value.send(value)
        for _ in 0..<1000 {
            print(copyValue)
        }
    }

}

struct ContentView: View {
    @State var obj = MyClass()
    var body: some View {
        VStack {
            Button("Go") {
                Task {
                    await obj.set(value: await obj.copyValue + 1)
                    print("Value = \(await obj.copyValue)")
                }
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

As expected, this compiles fine, but throws an exception at runtime.
If I change the languge version of Module to Swift 6, also as expected, it won't build (I get a "Sending 'self' risks causing data races") in the body of the DispatchQueue.main.async.

Thanks for the very helpful replies and for clearing up my understanding here.

FWIW, I took this a bit further, and tried to make a version of MySubject in Swift 6:

public class MySubject: @unchecked Sendable {
    private var value: Int
    private var _closure: ((Int) -> ())?
    public var closure: ((@Sendable (Int) -> ()))? {
        get {
            fatalError("Not implemented")
        }
        set {
            DispatchQueue.main.async {
                self._closure = newValue
            }
        }
    }
    
    public init(_ value: Int) {
        self.value = value
    }
    
    public func send(_ value: Int) {
        DispatchQueue.main.async {
            self.value = value
            self._closure?(value)
        }
    }
}

Pleased to report that with this Swift 6 implementation, I get the expected error in the App:

"Global actor 'MyActor'-isolated property 'copyValue' can not be mutated from a Sendable closure"

i.e. the sharp edge has gone and is detected.

Yeah, that is my understanding.

Thanks!