PHCachingImageManager not returning result or error at times

93 Views Asked by At

I'm using a PHCachingImageManager to request thumbnail for a series of images as the user scrolls up and down a gridview which displays those images. I'm using an async function as follows to support modern swift concurrency with async-await style.

class PhotoRequestManager {
  // Properties from class
  private var imageCachingManager = PHCachingImageManager()

  func fetchImage(
    asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode
  ) async throws -> UIImage? {
    
    let options = PHImageRequestOptions()
    options.deliveryMode = .opportunistic
    options.resizeMode = .fast
    options.isNetworkAccessAllowed = true
    options.isSynchronous = true

    return try await withCheckedThrowingContinuation { [weak self] continuation in
      self?.imageCachingManager.requestImage(
        for: asset, targetSize: targetSize, contentMode: contentMode, options: options
      ) { image, info in
        /// When the hang occurs, this block never gets executed.
        if let error = info?[PHImageErrorKey] as? Error {
          continuation.resume(throwing: error)
          return
        }
        continuation.resume(returning: image)
      }
    }
  }

}

// From UI
struct MySwiftUIView: View {
  @EnvironmentObject private var photosRequestManager: PhotoRequestManager

  var body: some View {
    ZStack {
      // Content
    }
    .task { await requestImage() }
  }

  @State private var thumbnail: Image?

  private func requestImage() async {
    guard
      let uiImage = try? await photosRequestManager.fetchImage(
        asset: asset, targetSize: size)
    else {
      return
    }

    thumbnail = Image(uiImage: uiImage)
  }
}

I have come across a weird issue where at times this function hangs without returning either a result or an error. There are no console errors or anything that is visible, just that the function never returns a value. When that happens, any subsequent requests to the same function hangs as well. If anyone has any idea on what could be happening here, greatly appreciate any help.

1

There are 1 best solutions below

3
Rob On

You must avoid performing slow, synchronous tasks from within the Swift concurrency cooperative thread pool. As WWDC 2021’s Swift concurrency: Behind the scenes says, “The [cooperative] thread pool will only spawn as many threads as there are CPU cores, thereby making sure not to overcommit the system.” So, if you ever block all the threads in the very constrained cooperative thread pool, you can find yourself quickly exhausting this pool, and new tasks will be unable to run, and it can even deadlock. You should set isSynchronous to false (or don’t call this directly from the Swift concurrency cooperative thread pool).

In WWDC 2022 video Visualize and optimize Swift concurrency, they outline this deadlock risk. As they say:

If you have code that needs to do these things [slow, synchronous functions that cannot periodically yield to the Swift concurrency system], move that code outside of the concurrency thread pool – for example, by running it on a dispatch queue – and bridge it to the concurrency world using continuations. Whenever possible, use async APIs for blocking operations to keep the system operating smoothly.

That having been said, while that video talks about what to do if you are stuck with a synchronous API, you have no such constraint here. You can set isSynchronous to false and then we can stay within the realm of Swift concurrency without any problems.

Note, as discussed below, with the asynchronous rendition, a deliveryMode of opportunistic means that the closure might be called more than once. That means that you would either want to use a deliveryMode of highQualityFormat or fastFormat to ensure the closure is called only once, or switch from a simple continuation to an AsyncStream to handle the multiple images that might come in.


FWIW, my original answer, below, was predicated on using the asynchronous rendition (setting isSynchronous to false). But all of the caveats outlined below (about eliminating any paths of execution where you fail to satisfy the continuation) still apply.


I am unfamiliar with any scenario where the requestImage completion handler is not called. (If anything, you actually have the opposite problem, that using .opportunistic can cause the completion handler to be called multiple times, which would be a problem for your simple continuation pattern. You really should use an AsyncStream or AsyncThrowingStream if dealing with .opportunistic delivery mode. See the example in latter part of this answer.)

You should confirm:

  • that requestImage is getting called;
  • that its completion handler is also called; and if it is, then
  • identify whether the problem is one or more of the paths of execution where the continuation is not resumed.

On that last point, there are several paths of execution where the continuation is not resumed:

  1. The guard let-try?.

    Consider:

    guard let uiImage = try? await photosRequestManager.fetchImage(
        asset: asset,
        targetSize: size
    ) else {
        return
    }
    

    If there was an error thrown, the try? will discard the error (with its meaningful diagnostic information), silently exit and never satisfy the continuation. I would suggest:

    • replace try? with do-try-catch;
    • if an error is reported, log the error to the console and pass it on with continuation.resume(throwing: …);
    • in the unlikely event that no error was reported, but the image is still nil, log that fact, and have the continuation resume with your own custom error.

    Bottom line, never have an early exit within a withCheckedThrowingContinuation that fails to resume the continuation.

  2. The optional chaining of self.

    Consider:

    func fetchImage(
        asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode
    ) async throws -> UIImage? {
        let options = …
    
        return try await withCheckedThrowingContinuation { [weak self] continuation in
            self?.imageCachingManager.requestImage(…) { image, info in
                …
            }
        }
    }
    

    In the unlikely case that self was nil, the requestImage will never be called, and the continuation will never be satisfied. Personally, I would recommend not using the [weak self] capture list in your continuation. If you want to capture anything, capture the imageCachingManager.

    Again, never have a path of execution within withCheckedThrowingContinuation where we fail to resume a continuation.

In short, I would advise looking at all paths of execution and ensure you have none that could prevent the continuation from being satisfied. But right now, there are several.