In my Swift 5 project I have this extension to pass an async
function to Publisher.map
:
import Combine
public extension Publisher {
func asyncMap<T>(
_ asyncFunc: @escaping (Output) async -> T
) -> Publishers.FlatMap<Future<T, Never>, Self> {
flatMap { value in
Future { promise in
Task {
let result = await asyncFunc(value)
promise(.success(result))
}
}
}
}
}
However, I cannot compile this in Xcode 16.1 beta using Swift 6, getting:
"Task-isolated value of type '() async -> ()' passed as a strongly transferred parameter; later accesses could race".
Is it possible to migrate this extension to Swift 6 somehow? Already tried to add Sendable
and @Sendable
everywhere.
(See @Rob's answer for an improved version of this that handles cancellation.)
If asyncMap
is required to run on @MainActor
, and T
is Sendable
, then it's straightforward and will just work:
public extension Publisher {
@MainActor func asyncMap<T: Sendable>(
_ asyncFunc: @escaping (Output) async -> T
) -> Publishers.FlatMap<Future<T, Never>, Self> {
return flatMap { value in
Future { promise in
Task {
let result = await asyncFunc(value)
promise(.success(result))
}
}
}
}
}
If it is not...I don't believe this is possible without using the universal escape hatch (I'll show it in a moment). The problem is that Future does not mark its promise
parameter is @Sendable
or sending
or anything. It doesn't promise not to hold onto it and run it at some random point itself, and maybe it has side-effects which could cause a race. It doesn't do that, but it's not promised. And I don't think there's any way to really fix that without Apple updating Combine (which they seem to have mostly abandoned a this point).
There is always the universal escape hatch: @unchecked Sendable
, and coupled with marking just about every other thing @Sendable
will make this work:
struct UncheckedSendable<T>: @unchecked Sendable {
let unwrap: T
init(_ value: T) { unwrap = value}
}
public extension Publisher where Output: Sendable {
func asyncMap<T: Sendable>(
_ asyncFunc: @escaping @Sendable (Output) async -> T
) -> Publishers.FlatMap<Future<T, Never>, Self> {
flatMap { value in
Future { promise in
let promise = UncheckedSendable(promise)
Task {
let result = await asyncFunc(value)
promise.unwrap(.success(result))
}
}
}
}
}
I'm sorry.
Given what we know of Future
, I believe this is actually safe. Lots of things Swift 6 calls unsafe are in fact unsafe, and we just kind of ignore it because "that won't happen" (narrator: sometimes it happens). But I believe we do know that this is safe. Apple just hasn't updated Combine to mark it.
Or.... we could also reimplement Future:
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public final class SendingFuture<Output, Failure: Error>: Publisher, Sendable {
public typealias Promise = @Sendable (Result<Output, Failure>) -> Void
private let attemptToFulfill: @Sendable (@escaping Promise) -> Void
public init(_ attemptToFulfill: @Sendable @escaping (@escaping Promise) -> Void) {
self.attemptToFulfill = attemptToFulfill
}
public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input, S: Sendable {
let subscription = SendingSubscription(subscriber: subscriber, attemptToFulfill: attemptToFulfill)
subscriber.receive(subscription: subscription)
}
private final class SendingSubscription<S: Subscriber>: Subscription
where S.Input == Output, S.Failure == Failure, S: Sendable {
private var subscriber: S?
init(subscriber: S, attemptToFulfill: @escaping (@escaping Promise) -> Void) {
self.subscriber = subscriber
attemptToFulfill { result in
switch result {
case .success(let output):
_ = subscriber.receive(output)
subscriber.receive(completion: .finished)
case .failure(let failure):
subscriber.receive(completion: .failure(failure))
}
}
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
subscriber = nil
}
}
}
And then your original code will work, changing Future
to SendingFuture
.