Skip to content

Commit

Permalink
UncheckedSendable: AsyncSequence (#44)
Browse files Browse the repository at this point in the history
* `UncheckedSendable: AsyncSequence`

Currently, a non-sendable sequence cannot be erased to `AsyncStream`
using this library's initializers even if one takes care to traffic it
through via `nonisolated(unsafe)`:

```swift
nonisolated(unsafe) let nonSendable = nonSendable
AsyncStream(nonSendable)  // 🛑
```

This conditional conformance acts as a workaround:

```swift
AsyncStream(UncheckedSendable(nonSendable))
```

Ideally folks can stop using our concrete async sequence eraser in favor
of `any AsyncSequence<Element, Failure>`, but this requires a minimum
deployment target of iOS 18, so it won't be an option for many people
for some time.

* Xcode 15 support

* add deprecation

* fix wasm

* Update AsyncStream.swift

* Update AsyncThrowingStream.swift

---------

Co-authored-by: Brandon Williams <[email protected]>
  • Loading branch information
stephencelis and mbrandonw authored Oct 14, 2024
1 parent 4fee5ec commit e284ff5
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 16 deletions.
11 changes: 11 additions & 0 deletions Sources/ConcurrencyExtras/AsyncStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ extension AsyncStream {
}
}

@available(*, deprecated, message: "Explicitly wrap the given async sequence with 'UncheckedSendable' first.")
@_disfavoredOverload
public init<S: AsyncSequence>(_ sequence: S) where S.Element == Element {
self.init(UncheckedSendable(sequence))
}

/// An `AsyncStream` that never emits and never completes unless cancelled.
public static var never: Self {
Self { _ in }
Expand All @@ -94,4 +100,9 @@ extension AsyncSequence {
public func eraseToStream() -> AsyncStream<Element> where Self: Sendable {
AsyncStream(self)
}

@available(*, deprecated, message: "Explicitly wrap this async sequence with 'UncheckedSendable' before erasing to stream.")
public func eraseToStream() -> AsyncStream<Element> {
AsyncStream(UncheckedSendable(self))
}
}
11 changes: 11 additions & 0 deletions Sources/ConcurrencyExtras/AsyncThrowingStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ extension AsyncThrowingStream where Failure == Error {
}
}

@available(*, deprecated, message: "Explicitly wrap the given async sequence with 'UncheckedSendable' first.")
@_disfavoredOverload
public init<S: AsyncSequence>(_ sequence: S) where S.Element == Element {
self.init(UncheckedSendable(sequence))
}

/// An `AsyncThrowingStream` that never emits and never completes unless cancelled.
public static var never: Self {
Self { _ in }
Expand Down Expand Up @@ -53,4 +59,9 @@ extension AsyncSequence {
public func eraseToThrowingStream() -> AsyncThrowingStream<Element, Error> where Self: Sendable {
AsyncThrowingStream(self)
}

@available(*, deprecated, message: "Explicitly wrap this async sequence with 'UncheckedSendable' before erasing to throwing stream.")
public func eraseToThrowingStream() -> AsyncThrowingStream<Element, Error> {
AsyncThrowingStream(UncheckedSendable(self))
}
}
9 changes: 9 additions & 0 deletions Sources/ConcurrencyExtras/UncheckedSendable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ public struct UncheckedSendable<Value>: @unchecked Sendable {
}
}

extension UncheckedSendable: AsyncSequence where Value: AsyncSequence {
public typealias AsyncIterator = Value.AsyncIterator
public typealias Element = Value.Element

public func makeAsyncIterator() -> AsyncIterator {
value.makeAsyncIterator()
}
}

#if swift(>=5.10)
@available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available(
macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead."
Expand Down
51 changes: 35 additions & 16 deletions Tests/ConcurrencyExtrasTests/AsyncStreamTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,52 @@

@available(iOS 15, *)
private let sendable: @Sendable () async -> AsyncStream<Void> = {
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
.eraseToStream()
UncheckedSendable(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
.eraseToStream()
}

@available(iOS 15, *)
private let sendableInitializer: @Sendable () async -> AsyncStream<Void> = {
AsyncStream(
UncheckedSendable(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
)
}

@available(iOS 15, *)
private let mainActor: @MainActor () -> AsyncStream<Void> = {
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
.eraseToStream()
UncheckedSendable(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
.eraseToStream()
}

@available(iOS 15, *)
private let sendableThrowing: @Sendable () async -> AsyncThrowingStream<Void, Error> = {
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
.eraseToThrowingStream()
UncheckedSendable(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
.eraseToThrowingStream()
}

@available(iOS 15, *)
private let mainActorThrowing: @MainActor () -> AsyncThrowingStream<Void, Error> = {
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
.eraseToThrowingStream()
UncheckedSendable(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
.eraseToThrowingStream()
}
#endif

0 comments on commit e284ff5

Please sign in to comment.