7

I have a networking layer that currently uses completion handlers to deliver a result on the operation is complete.

As I support a number of iOS versions, I instead extend the network layer within the app to provide support for Combine. I'd like to extend this to now also a support Async/Await but I am struggling to understand how I can achieve this in a way that allows me to cancel requests.

The basic implementation looks like;


protocol HTTPClientTask {
    func cancel()
}

protocol HTTPClient {
    typealias Result = Swift.Result<(data: Data, response: HTTPURLResponse), Error>
    @discardableResult
    func dispatch(_ request: URLRequest, completion: @escaping (Result) -> Void) -> HTTPClientTask
}

final class URLSessionHTTPClient: HTTPClient {
    
    private let session: URLSession
    
    init(session: URLSession) {
        self.session = session
    }
    
    func dispatch(_ request: URLRequest, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask {
        let task = session.dataTask(with: request) { data, response, error in
            completion(Result {
                if let error = error {
                    throw error
                } else if let data = data, let response = response as? HTTPURLResponse {
                    return (data, response)
                } else {
                    throw UnexpectedValuesRepresentation()
                }
            })
        }
        task.resume()
        return URLSessionTaskWrapper(wrapped: task)
    }
}

private extension URLSessionHTTPClient {
    struct UnexpectedValuesRepresentation: Error {}
    
    struct URLSessionTaskWrapper: HTTPClientTask {
        let wrapped: URLSessionTask
        
        func cancel() {
            wrapped.cancel()
        }
    }
}

It very simply provides an abstraction that allows me to inject a URLSession instance.

By returning HTTPClientTask I can call cancel from a client and end the request.

I extend this in a client app using Combine as follows;

extension HTTPClient {
    typealias Publisher = AnyPublisher<(data: Data, response: HTTPURLResponse), Error>

    func dispatchPublisher(for request: URLRequest) -> Publisher {
        var task: HTTPClientTask?

        return Deferred {
            Future { completion in
                task = self.dispatch(request, completion: completion)
            }
        }
        .handleEvents(receiveCancel: { task?.cancel() })
        .eraseToAnyPublisher()
    }
}

As you can see I now have an interface that supports canceling tasks.

Using async/await however, I am unsure what this should look like, how I can provide a mechanism for canceling requests.

My current attempt is;

extension HTTPClient {
    func dispatch(_ request: URLRequest) async -> HTTPClient.Result {

        let task = Task { () -> (data: Data, response: HTTPURLResponse) in
            return try await withCheckedThrowingContinuation { continuation in
                self.dispatch(request) { result in
                    switch result {
                    case let .success(values): continuation.resume(returning: values)
                    case let .failure(error): continuation.resume(throwing: error)
                    }
                }
            }
        }

        do {
            let output = try await task.value
            return .success(output)
        } catch {
            return .failure(error)
        }
    }
}

However this simply provides the async implementation, I am unable to cancel this.

How should this be handled?

Cristik
  • 30,989
  • 25
  • 91
  • 127
Harry Blue
  • 4,202
  • 10
  • 39
  • 78
  • 1
    The async/await paradigm doesn't fit very well here, async/await allow you to write code in a synchronous manner, and when you execute synchronous code, you cannot cancel a single statement, you have to wait until in completes. If you want support for cancel, you'll have to store the task in some other place and access the task by some identifier, for example. – Cristik Oct 10 '21 at 10:45
  • As discussed below, async/await and Swift concurrency fits perfectly well here. Cancelation is a core feature of Swift concurrency. – Rob Feb 18 '22 at 15:49

3 Answers3

12

Swift’s new concurrency model handles cancellation perfectly well. While the WWDC 2021 videos focused on the checkCancellation and isCancelled patterns (e.g., the Explore structured concurrency in Swift video), in this case, one would use withTaskCancellationHandler to create a task that cancels the network request when the task, itself, is canceled. (Obviously, this is only a concern in iOS 13/14, as in iOS 15 one would just use the provided async methods, data(for:delegate) or data(from:delegate:), which also handle cancelation well.)

See SE-0300: Continuations for interfacing async tasks with synchronous code: Additional Examples for example. That download example is a bit outdated, so here is an updated rendition:

extension URLSession {
    @available(iOS, deprecated: 15, message: "Use `data(from:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `data(from:delegate:)` instead")
    func data(with url: URL) async throws -> (URL, URLResponse) {
        try await download(with: URLRequest(url: url))
    }

