Search code examples
swifturlsessionswift-concurrency

Backporting URLSession's download(for:delegate:) for concurrent usage


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:).


Solution

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