SDWebImage icon indicating copy to clipboard operation
SDWebImage copied to clipboard

`downloadImageWithURL` may call completion twice when synchronously cancelled

Open yimajo opened this issue 5 months ago • 2 comments

Summary

Under certain conditions, downloadImage(with:) may call the completion handler twice for the same request. The second call reports a cancellation (e.g. Error=2002), which leads to unexpected re-entry into the handler right after a successful completion.

This is not 100% reproducible but becomes more likely with larger images and when multiple downloads are running.

Environment

The following is the environment I tested on.

  • SDWebImage 5.21.1
  • Xcode 16.3
    • iOS 18.3.1 (simulator)
    • iOS 26 (simulator)
  • Xcode 16.2
    • iOS 18.3.1 (simulator)
    • iOS 26 (simulator)

Reproduction Conditions

All of the following seem required for the re-entry to occur:

  1. Synchronously call token.cancel() without switching threads.
  2. Run two downloads for different URLs in parallel via SDWebImageDownloader (this increases the repro rate).
  3. Larger image sizes increase the repro rate further.

Expected Behavior

The completion handler is called once per request.

Actual Behavior

Immediately after a successful completion, the handler is invoked again for the same request, reporting a cancellation (e.g. Error=2002).

This results in unexpected re-entry into user code.

Minimal Repro (Swift / Swift Concurrency)

The following uses withCheckedThrowingContinuation to make the re-entry obvious:

import SwiftUI
import SDWebImage
import UIKit

struct ContentView: View {
    let demo: Demo
    @State private var image: UIImage? = nil

    var body: some View {
        VStack {
            if let image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 100, height: 100)
            } else {
                ProgressView()
            }
        }
        .padding()
        .task {
            let imageA = try? await demo.run(
                url: .init(string: "https://placehold.co/50x50.png")!
            )
            let imageB = try? await demo.run(
                url: .init(string: "https://placehold.co/[email protected]")!
            )

            image = imageA
        }
    }
}

class Demo {
    let downloader = SDWebImageDownloader()

    func run(url: URL) async throws -> UIImage {
        try await withCheckedThrowingContinuation { continuation in
            var token: SDWebImageDownloadToken?

            token = downloader.downloadImage(
                with: url,
                progress: nil
            ) { image, data, error, finished in

                if let image {
                    continuation.resume(returning: image)
                    token?.cancel() // <— synchronous cancel ‼️
                } else if let error {
                    continuation.resume(throwing: error)
                } else {
                    fatalError()
                }
            }
        }
    }
}

Motivation

Synchronous cancel() may happen in various bridging scenarios. For example, RxSwift users often write Disposables.create { token.cancel() }, which can result in a synchronous cancel triggered during or right after completion.

RxSwift variant

import RxSwift

class DemoWithRxSwift {
    let downloader = SDWebImageDownloader()

   func run(url: URL) -> Maybe<UIImage> {
        .create { [weak self] emitter -> Disposable in
            let token = self?.downloader.downloadImage(
                with: url,
                progress: nil
            ) { image, data, error, finished in

                if let image {
                    emitter(.success(image))
                } else if let error {
                    emitter(.error(error))
                } else {
                    fatalError()
                }
            }

            return Disposables.create {
                token?.cancel() // <— synchronous cancel ‼️
            }
        }
    }
}

Note

This report is intended to share an observation, not to request specific workarounds or changes. I would like to confirm whether this double-callback behavior is intended when cancel() is invoked synchronously. If it is not intended, this information may help in deciding whether a guard or documentation update would be appropriate.

yimajo avatar Aug 13 '25 04:08 yimajo

I’m also experiencing this issue when rendering Animated WebP images. The images load and display correctly, but they do not animate.

I consistently get the following error:

SDWebImageErrorDomain Code=2002 "Operation cancelled by user during querying the cache" UserInfo={NSLocalizedDescription=Operation cancelled by user during querying the cache}

Has anyone found a workaround or solution for this?

For additional context, here are the configurations I’m using:

sdOptions = [
    .lowPriority,
    .scaleDownLargeImages,
    .queryMemoryData
]

sdContext = [
    .imageForceDecodePolicy: SDImageForceDecodePolicy.never.rawValue,
    .animatedImageClass : SDAnimatedImage.self
]

And the setup code:

let WebPCoder = SDImageWebPCoder.shared SDImageCodersManager.shared.addCoder(WebPCoder)

SDWebImageDownloader.shared.setValue("image/webp,image/,/*;q=0.8", forHTTPHeaderField:"Accept") SDWebImageDownloader.shared.config.maxConcurrentDownloads = 2

let cache = SDImageCache.shared let maxDiskCacheSize: UInt = 10 * 1024 * 1024 // 10 MB cache.config.maxDiskSize = maxDiskCacheSize cache.config.maxMemoryCost = maxDiskCacheSize cache.config.maxMemoryCount = 2

Would appreciate any guidance on why the WebPs aren’t animating despite being rendered.

Arpit-2678 avatar Aug 18 '25 09:08 Arpit-2678

Seems this is a race condition. Our code to use non-concurrency (in Objective-C) can not guarantee the only once callback recurrently if you call cancel at a very high ratio.. Need some test and fix

dreampiggy avatar Aug 24 '25 09:08 dreampiggy