iosswiftasync-awaitcombineswift6

Getting "Task-isolated value of type '() async -> ()' passed as a strongly transferred parameter" in Swift 6


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.


Solution

  • (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.