How to cancel loadTransferable(type:)?

176 Views Asked by At

I'd like to implement a timeout for loading images from the PhotosPicker in SwiftUI in case there's no internet connection and the images are not available in full resolution on the device yet, for example.

I used a task group with a parallel timer task for this, but unfortunately it seems, that loadTransferable(type:) does not handle cancellation. The image loading task does not finish once the timer task has completed and group.cancelAll() was called and thus the switch block is not entered at all.

The other way round does work: If I pick an image that is available on the device already the timer task is cancelled and the task group finishes.

Am I doing something wrong? Is there a workaround or other approach for this?

func loadImage(_ image: PhotosPickerItem, timeout milliseconds: Int) async throws -> UIImage {
    var result: Result<UIImage, Error>!
    
    await withTaskGroup(of: Result<UIImage, Error>.self) { group in
        // Load the image.
        group.addTask {
            do {
                if let imageData = try await image.loadTransferable(type: Data.self) {
                    if let image = UIImage(data: imageData) {
                        return .success(image)
                    }
                }
                return .failure(MyError.imageCouldNotBeLoaded(details: "Image transfer or decoding issues."))
            } catch {
                return .failure(MyError.imageCouldNotBeLoaded(details: "Unsupported image format."))
            }
        }
        
        // Activate the timeout timer.
        group.addTask {
            do {
                try await Task.sleep(nanoseconds: UInt64(milliseconds * 1_000_000))
                return .failure(MyError.imageCouldNotBeLoaded(details: "Timeout occured."))
            } catch is CancellationError {
                return .failure(MyError.loadingCancelled)
            } catch {
                return .failure(error)
            }
        }
        
        // Wait for the first result and stop all (other) tasks.
        if let taskResult = await group.next() {
            result = taskResult
            group.cancelAll()
        }
    }

    switch result {
    case .success(let image):
        return image
    case .failure(let error):
        throw error
    case .none:
        throw MyError.imageCouldNotBeLoaded(details: "Task not executed or cancelled.")
    }
}
1

There are 1 best solutions below

0
On

Since I suspect a data race issue accessing variable result, this implementation will simplify your code and does not have data race issues, see below.

However, this does not fix your issue, if image.loadTransferable(type:) does not handle cancellation. A task group needs to wait until all child tasks have been completed.

private func loadImage(
    _ image: PhotosPickerItem,
    timeout milliseconds: UInt64
) async throws -> UIImage {
    try await withThrowingTaskGroup(
        of: UIImage.self,
        returning: UIImage.self
    ) { group in
        // Load the image.
        group.addTask {
            let data = try await self.loadTransferable(photosPickerItem: image)
            guard Task.isCancelled == false,
                    let imageData = data,
                    let image: UIImage = UIImage(data: imageData)
            else {
                throw MyError.imageCouldNotBeLoaded(
                    details: Task.isCancelled ? "cancelled"
                    : data == nil ? "content type not supported" : "image data encoding error"
                )
            }
            return image
        }
        
        // Activate the timeout timer.
        group.addTask {
            try await Task.sleep(for: .milliseconds(milliseconds))
            throw MyError.loadingCancelled
        }
        
        // If a child task throws an error and if it will be propagated from
        // this method out of the body of this function, then all remaining
        // child tasks in that group are implicitly canceled.
        guard let image = try await group.next() else {
            fatalError("can't happen") // `group.next()` either throws or it returns an image.
        }
        return image
    }
}