3

I was trying to backport URLSession's download(for:delegate:) since it requires a deployment target of iOS 15+ but concurrency is now supported for iOS 13+.

It seemed like a very straight forward process since you can pass a continuation into the completion handler of downloadTask(with:completionHandler:).

This is what I came up with:

extension URLSession {

    func asyncDownload(for request: URLRequest) async throws -> (URL, URLResponse) {
        return try await withCheckedThrowingContinuation { continuation in
            downloadTask(with: request) { url, response, error in
                if let url = url, let response = response {
                    continuation.resume(returning: (url, response))
                } else {
                    continuation.resume(throwing: error ?? URLError(.badServerResponse))
                }
            }.resume()
        }
    }
}

However there is a subtlety in the implementation of downloadTask(with:completionHandler:) that keeps this code from working correctly. The file behind the URL is deleted right after returning the completion block. Therefore the file is not available anymore after the awaiting.

I could reproduce this by placing FileManager.default.fileExists(atPath: url.path) right before resuming the continuation and the same line after awaiting this call. The first yielded a true the second one a false.

I then tried to use Apple's implementation download(for:delegate:) which magically did not have the same problem I am describing. The file at the given URL was still available after awaiting.

A possible solution would be to move the file to a different location inside downloadTask's closure. However in my opinion this is a separation-of-concern nightmare conflicts with the principle of separation-of-concern. I have to introduce the dependency of a FileManager to this call, which Apple is (probably) not doing either since the URL returned by downloadTask(with:completionHandler:) and download(for:delegate:) looks identical.

So I was wondering if there is a better solution to wrapping this call and making it async other than I am doing right now. Maybe you could somehow keep the closure from returning until the Task is finished? I'd like to keep the responsibility of moving the file to a better destination to the caller of asyncDownload(for:).

ph1psG
  • 568
  • 5
  • 24
  • By the way, the iOS 13+ support for concurrency [has been buggy](https://forums.swift.org/t/async-await-crash-on-ios14-with-xcode-13-2-1/54541). Allegedly, Xcode 13.3 fixes some of these issues, but I haven’t had a chance to confirm. Make sure you exhaustively test on iOS 13/14 devices before you invest too much time on this pattern. – Rob Mar 16 '22 at 15:44

1 Answers1

2

You said:

A possible solution would be to move the file to a different location inside downloadTask’s closure.

Yes, that is precisely what you should do. You have a method that is dealing with files in your local file system, and using Foundation’s FileManager is not an egregious violation of separation of concerns. Besides, it is the only logical alternative.


FWIW, below, I use withCheckedThrowingContinuation, but I also make it cancelable with withTaskCancellationHandler:

extension URLSession {
    @available(iOS, deprecated: 15, 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")
    func download(with request: URLRequest) async throws -> (URL, URLResponse) {
        let sessionTask = URLSessionTaskActor()

        return try await withTaskCancellationHandler {
            Task { await sessionTask.cancel() }
        } operation: {
            try await withCheckedThrowingContinuation { continuation in
                Task {
                    await sessionTask.start(downloadTask(with: request) { location, response, error in
                        guard let location = location, let response = 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)
                            continuation.resume(returning: (tempURL, response))
                        } catch {
                            continuation.resume(throwing: error)
                        }
                    })
                }
            }
        }
    }
}

private extension URLSession {
    actor URLSessionTaskActor {
        weak var task: URLSessionTask?

        func start(_ task: URLSessionTask) {
            self.task = task
            task.resume()
        }

        func cancel() {
            task?.cancel()
        }
    }
}

You can consider using the withUnsafeThrowingContinuation, instead of withCheckedThrowingContinuation, once you have verified that your implementation is correct.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • We should not be showing beginners `withCheckedThrowingContinuation` as if it were a solution. It is for debugging only. Solutions should use the _unchecked_ continuation variants. – matt Mar 16 '22 at 16:26
  • Lol. I completely disagree: I think it is a great disservice to advise beginners *not* to use the checked variants. The whole idea of the checked variants is to ensure that new developers use the API correctly. Your point is taken, that you can use the unsafe variants once you understand what you're doing, but future readers should note that the performance difference is unobservable in the vast majority of cases. – Rob Mar 16 '22 at 16:34
  • (Frankly, I think it was a fundamental mistake for Apple to introduce different API for checked/unsafe. It should be a compiler setting, not an API change.) – Rob Mar 16 '22 at 16:35
  • I'm just repeating what they said in the WWDC video that introduced this stuff. – matt Mar 16 '22 at 16:49
  • 1
    Apple has clarified their thoughts on this. From WWDC 2022 video [Visualize and optimize Swift concurrency](https://developer.apple.com/videos/play/wwdc2022/110350/?time=1403): “Always use the `withCheckedContinuation` API for continuations unless performance is absolutely critical.” – Rob Jun 08 '22 at 04:26