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()
}
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.
@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"