From 314ffc5a8a18007eed0e8a1ee795965b89d8db29 Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Sat, 9 May 2026 21:57:25 +0200 Subject: [PATCH 01/10] Add Enhancing-Async{Throwing}Stream proposal --- .../NNNN-Enhancing-Async{Throwing}Stream.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 proposals/NNNN-Enhancing-Async{Throwing}Stream.md diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md new file mode 100644 index 0000000000..7cf5c901d4 --- /dev/null +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -0,0 +1,328 @@ +# Enhancing `Async{Throwing}Stream` + +* Proposal: [SE-NNNN](NNNN-filename.md) +* Authors: [NotTheNHK](https://github.com/NotTheNHK) +* Review Manager: TBD +* Status: **Awaiting implementation** or **Awaiting review** +* Implementation: TBD +* Upcoming Feature Flag: StreamContinuationTracking +* Review: ([pitch](https://forums.swift.org/t/pitch-enhancing-async-throwing-stream/86339)) + +## Summary of changes + +This proposal introduces the following changes: + +1. Typed throws support for `AsyncThrowingStream`. +2. Update the unfolding initializer by adopting `nonisolated(nonsending)` and replacing `onCancel`’s `@Sendable` requirement with `sending`. +3. Terminate the stream when its continuation is discarded. +4. `Hashable` conformance for `Async{Throwing}Stream` and nested types. + +## Motivation + +### Typed Throws + +Thrown errors are type-erased to `any Error`, requiring additional boilerplate to preserve the thrown error's type and integrate into typed contexts. + +```swift +let locationStream = AsyncThrowingStream { ... } // Error: Initializer 'init(_:bufferingPolicy:_:)' requires the types 'LocationError' and 'any Error' be equivalent + +func processLocations() async throws(LocationError) { + for try await location in locationStream { // Error: Thrown expression type 'any Error' cannot be converted to error type 'LocationError' + ... + } +} +``` + +There are two suboptimal workarounds. + +1. Type cast: + +```swift +let locationStream = AsyncThrowingStream { ... } + +func processLocations() async throws(LocationError) { + do { + for try await location in locationStream { + ... + } + } catch { + throw error as! LocationError + } +} +``` + +2. Result type: + +```swift +let locationStream = AsyncStream> { ... } + +func processLocations() async throws(LocationError) { + for await result in locationStream { + switch result { + case .success(let location): + ... + case .failure(let locationError): + throw locationError + } + } +} +``` + +### Unfolding initializer + +[SE-0314](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0314-async-stream.md#detailed-design) proposed the following Unfolding initializers: + +```swift +// AsyncStream +public init( + unfolding produce: @escaping () async -> Element?, + onCancel: (@Sendable () -> Void)? = nil +) + +// AsyncThrowingStream +public init( + unfolding produce: @escaping () async throws -> Element?, + onCancel: (@Sendable () -> Void)? = nil +) +``` + +However, the `AsyncThrowingStream` variant was never implemented with an `onCancel` parameter, creating a discrepancy between the two APIs. + +Furthermore, [SE-0338](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md#proposed-solution) clarified the execution semantics of `nonisolated` asynchronous functions by specifying that such functions formally run on the Global Concurrent Executor (GCE), potentially introducing unnecessary actor hops. + +Additionally, the `@Sendable` requirement on `onCancel` is overly restrictive, as `onCancel` is invoked at most once and never concurrently with itself. + +```swift +let stream = AsyncStream { + ... +} onCancel: { + ... +} + +let throwingStream = AsyncThrowingStream { + ... +} // no `onCancel` parameter + +func process(on locationActor: isolated LocationActor) { // starts running on `locationActor` + let locationStream = AsyncStream { ... } + + for await location in locationStream { // implicit call to `produce`, hop off `locationActor` + locationActor.update(location) // hop back on `locationActor` + } +} +``` + +The `process(on:)` function is actor-isolated to its `locationActor` parameter. +This means its formal isolation is that of the passed-in actor instance. However, the for await-in loop implicitly calls the `nonisolated` asynchronous `produce` function-type parameter to receive the next element. + +As a result, `process(on:)` continuously hops off and back onto `locationActor` for each iteration. + +### Continuation and Stream Termination + +When the continuation of an active stream is discarded, task cancellation becomes the only way to terminate the stream. + +```swift +let stream = AsyncStream { continuation in + continuation.onTermination = { reason in + print(reason) + } + + for number in 0..<10 { + continuation.yield(number) + } +} // continuation discarded here + +for await element in stream { // indefinitely suspended + print(element) // prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 +} +``` + +Unless the consumer's task is cancelled, the for await-in loop remains indefinitely suspended. + +### `Hashable` conformance + +Extending `Hashable` conformance to `Async{Throwing}Stream` and its nested types would allow them to be used as stored properties or associated values in `Hashable`-conforming types, as `Dictionary` keys, and as elements of `Set`s. + +The inherited `Equatable` conformance from `Hashable` enables equality comparisons, which can be useful for testing. + +## Proposed solution + +### Typed Throws + +`AsyncThrowingStream` already defines a type parameter `Failure: Error`. Until now, `Failure` has been constrained to `any Error`. + +This proposal extends `AsyncThrowingStream` with new unconstrained initializers and a `makeStream` method, eliminating existing boilerplate and enabling seamless use in typed contexts. However, the existing `Failure == any Error` constraint cannot be lifted without breaking backward compatibility. + +```swift +let locationStream = AsyncThrowingStream { ... } + +func processLocations() async throws(LocationError) { + for try await location in locationStream { + ... + } +} +``` + +### Unfolding Initializer + +This proposal adds an `onCancel` parameter to the unfolding initializer of `AsyncThrowingStream`, aligning it with `AsyncStream` and with the original variant proposed in SE-0314. + +Additionally, this proposal adopts `nonisolated(nonsending)`. As described in [SE-0461](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md), this allows the `produce` closure to run on the caller’s actor, avoiding unnecessary actor hops. + +The `@Sendable` requirement on the `onCancel` closure is removed and replaced with the `sending` keyword. + +```swift +let locationStream = Async{Throwing}Stream { // consistent API + ... +} onCancel: { + ... +} + +for {try} await location in locationStream { // executes on the caller's actor + ... +} +``` + +### Stream termination when its continuation is discarded + +The continuation-based variant is updated to track outstanding references to the stream’s continuation, including the continuation itself and any copies of it. When the last reference to the continuation is discarded, the stream is canceled. + +The change is staged in via an upcoming feature flag (`StreamContinuationTracking`). + +```swift +let stream = AsyncStream { continuation in + continuation.onTermination = { reason in + print(reason) + } + + for number in 0..<10 { + continuation.yield(number) + } +} // continuation discarded here + +for await element in stream { + print(element) // prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 +} // `onTermination` invoked with `.cancelled` +``` + +`stream` is canceled after the for-in loop completes, since the continuation is discarded. + +## Detailed design + +Updated: + +```swift +extension AsyncStream { + init( + unfolding produce: nonisolated(nonsending) @escaping @Sendable () async -> Element?, + onCancel: sending (() -> Void)? = nil + ) +} + +extension AsyncThrowingStream { + public init( + unfolding produce: nonisolated(nonsending) @escaping @Sendable () async throws(Failure) -> Element?, + onCancel: sending (() -> Void)? = nil + ) where Failure == any Error +} +``` + +New: + +```swift +extension AsyncThrowingStream { + public init( + unfolding produce: nonisolated(nonsending) @escaping @Sendable () async throws(Failure) -> Element?, + onCancel: sending (() -> Void)? = nil + ) + + public init( + of elementType: Element.Type = Element.self, + throwing failureType: Failure.Type = Failure.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded, + _ build: (Continuation) -> Void + ) + + public static func makeStream( + of elementType: Element.Type = Element.self, + throwing failureType: Failure.Type = Failure.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: AsyncThrowingStream, continuation: AsyncThrowingStream.Continuation) +} +``` + +`Hashable` conformance: + +```swift +// AsyncStream + +extension AsyncStream: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.context === rhs.context + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self.context)) + } +} + +extension AsyncStream.Continuation.BufferingPolicy: Hashable {} + +extension AsyncStream.Continuation.YieldResult: Equatable, Hashable where Element: Equatable, Element: Hashable {} + +// AsyncThrowingStream + +extension AsyncThrowingStream: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.context === rhs.context + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self.context)) + } +} + +extension AsyncThrowingStream.Continuation.BufferingPolicy: Hashable {} + +extension AsyncThrowingStream.Continuation.YieldResult: Equatable, Hashable where Element: Equatable, Element: Hashable {} + +extension AsyncThrowingStream.Continuation.Termination: Equatable, Hashable where Failure: Hashable, Failure: Equatable {} +``` + +## Source compatibility + +This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`StreamContinuationTracking`). + +The `sending` keyword on `onCancel` will allow a wider range of functions and closures to be passed to it. + +## ABI compatibility + +Adopting `nonisolated(nonsending)` for `produce` and replacing `@Sendable` on `onCancel` is an ABI change. // TODO: Finish this + +## Implications on adoption + +Terminating the stream implicitly when the stream’s continuation is discarded would break code that relies on the current behavior, for example to create an indefinite suspension point. + +## Future directions + +### `~Copyable` Support + +In principle, it should be possible to support `~Copyable` types. But, several blockers currently prevent their adoption. +The key issue is the lack of support for iterating over a `~Copyable` sequence. +It is not as simple as declaring `{Async}Sequence`’s `Element` associated type as `~Copyable`. Changes to the compiler would be required. + +However, progress is being made in other areas. Swift Collections now includes multiple types that support `~Copyable` elements, such as `UniqueDeque` and, `UniqueArray`. There is also ongoing discussion about moving `UniqueArray` into the standard library. In addition, [SE-0528](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0528-noncopyable-continuation.md) introduced a `~Copyable` continuation type. + +## Alternatives considered +An alternative approach to staging in change Nr. 3 (“Terminate the stream when its continuation is discarded”) via an upcoming feature flag +is to introduce a new continuation-based initializer and `makeStream` method that explicitly signals this new behavior to the user. + +There are three problems with this approach: + +1. It would require introducing five additional initializer overloads and two `makeStream` methods. +2. To disambiguate them, this would require adding some form of clear differentiation. +3. It would not help with staging in the new behavior, as users of the API would need to switch to the new, more verbose, API +and the old, less verbose, API would eventually need to be deprecated. + +## Acknowledgments +I would like to thank @jamieQ for initial guidance and continued feedback, as well as @phausler and @FranzBusch for their feedback. From 6d0ac8366bf79e1e641470843f6fed536065aad8 Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Tue, 12 May 2026 12:46:22 +0200 Subject: [PATCH 02/10] Remove implementation details from proposal Co-authored-by: Konrad `ktoso` Malawski --- proposals/NNNN-Enhancing-Async{Throwing}Stream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index 7cf5c901d4..471d16ee2a 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -262,7 +262,7 @@ extension AsyncStream: Hashable { } public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self.context)) + // ... } } From c827dbd0b9c176e83f67dd28c0dc4d62dcdd327e Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Tue, 12 May 2026 12:48:27 +0200 Subject: [PATCH 03/10] Remove implementation details from proposal Co-authored-by: Konrad `ktoso` Malawski --- proposals/NNNN-Enhancing-Async{Throwing}Stream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index 471d16ee2a..f6205300ff 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -258,7 +258,7 @@ extension AsyncThrowingStream { extension AsyncStream: Hashable { public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.context === rhs.context + // ... } public func hash(into hasher: inout Hasher) { From da5c798f982a058f205741c9ddc3cc804724a612 Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Tue, 12 May 2026 13:03:24 +0200 Subject: [PATCH 04/10] Update spelling of the global concurrent executor to use the correct term Co-authored-by: Konrad `ktoso` Malawski --- proposals/NNNN-Enhancing-Async{Throwing}Stream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index f6205300ff..ca5baee490 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -88,7 +88,7 @@ public init( However, the `AsyncThrowingStream` variant was never implemented with an `onCancel` parameter, creating a discrepancy between the two APIs. -Furthermore, [SE-0338](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md#proposed-solution) clarified the execution semantics of `nonisolated` asynchronous functions by specifying that such functions formally run on the Global Concurrent Executor (GCE), potentially introducing unnecessary actor hops. +Furthermore, [SE-0338](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md#proposed-solution) clarified the execution semantics of `nonisolated` asynchronous functions by specifying that such functions formally run on the global concurrent executor, potentially introducing unnecessary actor hops. Additionally, the `@Sendable` requirement on `onCancel` is overly restrictive, as `onCancel` is invoked at most once and never concurrently with itself. From 76c17941a6d0c19ea86e5c9e6dd8aa3e52b14ba3 Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Tue, 12 May 2026 20:16:05 +0200 Subject: [PATCH 05/10] Update proposal based on received feedback --- .../NNNN-Enhancing-Async{Throwing}Stream.md | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index ca5baee490..062dd46ca4 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -5,7 +5,7 @@ * Review Manager: TBD * Status: **Awaiting implementation** or **Awaiting review** * Implementation: TBD -* Upcoming Feature Flag: StreamContinuationTracking +* Upcoming Feature Flag: `AsyncStreamCancelOnContinuationDeinit` * Review: ([pitch](https://forums.swift.org/t/pitch-enhancing-async-throwing-stream/86339)) ## Summary of changes @@ -21,7 +21,7 @@ This proposal introduces the following changes: ### Typed Throws -Thrown errors are type-erased to `any Error`, requiring additional boilerplate to preserve the thrown error's type and integrate into typed contexts. +Currently errors are type-erased to `any Error` when `AsyncThrowingStream` is finished with an error (`finish(throwing:)` or when the `unfolding` closure throws), requiring additional boilerplate to preserve the thrown error's type and integrate into typed contexts. ```swift let locationStream = AsyncThrowingStream { ... } // Error: Initializer 'init(_:bufferingPolicy:_:)' requires the types 'LocationError' and 'any Error' be equivalent @@ -172,7 +172,7 @@ Additionally, this proposal adopts `nonisolated(nonsending)`. As described in [S The `@Sendable` requirement on the `onCancel` closure is removed and replaced with the `sending` keyword. ```swift -let locationStream = Async{Throwing}Stream { // consistent API +let locationStream = Async{Throwing}Stream { ... } onCancel: { ... @@ -187,9 +187,11 @@ for {try} await location in locationStream { // executes on the caller's actor The continuation-based variant is updated to track outstanding references to the stream’s continuation, including the continuation itself and any copies of it. When the last reference to the continuation is discarded, the stream is canceled. -The change is staged in via an upcoming feature flag (`StreamContinuationTracking`). +The change is staged in via an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). ```swift +// with `AsyncStreamCancelOnContinuationDeinit` + let stream = AsyncStream { continuation in continuation.onTermination = { reason in print(reason) @@ -253,6 +255,8 @@ extension AsyncThrowingStream { `Hashable` conformance: +For `Async{Throwing}Stream` specifically, `Hashable` conformance is identity-based. Although it is a struct, it wraps a `context` class that is unique to each instance but shared across its copies. + ```swift // AsyncStream @@ -274,11 +278,11 @@ extension AsyncStream.Continuation.YieldResult: Equatable, Hashable where Elemen extension AsyncThrowingStream: Hashable { public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.context === rhs.context + // ... } public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self.context)) + // ... } } @@ -291,17 +295,17 @@ extension AsyncThrowingStream.Continuation.Termination: Equatable, Hashable wher ## Source compatibility -This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`StreamContinuationTracking`). +This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). -The `sending` keyword on `onCancel` will allow a wider range of functions and closures to be passed to it. +The `sending` keyword on `onCancel` broadens the set of functions and closures that can be passed to it. ## ABI compatibility -Adopting `nonisolated(nonsending)` for `produce` and replacing `@Sendable` on `onCancel` is an ABI change. // TODO: Finish this +The changes are additive. ## Implications on adoption -Terminating the stream implicitly when the stream’s continuation is discarded would break code that relies on the current behavior, for example to create an indefinite suspension point. +Implicitly terminating the stream when its continuation is discarded would break code that relies on the current behavior, for example to create an indefinite suspension point. That is the rationale for gating this change behind the upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). ## Future directions @@ -325,4 +329,4 @@ There are three problems with this approach: and the old, less verbose, API would eventually need to be deprecated. ## Acknowledgments -I would like to thank @jamieQ for initial guidance and continued feedback, as well as @phausler and @FranzBusch for their feedback. +I would like to thank @jamieQ for initial guidance and continued feedback, as well as @phausler, @FranzBusch, annd @ktoso for their feedback. From 135b9d5ac42900b1fcecd48312150288071958ea Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Wed, 13 May 2026 18:57:10 +0200 Subject: [PATCH 06/10] Fix typo in code sample --- proposals/NNNN-Enhancing-Async{Throwing}Stream.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index 062dd46ca4..3fe4ed17f6 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -103,11 +103,11 @@ let throwingStream = AsyncThrowingStream { ... } // no `onCancel` parameter -func process(on locationActor: isolated LocationActor) { // starts running on `locationActor` +func process(on locationActor: isolated LocationActor) async { // starts running on `locationActor` let locationStream = AsyncStream { ... } for await location in locationStream { // implicit call to `produce`, hop off `locationActor` - locationActor.update(location) // hop back on `locationActor` + locationActor.update(to: location) // hop back on `locationActor` } } ``` From 79704a641a46b33dad8a04de47980c4784566243 Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Fri, 15 May 2026 19:26:10 +0200 Subject: [PATCH 07/10] Update headers to use sentence-case --- proposals/NNNN-Enhancing-Async{Throwing}Stream.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index 3fe4ed17f6..9a42333aa6 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -19,7 +19,7 @@ This proposal introduces the following changes: ## Motivation -### Typed Throws +### Typed throws Currently errors are type-erased to `any Error` when `AsyncThrowingStream` is finished with an error (`finish(throwing:)` or when the `unfolding` closure throws), requiring additional boilerplate to preserve the thrown error's type and integrate into typed contexts. @@ -117,7 +117,7 @@ This means its formal isolation is that of the passed-in actor instance. However As a result, `process(on:)` continuously hops off and back onto `locationActor` for each iteration. -### Continuation and Stream Termination +### Continuation and stream termination When the continuation of an active stream is discarded, task cancellation becomes the only way to terminate the stream. @@ -147,7 +147,7 @@ The inherited `Equatable` conformance from `Hashable` enables equality compariso ## Proposed solution -### Typed Throws +### Typed throws `AsyncThrowingStream` already defines a type parameter `Failure: Error`. Until now, `Failure` has been constrained to `any Error`. @@ -163,7 +163,7 @@ func processLocations() async throws(LocationError) { } ``` -### Unfolding Initializer +### Unfolding initializer This proposal adds an `onCancel` parameter to the unfolding initializer of `AsyncThrowingStream`, aligning it with `AsyncStream` and with the original variant proposed in SE-0314. @@ -309,7 +309,7 @@ Implicitly terminating the stream when its continuation is discarded would break ## Future directions -### `~Copyable` Support +### `~Copyable` support In principle, it should be possible to support `~Copyable` types. But, several blockers currently prevent their adoption. The key issue is the lack of support for iterating over a `~Copyable` sequence. From f38ee56b53cd20cf347cdccff21ff11533a84ddd Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Fri, 22 May 2026 12:44:43 +0200 Subject: [PATCH 08/10] Add GitHub issues to the bug bullet point --- proposals/NNNN-Enhancing-Async{Throwing}Stream.md | 1 + 1 file changed, 1 insertion(+) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index 9a42333aa6..6e1ba4426d 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -4,6 +4,7 @@ * Authors: [NotTheNHK](https://github.com/NotTheNHK) * Review Manager: TBD * Status: **Awaiting implementation** or **Awaiting review** +* Bug: [#75853](https://github.com/swiftlang/swift/issues/75853), [#77974](https://github.com/swiftlang/swift/issues/77974) * Implementation: TBD * Upcoming Feature Flag: `AsyncStreamCancelOnContinuationDeinit` * Review: ([pitch](https://forums.swift.org/t/pitch-enhancing-async-throwing-stream/86339)) From d2132dfd6a19f71990ad1b17098b0a4a32df6d6f Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Fri, 22 May 2026 14:57:12 +0200 Subject: [PATCH 09/10] Refine proposal --- .../NNNN-Enhancing-Async{Throwing}Stream.md | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index 6e1ba4426d..d048bc8a7d 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -25,10 +25,10 @@ This proposal introduces the following changes: Currently errors are type-erased to `any Error` when `AsyncThrowingStream` is finished with an error (`finish(throwing:)` or when the `unfolding` closure throws), requiring additional boilerplate to preserve the thrown error's type and integrate into typed contexts. ```swift -let locationStream = AsyncThrowingStream { ... } // Error: Initializer 'init(_:bufferingPolicy:_:)' requires the types 'LocationError' and 'any Error' be equivalent +let locationStream = AsyncThrowingStream { ... } // Error: Initializer 'init(_:bufferingPolicy:_:)' requires the types 'LocationError' and 'any Error' be equivalent. func processLocations() async throws(LocationError) { - for try await location in locationStream { // Error: Thrown expression type 'any Error' cannot be converted to error type 'LocationError' + for try await location in locationStream { // Error: Thrown expression type 'any Error' cannot be converted to error type 'LocationError'. ... } } @@ -71,7 +71,7 @@ func processLocations() async throws(LocationError) { ### Unfolding initializer -[SE-0314](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0314-async-stream.md#detailed-design) proposed the following Unfolding initializers: +[SE-0314](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0314-async-stream.md#detailed-design) proposed the following unfolding initializers: ```swift // AsyncStream @@ -104,11 +104,11 @@ let throwingStream = AsyncThrowingStream { ... } // no `onCancel` parameter -func process(on locationActor: isolated LocationActor) async { // starts running on `locationActor` +func process(on locationActor: isolated LocationActor) async { // Starts running on `locationActor`. let locationStream = AsyncStream { ... } - for await location in locationStream { // implicit call to `produce`, hop off `locationActor` - locationActor.update(to: location) // hop back on `locationActor` + for await location in locationStream { // Implicit call to `produce`, hop off `locationActor`. + locationActor.update(to: location) // Hop back on `locationActor`. } } ``` @@ -131,10 +131,10 @@ let stream = AsyncStream { continuation in for number in 0..<10 { continuation.yield(number) } -} // continuation discarded here +} // Continuation discarded here. -for await element in stream { // indefinitely suspended - print(element) // prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 +for await element in stream { // Prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Afterwards, suspends indefinitely. + print(element) } ``` @@ -170,7 +170,7 @@ This proposal adds an `onCancel` parameter to the unfolding initializer of `Asyn Additionally, this proposal adopts `nonisolated(nonsending)`. As described in [SE-0461](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md), this allows the `produce` closure to run on the caller’s actor, avoiding unnecessary actor hops. -The `@Sendable` requirement on the `onCancel` closure is removed and replaced with the `sending` keyword. +The `@Sendable` requirement on the `onCancel` parameter is removed and replaced with the `sending` keyword, allowing a wider range of functions and closures to be passed to the parameter. ```swift let locationStream = Async{Throwing}Stream { @@ -179,8 +179,12 @@ let locationStream = Async{Throwing}Stream { ... } -for {try} await location in locationStream { // executes on the caller's actor - ... +func process(on locationActor: isolated LocationActor) async { // Starts running on `locationActor`. + let locationStream = AsyncStream { ... } + + for await location in locationStream { // Implicit call to `produce`, runs on `locationActor`. + locationActor.update(to: location) // Already running on `locationActor`, no hop needed. + } } ``` @@ -191,7 +195,7 @@ The continuation-based variant is updated to track outstanding references to the The change is staged in via an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). ```swift -// with `AsyncStreamCancelOnContinuationDeinit` +// With `AsyncStreamCancelOnContinuationDeinit`. let stream = AsyncStream { continuation in continuation.onTermination = { reason in @@ -201,11 +205,11 @@ let stream = AsyncStream { continuation in for number in 0..<10 { continuation.yield(number) } -} // continuation discarded here +} // Continuation discarded here. -for await element in stream { - print(element) // prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 -} // `onTermination` invoked with `.cancelled` +for await element in stream { // Prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Afterwards, `onTermination` invoked with `.cancelled`. + print(element) +} ``` `stream` is canceled after the for-in loop completes, since the continuation is discarded. @@ -260,7 +264,6 @@ For `Async{Throwing}Stream` specifically, `Hashable` conformance is identity-bas ```swift // AsyncStream - extension AsyncStream: Hashable { public static func == (lhs: Self, rhs: Self) -> Bool { // ... @@ -276,7 +279,6 @@ extension AsyncStream.Continuation.BufferingPolicy: Hashable {} extension AsyncStream.Continuation.YieldResult: Equatable, Hashable where Element: Equatable, Element: Hashable {} // AsyncThrowingStream - extension AsyncThrowingStream: Hashable { public static func == (lhs: Self, rhs: Self) -> Bool { // ... @@ -296,9 +298,7 @@ extension AsyncThrowingStream.Continuation.Termination: Equatable, Hashable wher ## Source compatibility -This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). - -The `sending` keyword on `onCancel` broadens the set of functions and closures that can be passed to it. +This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). Apart from this change, the proposed changes are additive. In particular, replacing `onCancel`’s `@Sendable` requirement with `sending` is less restrictive on the caller’s side. ## ABI compatibility @@ -319,14 +319,14 @@ It is not as simple as declaring `{Async}Sequence`’s `Element` associated type However, progress is being made in other areas. Swift Collections now includes multiple types that support `~Copyable` elements, such as `UniqueDeque` and, `UniqueArray`. There is also ongoing discussion about moving `UniqueArray` into the standard library. In addition, [SE-0528](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0528-noncopyable-continuation.md) introduced a `~Copyable` continuation type. ## Alternatives considered -An alternative approach to staging in change Nr. 3 (“Terminate the stream when its continuation is discarded”) via an upcoming feature flag -is to introduce a new continuation-based initializer and `makeStream` method that explicitly signals this new behavior to the user. + +An alternative approach to staging-in change Nr. 3 (“Terminate the stream when its continuation is discarded”) via an upcoming feature flag would be to introduce a new continuation-based initializer and `makeStream` method that explicitly signals this behavior to the user. There are three problems with this approach: 1. It would require introducing five additional initializer overloads and two `makeStream` methods. 2. To disambiguate them, this would require adding some form of clear differentiation. -3. It would not help with staging in the new behavior, as users of the API would need to switch to the new, more verbose, API +3. It would not help with staging-in the new behavior, as users of the API would need to switch to the new, more verbose, API and the old, less verbose, API would eventually need to be deprecated. ## Acknowledgments From ec5a8c3e28910a10238a6947de56d19a4adc1e23 Mon Sep 17 00:00:00 2001 From: NotTheNHK <172546110+NotTheNHK@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:35:06 +0200 Subject: [PATCH 10/10] Editorial refinements --- .../NNNN-Enhancing-Async{Throwing}Stream.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md index d048bc8a7d..c392fb078c 100644 --- a/proposals/NNNN-Enhancing-Async{Throwing}Stream.md +++ b/proposals/NNNN-Enhancing-Async{Throwing}Stream.md @@ -22,7 +22,7 @@ This proposal introduces the following changes: ### Typed throws -Currently errors are type-erased to `any Error` when `AsyncThrowingStream` is finished with an error (`finish(throwing:)` or when the `unfolding` closure throws), requiring additional boilerplate to preserve the thrown error's type and integrate into typed contexts. +Currently, thrown errors are type-erased to `any Error` when `AsyncThrowingStream` throws (either by calling the `finish(throwing:)` method or when the `unfolding` closure throws), requiring additional boilerplate to preserve the thrown error's type and integrate it into typed contexts. ```swift let locationStream = AsyncThrowingStream { ... } // Error: Initializer 'init(_:bufferingPolicy:_:)' requires the types 'LocationError' and 'any Error' be equivalent. @@ -87,7 +87,7 @@ public init( ) ``` -However, the `AsyncThrowingStream` variant was never implemented with an `onCancel` parameter, creating a discrepancy between the two APIs. +However, the `AsyncThrowingStream` variant never implemented the `onCancel` parameter, creating a discrepancy between the two APIs. Furthermore, [SE-0338](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md#proposed-solution) clarified the execution semantics of `nonisolated` asynchronous functions by specifying that such functions formally run on the global concurrent executor, potentially introducing unnecessary actor hops. @@ -102,7 +102,7 @@ let stream = AsyncStream { let throwingStream = AsyncThrowingStream { ... -} // no `onCancel` parameter +} // No `onCancel` parameter. func process(on locationActor: isolated LocationActor) async { // Starts running on `locationActor`. let locationStream = AsyncStream { ... } @@ -113,8 +113,8 @@ func process(on locationActor: isolated LocationActor) async { // Starts running } ``` -The `process(on:)` function is actor-isolated to its `locationActor` parameter. -This means its formal isolation is that of the passed-in actor instance. However, the for await-in loop implicitly calls the `nonisolated` asynchronous `produce` function-type parameter to receive the next element. +The `process(on:)` function is actor-isolated to its `locationActor` parameter, +Its formal isolation is that of the passed-in actor instance. However, the for await-in loop implicitly calls the `nonisolated` asynchronous `produce` function-type parameter to receive the next element. As a result, `process(on:)` continuously hops off and back onto `locationActor` for each iteration. @@ -150,9 +150,9 @@ The inherited `Equatable` conformance from `Hashable` enables equality compariso ### Typed throws -`AsyncThrowingStream` already defines a type parameter `Failure: Error`. Until now, `Failure` has been constrained to `any Error`. +`AsyncThrowingStream` already defines a type parameter `Failure: Error`, which until now has been constrained to `any Error`. -This proposal extends `AsyncThrowingStream` with new unconstrained initializers and a `makeStream` method, eliminating existing boilerplate and enabling seamless use in typed contexts. However, the existing `Failure == any Error` constraint cannot be lifted without breaking backward compatibility. +Because the existing `Failure == any Error` constraint cannot be lifted without breaking backward compatibility, this proposal extends `AsyncThrowingStream` with new unconstrained initializers and a `makeStream` method, eliminating existing boilerplate and enabling seamless use in typed contexts. ```swift let locationStream = AsyncThrowingStream { ... } @@ -166,11 +166,11 @@ func processLocations() async throws(LocationError) { ### Unfolding initializer -This proposal adds an `onCancel` parameter to the unfolding initializer of `AsyncThrowingStream`, aligning it with `AsyncStream` and with the original variant proposed in SE-0314. +This proposal adds the missing `onCancel` parameter to the unfolding initializer of `AsyncThrowingStream`, aligning it with `AsyncStream` and with the original variant proposed in SE-0314. -Additionally, this proposal adopts `nonisolated(nonsending)`. As described in [SE-0461](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md), this allows the `produce` closure to run on the caller’s actor, avoiding unnecessary actor hops. +Additionally, this proposal adopts `nonisolated(nonsending)`. As described in [SE-0461](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md), this allows the `produce` closure to run on the caller’s actor, and avoids unnecessary actor hops. -The `@Sendable` requirement on the `onCancel` parameter is removed and replaced with the `sending` keyword, allowing a wider range of functions and closures to be passed to the parameter. +The `@Sendable` requirement on the `onCancel` parameter is replaced with the `sending` keyword; this allows a wider range of functions and closures to be passed to the parameter wihtout the risk of a data race. ```swift let locationStream = Async{Throwing}Stream { @@ -190,9 +190,9 @@ func process(on locationActor: isolated LocationActor) async { // Starts running ### Stream termination when its continuation is discarded -The continuation-based variant is updated to track outstanding references to the stream’s continuation, including the continuation itself and any copies of it. When the last reference to the continuation is discarded, the stream is canceled. +The continuation-based `Async{Throwing}Stream` variant is modified to track outstanding references to the stream’s continuation, including the continuation itself and any copies of it. When the last reference to the continuation is discarded, the stream is canceled. -The change is staged in via an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). +This change is staged in via an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). ```swift // With `AsyncStreamCancelOnContinuationDeinit`. @@ -207,12 +207,12 @@ let stream = AsyncStream { continuation in } } // Continuation discarded here. -for await element in stream { // Prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Afterwards, `onTermination` invoked with `.cancelled`. +for await element in stream { // Prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Afterwards, `onTermination` is invoked with `.cancelled`. print(element) } ``` -`stream` is canceled after the for-in loop completes, since the continuation is discarded. +Because the continuation is discarded after the for-in loop completes, `stream` is canceled. ## Detailed design @@ -298,7 +298,7 @@ extension AsyncThrowingStream.Continuation.Termination: Equatable, Hashable wher ## Source compatibility -This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). Apart from this change, the proposed changes are additive. In particular, replacing `onCancel`’s `@Sendable` requirement with `sending` is less restrictive on the caller’s side. +This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). Apart from this change, the proposed changes are additive. In particular, replacing `onCancel`’s `@Sendable` requirement with `sending` makes it less restrictive for the caller while still preventing data races. ## ABI compatibility @@ -306,7 +306,7 @@ The changes are additive. ## Implications on adoption -Implicitly terminating the stream when its continuation is discarded would break code that relies on the current behavior, for example to create an indefinite suspension point. That is the rationale for gating this change behind the upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). +The rationale for gating this change behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`) is that implicitly terminating the stream when its continuation is discarded would break code that relies on the current behavior, for example to create an indefinite suspension point. ## Future directions @@ -330,4 +330,4 @@ There are three problems with this approach: and the old, less verbose, API would eventually need to be deprecated. ## Acknowledgments -I would like to thank @jamieQ for initial guidance and continued feedback, as well as @phausler, @FranzBusch, annd @ktoso for their feedback. +I would like to thank @jamieQ for initial guidance and continued feedback, as well as @FranzBusch, @ktoso, and @phausler for their feedback.