Add opt-in asyncCacheTypeCheck to move cache probe off caller thread#2521
Add opt-in asyncCacheTypeCheck to move cache probe off caller thread#2521
Conversation
The cache-existence probe that runs before KingfisherManager decides between a cache read and a network download previously invoked `ImageCache.imageCachedType` synchronously on the caller thread. Under disk pressure, its `stat` syscalls can hang the main thread when `setImage` is called from UIKit layout callbacks such as `tableView(_:cellForRowAt:)`. Adds `KingfisherOptionsInfoItem.asyncCacheTypeCheck`. When enabled, `retrieveImage` returns a `DownloadTask` shell synchronously, dispatches the probe via `imageCachedTypeAsync` on the cache I/O queue, and on cache miss links the resulting session task onto the shell with `linkToTask(_:)`. Default behavior is unchanged for existing callers. Refactors `retrieveImageFromCache` to share `deliverTargetCacheHit` / `deliverOriginalCacheHit` helpers with the new async path. Fixes #2512
There was a problem hiding this comment.
Pull request overview
Adds an opt-in asyncCacheTypeCheck option to move the cache-existence probe (the “is this cached?” check) off the caller thread, addressing reported main-thread hangs under disk pressure by dispatching cache-type checks onto the cache I/O queue while keeping the API synchronous (returns a DownloadTask immediately).
Changes:
- Introduces
KingfisherOptionsInfoItem.asyncCacheTypeCheckand parses it intoKingfisherParsedOptionsInfo. - Adds an async-probe retrieval path in
KingfisherManagerthat returns aDownloadTaskshell immediately and links it to the real task after the async cache probe. - Refactors cache-hit delivery logic into shared helper methods and adds new unit tests covering key cache/miss/cancellation scenarios.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
Sources/General/KingfisherOptionsInfo.swift |
Adds the new public option, docs, and parsed-options plumbing. |
Sources/General/KingfisherManager.swift |
Implements async cache-type probing path + shared cache-hit delivery helpers. |
Tests/KingfisherTests/KingfisherManagerTests.swift |
Adds tests for the new option across cache states, only-from-cache behavior, original-cache fallback, and cancellation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -760,99 +772,272 @@ public class KingfisherManager: @unchecked Sendable { | |||
| let originalImageCacheType = originalCache.imageCachedType( | |||
| forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier) | |||
There was a problem hiding this comment.
Same as above for the original-cache probe: originalCache.imageCachedType(...) ignores options.forcedExtension, which can mis-detect cached originals when a forced extension is in use. Passing forcedExtension: options.forcedExtension would also align the sync and async probe paths.
| forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier) | |
| forKey: key, | |
| processorIdentifier: DefaultImageProcessor.default.identifier, | |
| forcedExtension: options.forcedExtension) |
|
|
||
| if options.onlyFromCache { | ||
| let error = KingfisherError.cacheError(reason: .imageNotExisting(key: source.cacheKey)) | ||
| completionHandler?(.failure(error)) |
There was a problem hiding this comment.
The public doc for retrieveImage says completionHandler is invoked on options.callbackQueue, but this onlyFromCache early-exit calls completionHandler directly on the caller thread. This is also now inconsistent with the new asyncCacheTypeCheck path (which dispatches onto callbackQueue). Consider wrapping this failure in options.callbackQueue.execute { ... } to match the documented behavior.
| completionHandler?(.failure(error)) | |
| options.callbackQueue.execute { | |
| completionHandler?(.failure(error)) | |
| } |
| let wrapped = self.loadAndCacheImage( | ||
| source: source, | ||
| context: context, | ||
| completionHandler: completionHandler)?.value | ||
| completionHandler: completionHandler) | ||
| if let realTask = wrapped?.value { | ||
| shell.linkToTask(realTask) | ||
| } | ||
| } |
There was a problem hiding this comment.
In the async probe path, shell is only linked when loadAndCacheImage(...) returns a non-nil wrapped task with a non-nil value. This misses at least two real cases: (1) provider-backed sources (.dataProviding) where cancellation relies on providerTask (not sessionTask/cancelToken), and (2) network loads that return nil due to async requestModifier initialization (where users are told to use onDownloadTaskStarted). In these cases the returned shell may never become cancellable. Consider handling .dataProviding by transferring the underlying providerTask onto shell, and in the async-request-modifier case wiring onDownloadTaskStarted (or otherwise capturing the downloader task) to link it back to shell.
| /// By default, ``KingfisherManager`` calls ``ImageCache/imageCachedType(forKey:processorIdentifier:forcedExtension:)`` | ||
| /// before deciding between a cache read and a network download. That call performs file-system `stat` syscalls on | ||
| /// whatever thread invoked `setImage`. When `setImage` is called from UIKit layout callbacks such as | ||
| /// `tableView(_:cellForRowAt:)` or `collectionView(_:cellForItemAt:)` on a device under disk pressure, those syscalls | ||
| /// can hang the main thread. | ||
| /// | ||
| /// Opt in to this flag to move the probe onto the cache's I/O queue via | ||
| /// ``ImageCache/imageCachedTypeAsync(forKey:processorIdentifier:forcedExtension:callbackQueue:completionHandler:)``. | ||
| /// The ``DownloadTask`` returned from `setImage` is still delivered synchronously; if the probe discovers a cache | ||
| /// miss, the resulting network task is linked to the returned shell via ``DownloadTask/linkToTask(_:)``. | ||
| /// | ||
| /// - Note: If ``KingfisherOptionsInfoItem/loadDiskFileSynchronously`` is set, that option continues to govern the | ||
| /// actual disk read. This flag only affects the existence probe that precedes the read. | ||
| case asyncCacheTypeCheck |
There was a problem hiding this comment.
The doc comment for asyncCacheTypeCheck states that KingfisherManager calls ImageCache.imageCachedType(forKey:processorIdentifier:forcedExtension:) by default, but the current sync probe in retrieveImageFromCache does not pass forcedExtension. Either update the manager probe to include forcedExtension (so the doc is accurate) or adjust this comment to reflect the actual behavior.
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | ||
| XCTAssertNotNil(result.value?.image) | ||
| XCTAssertEqual(result.value!.cacheType, .none) | ||
|
|
||
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | ||
| XCTAssertNotNil(result.value?.image) | ||
| XCTAssertEqual(result.value!.cacheType, .memory) | ||
|
|
||
| manager.cache.clearMemoryCache() | ||
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | ||
| XCTAssertNotNil(result.value?.image) | ||
| XCTAssertEqual(result.value!.cacheType, .disk) | ||
|
|
||
| manager.cache.clearMemoryCache() | ||
| manager.cache.clearDiskCache { | ||
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | ||
| XCTAssertNotNil(result.value?.image) | ||
| XCTAssertEqual(result.value!.cacheType, .none) | ||
| exp.fulfill() | ||
| }}}}} |
There was a problem hiding this comment.
testRetrieveImageWithAsyncCacheTypeCheckAcrossCacheStates is structured as deeply nested completion handlers with a trailing }}}}}}, which is hard to read and easy to break when editing. Consider refactoring to clearer sequencing (e.g., helper methods, chaining via a small state machine, or using async/await if available in the test target) so each step’s scope is explicit.
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | |
| XCTAssertNotNil(result.value?.image) | |
| XCTAssertEqual(result.value!.cacheType, .none) | |
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | |
| XCTAssertNotNil(result.value?.image) | |
| XCTAssertEqual(result.value!.cacheType, .memory) | |
| manager.cache.clearMemoryCache() | |
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | |
| XCTAssertNotNil(result.value?.image) | |
| XCTAssertEqual(result.value!.cacheType, .disk) | |
| manager.cache.clearMemoryCache() | |
| manager.cache.clearDiskCache { | |
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | |
| XCTAssertNotNil(result.value?.image) | |
| XCTAssertEqual(result.value!.cacheType, .none) | |
| exp.fulfill() | |
| }}}}} | |
| let expectedCacheTypes: [CacheType] = [.none, .memory, .disk, .none] | |
| func runStep(_ index: Int) { | |
| manager.retrieveImage(with: url, options: [.asyncCacheTypeCheck]) { result in | |
| XCTAssertNotNil(result.value?.image) | |
| XCTAssertEqual(result.value!.cacheType, expectedCacheTypes[index]) | |
| switch index { | |
| case 0: | |
| runStep(1) | |
| case 1: | |
| manager.cache.clearMemoryCache() | |
| runStep(2) | |
| case 2: | |
| manager.cache.clearMemoryCache() | |
| manager.cache.clearDiskCache { | |
| runStep(3) | |
| } | |
| default: | |
| exp.fulfill() | |
| } | |
| } | |
| } | |
| runStep(0) |
| // Give the async cache probe time to resolve and link the real download task onto the shell, | ||
| // then cancel through the shell before flushing the delayed stub. | ||
| DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { | ||
| task?.cancel() | ||
| _ = stub.go() | ||
| teardownExp.fulfill() | ||
| } |
There was a problem hiding this comment.
testRetrieveImageWithAsyncCacheTypeCheckReturnsCancellableShellOnMiss uses a fixed asyncAfter(... + 0.3) delay to wait for the probe/link to happen before cancelling. This kind of timing-based coordination can be flaky on slow CI or under load. Prefer synchronizing on an explicit signal (e.g., an expectation fulfilled when the returned task becomes initialized/linked, or when the downloader’s stub is observed to start) before calling cancel().
| let key = source.cacheKey | ||
| let targetImageCached = targetCache.imageCachedType( | ||
| forKey: key, processorIdentifier: options.processor.identifier) | ||
|
|
There was a problem hiding this comment.
retrieveImageFromCache does not pass options.forcedExtension into imageCachedType(...). This can incorrectly report a cache miss/hit when .forcedCacheFileExtension is used, and it makes the default (sync) path behave differently from the new async probe path (which does pass forcedExtension). Consider calling imageCachedType(forKey:processorIdentifier:forcedExtension:) here with forcedExtension: options.forcedExtension.
Summary
KingfisherOptionsInfoItem.asyncCacheTypeCheck. When set, the cache-existence probe that precedes everyretrieveImagecall is dispatched onto the cache's I/O queue viaimageCachedTypeAsync, instead of runningstatsyscalls synchronously on the caller thread.retrieveImagestill returns aDownloadTasksynchronously. With the opt-in flag it returns a shell, probes the cache asynchronously, and on cache miss links the resulting session task onto the shell viaDownloadTask.linkToTask(_:).deliverTargetCacheHit/deliverOriginalCacheHithelpers so both sync and async paths share their cache-hit delivery logic.Motivation
Reported in #2512: under disk pressure,
ImageCache.imageCachedType→DiskStorage.Backend.isCached→fileManager.fileExistsperformsstatsyscalls on whatever thread calledsetImage. WhensetImageruns from UIKit layout callbacks (tableView(_:cellForRowAt:),collectionView(_:cellForItemAt:)) these syscalls hang the main thread.Usage
Test plan
bundle exec fastlane test destination:"platform=iOS Simulator,name=iPhone 17"— 336 tests pass.KingfisherManagerTestscovering memory hit, disk hit, cache miss → download,onlyFromCache,originalCachefallback, and cancellation of the returned shell.Fixes #2512