Skip to content

Add opt-in asyncCacheTypeCheck to move cache probe off caller thread#2521

Merged
onevcat merged 9 commits intomasterfrom
fix/2512-async-cache-type-check
Apr 25, 2026
Merged

Add opt-in asyncCacheTypeCheck to move cache probe off caller thread#2521
onevcat merged 9 commits intomasterfrom
fix/2512-async-cache-type-check

Conversation

@onevcat
Copy link
Copy Markdown
Owner

@onevcat onevcat commented Apr 18, 2026

Summary

  • Adds KingfisherOptionsInfoItem.asyncCacheTypeCheck. When set, the cache-existence probe that precedes every retrieveImage call is dispatched onto the cache's I/O queue via imageCachedTypeAsync, instead of running stat syscalls synchronously on the caller thread.
  • retrieveImage still returns a DownloadTask synchronously. 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 via DownloadTask.linkToTask(_:).
  • Default behavior is unchanged; callers that do not pass the new option continue to go through the existing synchronous probe path.
  • Extracts deliverTargetCacheHit / deliverOriginalCacheHit helpers so both sync and async paths share their cache-hit delivery logic.

Motivation

Reported in #2512: under disk pressure, ImageCache.imageCachedTypeDiskStorage.Backend.isCachedfileManager.fileExists performs stat syscalls on whatever thread called setImage. When setImage runs from UIKit layout callbacks (tableView(_:cellForRowAt:), collectionView(_:cellForItemAt:)) these syscalls hang the main thread.

Usage

imageView.kf.setImage(with: url, options: [.asyncCacheTypeCheck])

Test plan

  • bundle exec fastlane test destination:"platform=iOS Simulator,name=iPhone 17" — 336 tests pass.
  • New cases in KingfisherManagerTests covering memory hit, disk hit, cache miss → download, onlyFromCache, originalCache fallback, and cancellation of the returned shell.

Fixes #2512

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.asyncCacheTypeCheck and parses it into KingfisherParsedOptionsInfo.
  • Adds an async-probe retrieval path in KingfisherManager that returns a DownloadTask shell 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.

Comment thread Sources/General/KingfisherManager.swift Outdated
@@ -760,99 +772,272 @@ public class KingfisherManager: @unchecked Sendable {
let originalImageCacheType = originalCache.imageCachedType(
forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier)
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier)
forKey: key,
processorIdentifier: DefaultImageProcessor.default.identifier,
forcedExtension: options.forcedExtension)

Copilot uses AI. Check for mistakes.
Comment thread Sources/General/KingfisherManager.swift Outdated

if options.onlyFromCache {
let error = KingfisherError.cacheError(reason: .imageNotExisting(key: source.cacheKey))
completionHandler?(.failure(error))
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
completionHandler?(.failure(error))
options.callbackQueue.execute {
completionHandler?(.failure(error))
}

Copilot uses AI. Check for mistakes.
Comment thread Sources/General/KingfisherManager.swift Outdated
Comment on lines 503 to 510
let wrapped = self.loadAndCacheImage(
source: source,
context: context,
completionHandler: completionHandler)?.value
completionHandler: completionHandler)
if let realTask = wrapped?.value {
shell.linkToTask(realTask)
}
}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +246 to +259
/// 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
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1789 to +1808
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()
}}}}}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +1825 to +1831
// 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()
}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines 747 to +750
let key = source.cacheKey
let targetImageCached = targetCache.imageCachedType(
forKey: key, processorIdentifier: options.processor.identifier)

Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@onevcat onevcat merged commit af9e2fc into master Apr 25, 2026
29 of 48 checks passed
@onevcat onevcat deleted the fix/2512-async-cache-type-check branch April 25, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ImageCache.imageCachedType performs synchronous stat calls on caller thread, causing main-thread hangs under disk pressure

2 participants