    @available(iOS, deprecated: 15, message: "Use `data(for:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `data(for:delegate:)` instead")
    func data(with request: URLRequest) async throws -> (Data, URLResponse) {
        let sessionTask = SessionTask(session: self)

        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                Task {
                    await sessionTask.data(for: request) { data, response, error in
                        guard let data, let response else {
                            continuation.resume(throwing: error ?? URLError(.badServerResponse))
                            return
                        }

                        continuation.resume(returning: (data, response))
                    }
                }
            }
        } onCancel: {
            Task { await sessionTask.cancel() }
        }
    }
}

private extension URLSession {
    actor SessionTask {
        var state: State = .ready
        private let session: URLSession

        init(session: URLSession) {
            self.session = session
        }

        func cancel() {
            if case .executing(let task) = state {
                task.cancel()
            }
            state = .cancelled
        }
    }
}

// MARK: Data

extension URLSession.SessionTask {
    func data(for request: URLRequest, completionHandler: @Sendable @escaping (Data?, URLResponse?, Error?) -> Void) {
        if case .cancelled = state {
            completionHandler(nil, nil, CancellationError())
            return
        }

        let task = session.dataTask(with: request, completionHandler: completionHandler)

        state = .executing(task)
        task.resume()
    }
}

extension URLSession.SessionTask {
    enum State {
        case ready
        case executing(URLSessionTask)
        case cancelled
    }
}

A few minor observations on my code snippet:

  • I gave these names to avoid collision with the iOS 15 method names, but added deprecated messages to inform the developer to use the iOS 15 renditions once you abandon iOS 13/14 support.

  • I deviated from SE-0300’s example to follow the pattern of the data(from:delegate:) and data(for:delegate:) methods (returning a tuple with Data and a URLResponse).

  • The actor, not in the original example, is needed to synchronize the access to the URLSessionTask.

  • Note that according to SE-0304, that regarding withTaskCancellationHandler:

    If the task has already been cancelled at the point withTaskCancellationHandler is called, the cancellation handler is invoked immediately, before the operation block is executed.

    Because of this, the actor in the above uses a state variable to determine if the request has already been canceled, and just immediately resumes, throwing a CancellationError if it is already canceled.

But all of that is unrelated to the question at hand. In short, use withTaskCancellationHandler.

E.g. Here are five image requests that I started in a task group, as monitored by Charles:

enter image description here

And here are the same requests, but this time I canceled the whole task group (and the cancelations successfully stopped the associated network requests for me):

enter image description here

(Obviously the x-axis scale is different.)


If you need download renditions (to wrap downloadTask), you could do supplement the above with:

extension URLSession {
    @available(iOS, deprecated: 15, message: "Use `download(from:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `download(from:delegate:)` instead")
    func download(with url: URL) async throws -> (URL, URLResponse) {
        try await download(with: URLRequest(url: url))
    }

    @available(iOS, deprecated: 15, message: "Use `download(for:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `download(for:delegate:)` instead")
    func download(with request: URLRequest) async throws -> (URL, URLResponse) {
        let sessionTask = SessionTask(session: self)

        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                Task {
                    await sessionTask.download(for: request) { location, response, error in
                        guard let location, let response else {
                            continuation.resume(throwing: error ?? URLError(.badServerResponse))
                            return
                        }

                        // since continuation can happen later, let’s figure out where to store it ...

                        let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
                            .appendingPathComponent(UUID().uuidString)
                            .appendingPathExtension(request.url!.pathExtension)

                        // ... and move it to there

                        do {
                            try FileManager.default.moveItem(at: location, to: tempURL)
                        } catch {
                            continuation.resume(throwing: error)
                            return
                        }

                        continuation.resume(returning: (tempURL, response))
                    }
                }
            }
        } onCancel: {
            Task { await sessionTask.cancel() }
        }
    }
}

