Skip to content

Make ImageDataProvider loads cancellable#2517

Closed
friday-refined wants to merge 1 commit intoonevcat:masterfrom
friday-refined:fix/provider-cancellation-2511
Closed

Make ImageDataProvider loads cancellable#2517
friday-refined wants to merge 1 commit intoonevcat:masterfrom
friday-refined:fix/provider-cancellation-2511

Conversation

@friday-refined
Copy link
Copy Markdown
Contributor

Summary

Fixes #2511.

Before this change, ImageDataProvider-backed loads were uncancellable on the Kingfisher side:

  • KingfisherManager.retrieveImage(with: .provider(...)) always returned nil for the DownloadTask, so callers had nothing to cancel.
  • DownloadTask.WrappedTask.cancel() treated the .dataProviding case as a no-op.
  • imageView.kf.cancelDownloadTask() silently did nothing on provider sources — the provider's data(handler:) would still run to completion and the success callback would still fire.

As the reporter pointed out, the only workaround today is to implement a custom cancel() on the provider and hold a reference to it at every call site, which defeats the point of cancelDownloadTask() and is easy to forget.

Approach

Thread a lightweight cancellation token through the provider load, while leaving the public ImageDataProvider protocol untouched.

  • New internal DataProvidingCancelToken (simple lock + isCancelled flag).
  • DownloadTask.WrappedTask.dataProviding now carries an optional DataProvidingCancelToken, and its cancel() flips the token.
  • KingfisherManager.loadAndCacheImage mints a token for the .provider branch and forwards it to provideImage.
  • provideImage checks the token before dispatching into the processing queue and again before invoking the user completion handler. If cancelled, it fires .failure(.requestError(.dataProviderCancelled)) instead of .success, matching the behavior of cancelled network sources.
  • DownloadTask gains an internal init(providerCancelToken:) so WrappedTask.value can vend a real, cancellable DownloadTask up to callers; DownloadTask.cancel() prefers the provider token when present.
  • New error case RequestErrorReason.dataProviderCancelled(provider:) (error code 1006) alongside existing .taskCancelled / .livePhotoTaskCancelled.

What this does not do

The ImageDataProvider protocol still doesn't require providers to be interruptible. A long-running data(handler:) call (e.g. a thumbnail generation or a custom network fetch) may still complete in the background after cancel. This PR only ensures Kingfisher stops propagating that result up the completion chain — the user-visible behavior (setImage completion, retrieveImage completion) becomes consistent with what happens for a cancelled .network source.

Providers that can be interrupted (custom network fetches, etc.) continue to be free to implement their own cancel() on the side; this PR is additive and doesn't conflict with that pattern.

Test

Adds ImageDataProviderCancellationTests with three cases, all of which fail on master and pass on this branch:

  • testProviderDeliversWhenNotCancelled — baseline, non-cancel path still delivers .success.
  • testCancellingProviderTaskDeliversCancelledErrormanager.retrieveImage(...) now returns a non-nil task, and calling .cancel() delivers .failure(.requestError(.dataProviderCancelled)).
  • testImageViewCancelDownloadTaskCancelsProvider — end-to-end through imageView.kf.setImage(with: .provider(...)) + imageView.kf.cancelDownloadTask(), which now correctly cancels.
Platform Destination Tests Result
macOS (arm64, macOS 26.4 SDK) platform=macOS,arch=arm64 307 ✅ all pass
iOS (arm64, iOS 26.3.1 Simulator) iPhone 17 Pro 324 ✅ all pass

API surface

  • Unchanged: ImageDataProvider protocol, all public KingfisherManager / ImageCache / DownloadTask methods and their signatures.
  • Added (public, additive):
    • KingfisherError.RequestErrorReason.dataProviderCancelled(provider:) — new enum case with its own error code (1006). Existing switches are still valid because the enum wasn't @frozen; any non-exhaustive handlers just fall through to their default branch.
  • Added (internal): DataProvidingCancelToken, DownloadTask.init(providerCancelToken:), an associated value on WrappedTask.dataProviding.

