`downloadImageWithURL` may call completion twice when synchronously cancelled
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:
- Synchronously call
token.cancel()without switching threads. - Run two downloads for different URLs in parallel via SDWebImageDownloader (this increases the repro rate).
- 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.
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.
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