I just fixed a bug in my app, where a CurrentValueSubject would not publish a value on the main actor, as I expected.
The relevant code was:
// subject is MainActor-isolated
@MainActor let subject = CurrentValueSubject(...)
// In a non-isolated function,
// I await the subject before I send a value in it,
// expecting the value to be published on
// the main actor:
await subject.send(...)
Well, send is not called on the main actor, as I could witness (see below for a reproducing case).
The fix was to explicitly call send from the MainActor:
I can't tell if this is a compiler bug (Xcode 16.2, 16C5032a), or if this is expected (but surprising and borderline hostile).
If anyone is interested, this executable reproduces the problem:
/// This class takes place of CurrentValueSubject.
/// It is not sendable, and in our particular case we
/// want to make sure its `send` method is called on the
/// main actor.
class MySubject {
func send() {
MainActor.assumeIsolated {
print("Good: on main thread")
}
}
}
/// A class that has a MainActor-isolated subject, and calls its `send`
/// method from a non-isolated context.
final class Context: Sendable {
@MainActor let subject = MySubject()
init() { }
func send() async {
await subject.send()
}
}
@main struct App {
static func main() async {
let context = Context()
// Will it crash, or print "Good: on main thread"?
// Well, it crashes.
await context.send()
}
}
I can't tell if this is a compiler bug (Xcode 16.2, 16C5032a), or if this is expected (but surprising and borderline hostile).
It's a bug. The compiler, in Swift 6 language mode, should not allow the non-Sendable subject to cross isolation domains:
// @MainActor let subject = MySubject()
await subject.send()
// <-----> Main actor
// <----> Somewhere else, as proven by the reproducing example
The only correct options are:
Either call send() on the main actor.
Either refuse to compile and emit a diagnostic.
To go further: if Region-Based-Isolation would allow, in some circumstances, to accept such code, then consistency would strongly suggest that send() is always called on the main actor, in order to avoid surprises and subtle/unexpected behavior changes.
There we are: a crasher that involves sending. No compiler warning, no compiler error, but a runtime crash:
/// This class takes place of CurrentValueSubject.
/// It is not sendable, and in our particular case we
/// want to make sure its `send` method is called on the
/// main actor.
class MySubject {
func send() {
MainActor.assumeIsolated {
print("Good: on main thread")
}
}
}
@available(*, unavailable)
extension MySubject: Sendable { }
/// A class that builds a MainActor-isolated subject, and calls its `send`
/// method from a non-isolated context.
final class Context: Sendable {
@MainActor func makeSubject() -> sending MySubject {
MySubject()
}
func send() async {
await makeSubject().send()
}
}
@main struct App {
static func main() async {
let context = Context()
// Will it crash? Yes it crashes.
await context.send()
}
}
The crash is to be expected, this is from the docs of assumeIsolated:
If the current context is not running on the actor’s serial executor, or if the actor is a reference to a remote actor, this method will crash with a fatal error (similar to preconditionIsolated()).
MySubject.send is a synchronous non-isolated function, meaning it will inherit the isolation of the caller. Context.send is an asynchronous non-isolated function, meaning it will run on the global executor.
makeSubject's isolation is not really relevant in this case. Just the creation of MySubject will be MainActor-isolated. After that it will switch back to the global executor and run MySubject.send on there.
You'd need to annotate Context.send with @MainActor. You will then also be able to drop the await in front of mySubject().send().
That's fine. And to be honest, my initial reproducing code does not create any warning in the Swift 5 language mode, but does in the Swift 6 language mode.
The second crasher compiles fine in the Swift 6 language mode, and is an actual bug.
To sum up: thank you @ph1ps for your correct analysis. I was developing in the Swift 5 language mode, and I was bitten. I still think that the second sample code can surprise a lot of people, and is borderline hostile.
any thoughts on ways this could be surfaced as potential unexpected behavior? perhaps the key issue in this case is that it's ambiguous at the callsite as to what exactly is being awaited. e.g.
// is it this?
(await makeSubject()).send() // âś…
// or this?
await (await makeSubject()).send() // ⚠️: no 'async' operations occur within 'await' expression
not sure if there is a pattern here that would be generically useful to detect, but maybe there is the seed of some form of lint rule?