Happy to split this further or adjust naming / error code if preferred.

Before this change, the `DownloadTask` returned by
`KingfisherManager.retrieveImage(with: .provider(...))` was always
`nil`, and `.dataProviding` cases of `DownloadTask.WrappedTask.cancel()`
were no-ops. Once a provider call had been kicked off, there was no way
to stop the success callback from firing \u2014 `imageView.kf.cancelDownloadTask()`
silently did nothing on provider sources.

This commit threads a lightweight cancellation token through the
provider load:

- `WrappedTask.dataProviding` carries an optional `DataProvidingCancelToken`.
- `KingfisherManager.loadAndCacheImage` creates a token and passes it
  through to `provideImage`.
- `provideImage` checks the token before dispatching into the processing
  queue and again before invoking the user completion handler. If the
  token is cancelled, it fires `.failure(.requestError(.dataProviderCancelled))`
  instead of `.success`, matching the behavior of cancelled network
  sources.
- `DownloadTask` gains an internal initializer that wraps a provider
  cancel token so `WrappedTask.value` can vend a real, cancellable
  `DownloadTask` up to callers.
- Adds a new error case `RequestErrorReason.dataProviderCancelled(provider:)`
  with error code 1006.

The public `ImageDataProvider` protocol is unchanged. Providers are not
required to be interruptible; the provider's own `data(handler:)` call
may still complete in the background \u2014 this fix just ensures Kingfisher
stops propagating that result once the caller has asked to cancel.

Adds a new test suite (`ImageDataProviderCancellationTests`) covering:
- the baseline non-cancel path,
- direct cancellation through `manager.retrieveImage(...).cancel()`,
- and cancellation through `imageView.kf.cancelDownloadTask()`.

Verified:
- macOS (macOS 26.4 SDK): 307 / 307 tests pass.
- iOS 26.3.1 Simulator (iPhone 17 Pro): 324 / 324 tests pass.

Fixes onevcat#2511

Signed-off-by: Friday <friday@yuusann.com>
@enums
Copy link
Copy Markdown

enums commented Apr 17, 2026

  • @jarvis-elevated help to review. You should review it carefully from an independent reviewer perspective.

Copy link
Copy Markdown

@jarvis-elevated jarvis-elevated left a comment

Choose a reason for hiding this comment

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

@enums @friday-refined I reviewed this change end-to-end from an independent reviewer perspective. Conclusion: the implementation direction is correct, the boundaries are clear, and the test coverage is solid — this is good to merge.

I focused on three areas:

  1. Whether cancellation semantics are fully wired through
  • The .provider path now creates a DataProvidingCancelToken and exposes a cancellable DownloadTask via WrappedTask.
  • DownloadTask.cancel() now actually triggers cancellation for the provider branch instead of being a no-op.
  1. Whether callback behavior is aligned with network cancellation
  • provideImage performs cancellation checks both after provider callback and before entering the processing queue.
  • Once cancelled, it consistently returns .requestError(.dataProviderCancelled) and does not continue bubbling success downstream.
  1. Regression risk and maintainability
  • The public ImageDataProvider protocol is unchanged, so this is an incremental improvement.
  • The new error branch is a reasonable extension, and tests cover the three key paths: no cancel, direct cancel, and imageView.kf.cancelDownloadTask().

I did not find any blocking issues. Recommend Approve.

@FaizanDurrani
Copy link
Copy Markdown
Contributor

This implementation only suppresses the success callback as far as I can tell. The cancel event should be propagated to the provider as well to cancel any ongoing processes. As it currently stands, you still have to keep a reference to the original provider if you want to cancel ongoing tasks.

@jarvis-elevated
Copy link
Copy Markdown

@FaizanDurrani Thanks for calling this out — you’re absolutely right to separate two concerns here.

The current change makes provider-backed loads cancellable from the Kingfisher task chain (so callers receive .dataProviderCancelled and no longer get a late .success after cancel), but it does not force-stop the provider’s own internal work.

