I have these two block of codes, the second one results in compiler error on the last line even though the only difference is the for await block. What gives? I’m on Swift 6 language mode
// compiles fine
Task { @MainActor in
let defaultNotif = Foundation.NotificationCenter.default.notifications(named: .NSAppleEventManagerWillProcessFirstEvent) as! AsyncSequence<Notification, Never>
var it2 = defaultNotif.makeAsyncIterator()
_ = try await it2.next() // only warning of data race
}
// compile error!
Task { @MainActor in
let defaultNotif = Foundation.NotificationCenter.default.notifications(named: .NSAppleEventManagerWillProcessFirstEvent) as! AsyncSequence<Notification, Never>
var it2 = defaultNotif.makeAsyncIterator()
for await val in defaultNotif {
print(val)
break
}
_ = try await it2.next() // <- compiler error, sending it2 risk causing data races; Sending main actor-isolated 'it2' to @concurrent instance method 'next()' risks causing data races between @concurrent and main actor-isolated uses
}
Additionally, the compile error doesn’t happen when my AsyncSequence is concrete type, even though I cast my async iterator using as! AsyncIteratorProtocol<Notification, Never>
// compiles fine
Task { @MainActor in
let defaultNotif = Foundation.NotificationCenter.default.notifications(named: .NSAppleEventManagerWillProcessFirstEvent)
var it2 = defaultNotif.makeAsyncIterator() as! AsyncIteratorProtocol<Notification, Never>
for await val in defaultNotif {
print(val)
break
}
_ = try await it2.next() // no warning
}
let's consider a stripped-down example that exhibits similar behavior, without involving any closed-source code:
open class NS {} // non-Sendable type
@MainActor
func doit(
_ seq: any AsyncSequence<NS, Never>
) async throws {
var it: any AsyncIteratorProtocol<NS, Never> = seq.makeAsyncIterator()
_ = try await it.next() // 🛑 error: sending 'it' risks causing data races
}
AsyncIteratorProtocol is a bit unusual because it effectively has two versions of the same requirement for the next() method, one of which was introduced in some sense to solve this exact problem. you can read the details in SE-421 if interested, but this quote from the motivation section is essentially the issue you've run into:
Additionally, AsyncSequence types are designed to work with Sendable and non-Sendable element types, but it's currently impossible to use an AsyncSequence with non-Sendable elements in an actor-isolated context:
class NotSendable { ... }
@MainActor
func iterate(over stream: AsyncStream<NotSendable>) {
for await element in stream { // warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
}
}
Because AsyncIteratorProtocol.next() is nonisolated async, it always runs on the generic executor, so calling it from an actor-isolated context crosses an isolation boundary. If the result is non-Sendable, the call is invalid under strict concurrency checking.
in the case that the iterator's concrete type is hidden behind an existential, it's not entirely clear to me why, but it seems the compiler emits calls to the original next() requirement (not the isolation-inheriting overload). i think this may be because some types may only implement that requirement, and in the existential case the compiler may have to conservatively assume that's the only one available? not totally sure if this behavior is entirely necessary though (see below regarding for-await)... it's possible this is an implementation bug/limitation – maybe someone more knowledgeable can weigh in.
if the callsite is changed to explicitly use the isolated overload, then the issue is resolved:
@MainActor
func doit2(
_ seq: any AsyncSequence<NS, Never>
) async throws {
var it: any AsyncIteratorProtocol<NS, Never> = seq.makeAsyncIterator()
_ = await it.next(isolation: #isolation) // ✅
// also note that this can be performed without a `try` because the
// isolated requirement also uses typed-throws so knows an error can
// never be produced
}
i think this also explains why the for-await loop doesn't exhibit the same problem – since the compiler gets to synthesize that code, it can choose to generate calls to the isolation-inheriting version of next(), rather than the legacy API:
@MainActor
func doit3(
_ seq: any AsyncSequence<NS, Never>
) async throws {
for await _ in seq { // ✅ – generated calls occur to the iterator's next(isolation:) method
break
}
}
as for why the first example only produces a warning... i'm not certain, but i suspect it may have something to do with the fact that some of the Notifications API is marked @preconcurrency (but i haven't investigated much, so this is mostly a guess).
i was unable to replicate this behavior locally – what environment were you using that exhibited it?
The AsyncIteratorProtocol has since Swift 6.0 two versions of the next() method, the original non-isolated (@concurrent) one and an updated one which does inherit isolation through the revamped isolated keyword. From what I can see, the compiler selects the original version which switches off the @MainActor executor to the global concurrent executor, thus crossing an isolation boundary.
For example, when manually selecting the correct next() method the program compiles:
Task { @MainActor in
let defaultNotif = Foundation.NotificationCenter.default.notifications(named: .NSAppleEventManagerWillProcessFirstEvent) as (any AsyncSequence<Notification, Never>)
var it2 = defaultNotif.makeAsyncIterator()
for await val in defaultNotif {
print(val)
break
}
_ = await it2.next(isolation: #isolation)
}
I could replicate it, Xcode 26.2, Swift 6 default settings. I assume Swift switches to the correct next() version? Perhaps a bug? Furthermore shouldn’t the compiler switch to the right version when the try keyword is removed? Instead it errors with a missing try keyword error:
_ = await it2.next() // Call can throw, but it is not marked with 'try' and the error is not handled
thanks for the replies, I understand swift 6 and isolation domain a little better now (except for why sometimes next is called with the correct version and sometimes not)
I’m on xcode 26.2 as well.
It seems, when calling next() directly, the compiler always chooses the backwards compatible version over the isolated one, which would be overly conservative if that is the case. But what confuses me here, is that removing the try keyword on next() for AsyncSequences that don't throw results in a compiler error. It seems to me as if the compiler is not aware of the second implementation? Perhaps someone more knowledgeable could chime in here.
after thinking about it a bit more, i'm not sure the compiler can actually just pick the isolated overload because there’s not a default parameter provided in the default implementation currently. not sure if adding one would be possible without additional adverse consequences...
You're right, I could have sworn it did have a default argument, after rereading SE-421 which does mention both, the reason for not having #isolation as the default argument and adding #isolation as the default argument for a future direction. Perhaps it’s worth opening a separate discussion about this? @John_McCall@hborla
When the sequence is used in a for await loop, a synthesized version of next(isolation:) with the argument #isolation is used. In contrast, when calling next() directly, the version you choose, i.e., next() or next(isolation:) is used.