extension URLSession.SessionTask {
    func download(for request: URLRequest, completionHandler: @Sendable @escaping (URL?, URLResponse?, Error?) -> Void) {
        if case .cancelled = state {
            completionHandler(nil, nil, CancellationError())
            return
        }

        let task = session.downloadTask(with: request, completionHandler: completionHandler)

        state = .executing(task)
        task.resume()
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • So the cancellationHandler is always called on success or failure (like a finally clause), but do we know if KVO notifications are properly thrown (as in NSOperation.cancel)? – MandisaW Jan 06 '22 at 21:39
  • 1. The `cancellationHandler` is only called when you cancel the task. Otherwise it just completes in the course of its natural lifecycle. 2. There are no KVO notifications. This is not an `Operation`. But yes, whether you cancel or whether it completes in the normal course of events, the `await` of the task is properly satisfied. – Rob Jan 06 '22 at 22:00
  • In Xcode 13.3 (Beta 2) this produces warnings: `Capture of 'request' with non-sendable type 'URLRequest' in a @Sendable closure`, `Non-sendable type 'URLSessionTask' passed in implicitly asynchronous call to actor-isolated instance method 'start' cannot cross actor boundary` – Ortwin Gentz Feb 17 '22 at 20:22
  • I don’t think that the warnings triggered by this second beta are easily resolved because it is not recognizing clearly sendable objects as such (e.g., an immutable `URLRequest` constant is clearly capturable, but beta 2 is not allowing this). I’d suggest we keep an eye out for future betas. Hard to fix right now. – Rob Feb 18 '22 at 15:34
  • For the time being, you can add `@_predatesConcurrency` before `import Foundation` for this extension. – Rob Feb 18 '22 at 17:45
0

You can't hybridize Combine with async/await. If you embrace async/await fully and call one of the async download methods...

https://developer.apple.com/documentation/foundation/urlsession/3767353-data

...then the task where you call that method will be cancellable in good order through the standard structured concurrency mechanism.

So if you want to support Swift 5.5 / iOS 15 async and yet support earlier versions too, you will need two completely independent implementations of this functionality.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 1
    I think the Combine snippet was intended as an example of what he'd like to achieve in async-await, not something he was planning on using in conjunction with async-await. – Rob Dec 18 '21 at 18:50
-1

async/await might not be the proper paradigm if you want cancellation. The reason is that the new structured concurrency support in Swift allows you to write code that looks single-threaded/synchronous, but it fact it's multi-threaded.

Take for example a naive synchronous code:

let data = tryData(contentsOf: fileURL)

If the file is huge, then it might take a lot of time for the operation to finish, and during this time the operation cannot be cancelled, and the caller thread is blocked.

Now, assuming Data exports an async version of the above initializer, you'd write the async version of the code similar to this:

let data = try await Data(contentsOf: fileURL)

For the developer, it's the same coding style, once the operation finishes, they'll either have a data variable to use, or they'll be receiving an error.

In both cases, there's no cancellation built in, as the operation is synchronous from the developer's perspective. The major difference is that the await-ed call doesn't block the caller thread, but on the other hand once the control flow returns it might well be that the code continues executing on a different thread.

Now, if you need support for cancellation, then you'll have to store somewhere some identifiable data that can be used to cancel the operation.

If you'll want to store those identifiers from the caller scope, then you'll need to split your operation in two: initialization, and execution.

Something along the lines of

extension HTTPClient {
    // note that this is not async
    func task(for request: URLRequest) -> HTTPClientTask {
        // ...
    }
}

class HTTPClientTask {
    func dispatch() async -> HTTPClient.Result {
        // ...
    }
}

let task = httpClient.task(for: urlRequest)
self.theTask = task
let result = await task.dispatch()

// somewhere outside the await scope
self.theTask.cancel()
Cristik
  • 30,989
  • 25
  • 91
  • 127
  • 4
    “async/await might not be the proper paradigm if you want cancellation” ... The new structured concurrency model was built with cancellation in mind, covered in some detail in the [Explore structured concurrency in Swift](https://developer.apple.com/videos/play/wwdc2021/10134/?time=511) WWDC video. – Rob Dec 20 '21 at 13:09
  • 2
    @Rob I wanted to say "cancellation from the enclosing scope", AFAIK, there's no way to cancel an await operation, in any of the programming languages that have the "await" feature. The underlying operation might be cancellable, in which case the await will report an error, but you cannot cancel the await itself. – Cristik Dec 20 '21 at 13:16
  • Not sure how that fits the asker's model, @Rob, that's why I opened my answer with that "cancellation" statement. – Cristik Dec 20 '21 at 17:16
  • My point is merely that cancellation of network requests within async-await paradigm turns out to be remarkably simple. The OP merely was not handling cooperative cancellation correctly in his `async` rendition. The question was less “how do I cancel it” but rather “how do I make it cancellable”. – Rob Dec 20 '21 at 18:54