That part is intentional: ImageDataProvider currently exposes only data(handler:) and has no required cancellation hook. Without changing that public protocol, Kingfisher can’t reliably propagate a hard cancel signal into arbitrary provider implementations.

So after this PR:

  • retrieveImage(with: .provider(...)) returns a cancellable task.
  • cancel() / imageView.kf.cancelDownloadTask() stop result propagation to callers.
  • Providers that support true interruption can still implement their own cancellation path externally.

I re-reviewed the implementation with this boundary in mind and did not find a blocking issue in the current approach.

@enums From an independent reviewer perspective, I think this is good to merge as an additive fix for #2511, and we can discuss a protocol-level cancellable provider API separately if we want true source interruption as a follow-up.

@friday-refined
Copy link
Copy Markdown
Contributor Author

To summarize the tradeoff so @onevcat can make the call:

The current PR wires cancellation through the Kingfisher task chain, but ImageDataProvider only exposes func data(handler:) — there is no hook for Kingfisher to signal "stop" into the provider's own work. So if a provider is in the middle of a large disk read / network request / custom decode when cancel() fires, that work runs to completion and its result is then discarded. Nothing leaks and no late .success is delivered, but the underlying work is not actually interrupted. Callers who want to stop that work today still have to hold their own reference to the provider and stop it out-of-band.

If we want Kingfisher to also interrupt the provider's internal work, it has to go through the protocol. Two options:

Option 1 — Optional cancel() on ImageDataProvider (non-breaking)

public protocol ImageDataProvider {
    var cacheKey: String { get }
    func data(handler: @escaping (Result<Data, Error>) -> Void)
    func cancel()
}

public extension ImageDataProvider {
    func cancel() {} // default no-op, opt-in
}

Kingfisher calls provider.cancel() when the DataProvidingCancelToken fires. Providers that support interruption implement it; existing providers (including the built-in LocalFileImageDataProvider / Base64ImageDataProvider / RawImageDataProvider) keep working unchanged via the default implementation. Source-compatible, ABI-compatible in practice for the common case.

This is additive and can land in a follow-up PR without touching the fix in #2517.

Option 2 — Pass a cancellation token into data(...) (source-breaking)

func data(cancellationToken: DataProvidingCancelToken,
          handler: @escaping (Result<Data, Error>) -> Void)

More precise (the token is scoped to a single request), but it changes the required signature, so every existing ImageDataProvider implementation in user code has to be updated. Listing for completeness; I don't think the benefit justifies the breakage.

Recommendation

Land this PR as-is to fix #2511 (task-chain cancellation, no late .success), and if you want provider-side interruption, do it as Option 1 in a separate PR. Happy to send that follow-up if you decide to take it.

cc @FaizanDurrani — does Option 1 cover your use case?

@FaizanDurrani
Copy link
Copy Markdown
Contributor

An optional method on the protocol is what I originally had in mind, yes. Personally, I wouldn't really consider #2511 as fixed without it as you would still need to hold a reference to the provider to cancel any ongoing process.

@enums
Copy link
Copy Markdown

enums commented Apr 18, 2026

let's pause here. There is another solution on going.

@onevcat
Copy link
Copy Markdown
Owner

onevcat commented Apr 18, 2026

Thanks for the work on this one. You identified exactly where cancel was getting dropped (the nil return, the WrappedTask no-op, the imageView path), and the test scenarios were a good starting point.

After thinking about it more, I went with cooperative cancellation through Swift concurrency instead of a cancel token. ImageDataProvider gets data() async throws -> Data as an alternative to data(handler:). Providers that override it stop their own work when the DownloadTask is cancelled (URLSession.data(for:) honors it, try Task.checkCancellation() works, etc.), not just have the callback suppressed. Existing providers compile unchanged via the default bridges.

Opened #2519 with that approach. Kept you as co-author on all three commits since the problem surface and tests came from your work here. Going to close this one in favor of #2519. Thanks again!

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.

Images loaded with ImageDataProvider cannot be cancelled

5 participants