Please help me understand this compiler error when using async sequence with existential type / boxed type (Swift 6)

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?

3 Likes