Gordon W
Gordon W

Reputation: 33

Mixing Task with Future Publisher in Swift 6 with strict concurrency checking

I implemented a method that returns a Future (a Combine Publisher), which produces a value (or error) from a Task, it works in Swift 5:

  func call(api: API) -> AnyPublisher<Data?, Error> {

    return Future { promise in

      Task {
        let response = await AF.request( ... ).serializingData().response 

          if let error = response.error {
            promise(.failure(error))
          } else {
            promise(.success(response.data))
          }

      }

    }.eraseToAnyPublisher()

  }

The above code could not compile in Swift 6 with strict concurrency checking, because promise is NOT a sendable type:

Capture of 'promise' with non-sendable type '(Result<Data?, any Error>) -> Void' in a `@Sendable` closure

Does this mean with Swift 6, it is no longer possible to have a Combine publisher that produce value from an asynchronous method?

I checked Apple's documentation Using Combine for Your App’s Asynchronous Code, but it does not mention how to mix Combine and Task with strict concurrency checking.

Upvotes: 3

Views: 1501

Answers (4)

Serg Smyk
Serg Smyk

Reputation: 663

    private struct PromiseSendable: @unchecked Sendable {
        let value: (Result<String, Error>) -> Void
    }
    func myFunction() {
        Future<String, Error> { promise in
            let promiseSendable = PromiseSendable(value: promise)
            Task {
                do {
                    // do something
                    promiseSendable.value(.success("example"))
                }
                catch let error {
                    promiseSendable.value(.failure(error))
                }
            }
        }
    }

Upvotes: 0

dvossCricut
dvossCricut

Reputation: 31

import Combine

/// Replacement for a Combine Future that requires Sendable Output and Failure types. This is needed due to the fact that a Combine Future.Promise is not @Sendable.
public struct SwiftConcurrencyPublisher<Output: Sendable, Failure: Sendable & Error>: Publisher {
    private let subject: Publishers.HandleEvents<PassthroughSubject<Output, Failure>>

    public init(body: @Sendable @escaping () async -> Result<Output, Failure>) {
        let subject = PassthroughSubject<Output, Failure>()
        let task = Task {
            switch await body() {
            case .success(let success):
                subject.send(success)
                subject.send(completion: .finished)
            case .failure(let failure):
                subject.send(completion: .failure(failure))
            }
        }
        self.subject = subject.handleEvents(receiveCancel: { task.cancel() })
    }

    public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

Upvotes: 0

Michal Zaborowski
Michal Zaborowski

Reputation: 5099

If you want to fix it for Xcode15 / iOS17 too you need to use wrapper:

fileprivate final class FutureResultWrapper<Output, Failure: Error>: @unchecked Sendable {
    fileprivate typealias Promise = (Result<Output, Failure>) -> Void

    fileprivate let completionResult: Promise

    /// Creates a publisher that invokes a promise closure when the publisher emits an element.
    ///
    /// - Parameter attemptToFulfill: A ``Future/Promise`` that the publisher invokes when the publisher emits an element or terminates with an error.
    fileprivate init(_ attemptToFulfill: @escaping Promise) {
        self.completionResult = attemptToFulfill
    }
}

and then use it like this:

    let publisher = Future<T, Never> { [weak self] completionResult in
    guard let self = self else {
        completionResult(.success(object))
        return
    }

    let wrapper = FutureResultWrapper<T, Never>(completionResult)

    Task.detached { [weak self] in
        await self?.persist(object)
        wrapper.completionResult(.success(object))
    }

Upvotes: 2

rob mayoff
rob mayoff

Reputation: 385960

I believe the lack of a @Sendable annotation on Future.Promise is a bug, as I believe it is supposed to be thread-safe. You could file a feedback report with Apple.

For now, you can work around it using the Swift 6 nonisolated(unsafe) syntax to tell the compiler not to worry about it:

func call() -> AnyPublisher<Data?, Error> {
    return Future { promise in

        // Copy promise to a local property to make it nonisolated(unsafe):
        nonisolated(unsafe) let promise = promise

        Task {
            // Now capture promise in a Sendable closure.

            promise(.success(Data()))
        }
    }.eraseToAnyPublisher()
}

Upvotes: 2

Related Questions