Skip to content

Commit e284ff5

Browse files
UncheckedSendable: AsyncSequence (#44)
* `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]>
1 parent 4fee5ec commit e284ff5

File tree

4 files changed

+66
-16
lines changed

4 files changed

+66
-16
lines changed

Sources/ConcurrencyExtras/AsyncStream.swift

+11
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ extension AsyncStream {
7171
}
7272
}
7373

74+
@available(*, deprecated, message: "Explicitly wrap the given async sequence with 'UncheckedSendable' first.")
75+
@_disfavoredOverload
76+
public init<S: AsyncSequence>(_ sequence: S) where S.Element == Element {
77+
self.init(UncheckedSendable(sequence))
78+
}
79+
7480
/// An `AsyncStream` that never emits and never completes unless cancelled.
7581
public static var never: Self {
7682
Self { _ in }
@@ -94,4 +100,9 @@ extension AsyncSequence {
94100
public func eraseToStream() -> AsyncStream<Element> where Self: Sendable {
95101
AsyncStream(self)
96102
}
103+
104+
@available(*, deprecated, message: "Explicitly wrap this async sequence with 'UncheckedSendable' before erasing to stream.")
105+
public func eraseToStream() -> AsyncStream<Element> {
106+
AsyncStream(UncheckedSendable(self))
107+
}
97108
}

Sources/ConcurrencyExtras/AsyncThrowingStream.swift

+11
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ extension AsyncThrowingStream where Failure == Error {
2626
}
2727
}
2828

29+
@available(*, deprecated, message: "Explicitly wrap the given async sequence with 'UncheckedSendable' first.")
30+
@_disfavoredOverload
31+
public init<S: AsyncSequence>(_ sequence: S) where S.Element == Element {
32+
self.init(UncheckedSendable(sequence))
33+
}
34+
2935
/// An `AsyncThrowingStream` that never emits and never completes unless cancelled.
3036
public static var never: Self {
3137
Self { _ in }
@@ -53,4 +59,9 @@ extension AsyncSequence {
5359
public func eraseToThrowingStream() -> AsyncThrowingStream<Element, Error> where Self: Sendable {
5460
AsyncThrowingStream(self)
5561
}
62+
63+
@available(*, deprecated, message: "Explicitly wrap this async sequence with 'UncheckedSendable' before erasing to throwing stream.")
64+
public func eraseToThrowingStream() -> AsyncThrowingStream<Element, Error> {
65+
AsyncThrowingStream(UncheckedSendable(self))
66+
}
5667
}

Sources/ConcurrencyExtras/UncheckedSendable.swift

+9
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ public struct UncheckedSendable<Value>: @unchecked Sendable {
5555
}
5656
}
5757

58+
extension UncheckedSendable: AsyncSequence where Value: AsyncSequence {
59+
public typealias AsyncIterator = Value.AsyncIterator
60+
public typealias Element = Value.Element
61+
62+
public func makeAsyncIterator() -> AsyncIterator {
63+
value.makeAsyncIterator()
64+
}
65+
}
66+
5867
#if swift(>=5.10)
5968
@available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available(
6069
macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead."

Tests/ConcurrencyExtrasTests/AsyncStreamTests.swift

+35-16
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,52 @@
44

55
@available(iOS 15, *)
66
private let sendable: @Sendable () async -> AsyncStream<Void> = {
7-
NotificationCenter.default
8-
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
9-
.map { _ in }
10-
.eraseToStream()
7+
UncheckedSendable(
8+
NotificationCenter.default
9+
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
10+
.map { _ in }
11+
)
12+
.eraseToStream()
13+
}
14+
15+
@available(iOS 15, *)
16+
private let sendableInitializer: @Sendable () async -> AsyncStream<Void> = {
17+
AsyncStream(
18+
UncheckedSendable(
19+
NotificationCenter.default
20+
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
21+
.map { _ in }
22+
)
23+
)
1124
}
1225

1326
@available(iOS 15, *)
1427
private let mainActor: @MainActor () -> AsyncStream<Void> = {
15-
NotificationCenter.default
16-
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
17-
.map { _ in }
18-
.eraseToStream()
28+
UncheckedSendable(
29+
NotificationCenter.default
30+
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
31+
.map { _ in }
32+
)
33+
.eraseToStream()
1934
}
2035

2136
@available(iOS 15, *)
2237
private let sendableThrowing: @Sendable () async -> AsyncThrowingStream<Void, Error> = {
23-
NotificationCenter.default
24-
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
25-
.map { _ in }
26-
.eraseToThrowingStream()
38+
UncheckedSendable(
39+
NotificationCenter.default
40+
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
41+
.map { _ in }
42+
)
43+
.eraseToThrowingStream()
2744
}
2845

2946
@available(iOS 15, *)
3047
private let mainActorThrowing: @MainActor () -> AsyncThrowingStream<Void, Error> = {
31-
NotificationCenter.default
32-
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
33-
.map { _ in }
34-
.eraseToThrowingStream()
48+
UncheckedSendable(
49+
NotificationCenter.default
50+
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
51+
.map { _ in }
52+
)
53+
.eraseToThrowingStream()
3554
}
3655
#endif

0 commit comments

Comments
 (0)