How to use withTaskCancellationHandler properly?

This is something that was asked already for a Combine Publisher type here. But is such a common pattern that I'm still curious what's the best approach.

The question is, when you have a type that takes care of cancelling, but that must be created inside the async operation closure, how you keep it around safely?

 public func asyncFunc() async throws -> FeatureQueryResult {
        var cancellation: TypeThatCancels? // Publisher subscription, network operation...
        return try await withTaskCancellationHandler {
            cancellation?.cancel()
        } operation: {
            try await withCheckedThrowingContinuation { continuation in
                cancellation = StartTheWork() {
                    continuation.resume(with: $0)
                }
            }
        }
    }

The compiler will complain that we're referencing var cancellation from concurrent code, which is of course true.

Looking online the only solutions I've seen is to wrap the cancellation in a class, but is that the correct and intended solution? It feels too fragile. Seems like it just shuts up the compiler but probably doesn't fix the real issues.

Another approach would be to wrap it in an actor but doesn't that feel too much? Also at that point you need to care a lot of timings and check for cancellation inside the operation closure too.

I'm surprised to not find any more official or well explained solutions for such a common scenario. Any advice will be appreciated. Thanks!


Sometimes the workaround is easy, if the object we're using can give us a cancellation token separetly from the closure that gives us the results then we can just move the assignment outside.

 public func asyncFunc() async throws -> FeatureQueryResult {
        var request = someObject.createRequest() // gives us something that can start and also cancel the request
        return try await withTaskCancellationHandler {
            request.cancel()
        } operation: {
            try await withCheckedThrowingContinuation { continuation in
                request.start {
                    continuation.resume(with: $0)
                }
            }
        }
    }

But that is rarely the case.

Closures that have @Sendable function type can only use by-value captures. Captures of immutable values introduced by let are implicitly by-value; any other capture must be specified via a capture list:

public func asyncFunc() async throws -> FeatureQueryResult {
        var cancellation: TypeThatCancels? // Publisher subscription, network operation...
        return try await withTaskCancellationHandler { [cancellation] in
            cancellation?.cancel()
        } operation: {
            try await withCheckedThrowingContinuation { continuation in
                cancellation = StartTheWork() {
                    continuation.resume(with: $0)
                }
            }
        }
    }

Thanks for the link ^^

Sadly the proposed solution doesn't seem valid. Since you capture by-value it means what is being captured is nil, and thus when the cancellation closure runs it doesn't point to the correct instance set by the start work.

I thought that Sendable would help, but even making the cancellation token sendable doesn't change anything since what the compiler complains about is the var binding itself.

This brings us back to the suggestions I found, wrap the cancellation token in another class so you can have a let and still keep a reference to the token. Seems unfortunate with such a common scenario.

So my original doubts still stand: is wrapping it in a class the best solution? is it safe enough or should we use an actor? Or is there some other trick?

Thanks

Hope this example I just coded helps:

func requestPlacemark() async -> CLPlacemark? {
    let geocoder = CLGeocoder()
    return await withTaskCancellationHandler {
        geocoder.cancelGeocode()
    } operation: {
        await withCheckedContinuation { continuation in
            geocoder.reverseGeocodeLocation(location) { placemarks, error in
                continuation.resume(returning: placemarks?.first)
            }
        }
    }
}

Next I plan on handling the returned error, likely converting it to a throw.

Thanks but this example is exactly what my last paragraph mentions.

So that's the easy case ^^ I still wonder about the more tricky and common one :slight_smile:

I found a workaround for the compilation error here you could try, e.g.

        let onCancel = { dataTask?.cancel() }
        return try await withTaskCancellationHandler {
            onCancel()
        } operation: {
           ...
        }
    }
2 Likes

That's actually an interesting one!

The closure performs the same trick as the class: hiding the capture in a reference type that can itself be captured. But with less boilerplate.

Thanks! :clap:

Glad to help!

Something very important I also learned the hard way is that the operation is executed even if cancel is called so you have to check if the task is cancelled inside operation. You might not see this in some code examples that use Foundation's async methods because apparently check for cancellation and thus throw exceptions inside them. When calling other completion block-based APIs we do need to check for cancellation inside the operation block, e.g.

    func requestPlacemark(location: CLLocation) async throws -> CLPlacemark? {
        var geocoder: CLGeocoder?
        let onCancel = { geocoder?.cancelGeocode() }
        return try await withTaskCancellationHandler {
            onCancel()
        } operation: {
            try Task.checkCancellation() // need to check if the task was cancelled before it started.
            return try await withCheckedThrowingContinuation { continuation in
            // should we also check cancellation here? I don't know, it seems to be on the same thread called inline with earlier line so maybe not needed.

I had been calling my func from SwiftUI inside .task(id:location) and found that when location changed a few times in quick succession on app resume (from CLLocationManagerDelegate) the tasks were not being cancelled thus many happening simultaneously and resulted in the wrong placemark being shown.

1 Like

Yes sure! Thankfully I already knew that from the early days of the proposals so I didn't catch me by surprise :joy: but I admit is yet another trap with the cancellation API.

I usually write two cancellation unit test for code that uses continuations:

  1. Check that cancellation happens when the task is immediately cancelled. This usually uncovers the issue that you mention.
  2. Check that cancellation happens when the task is cancelled with a delay.

I sometimes have this question too but I found that is not needed, at least with the tests I've been doing.

1 Like
Terms of Service

Privacy Policy

Cookie Policy