How to cancel an `async` function with cancellable type returned from `async` operation initiation
Asked Answered
J

2

6

I need to support cancellation of a function that returns an object that can be cancelled after initiation. In my case, the requester class is in a 3rd party library that I can't modify.

actor MyActor {

    ...

    func doSomething() async throws -> ResultData {

        var requestHandle: Handle?
    
        return try await withTaskCancellationHandler {
            requestHandle?.cancel() // COMPILE ERROR: "Reference to captured var 'requestHandle' in concurrently-executing code"
        } operation: {

            return try await withCheckedThrowingContinuation{ continuation in
            
                requestHandle = requester.start() { result, error in
            
                    if let error = error
                        continuation.resume(throwing: error)
                    } else {
                        let myResultData = ResultData(result)
                        continuation.resume(returning: myResultData)
                    }
                }
            }
        }
    }

    ...
}

I have reviewed other SO questions and this thread: https://forums.swift.org/t/how-to-use-withtaskcancellationhandler-properly/54341/4

There are cases that are very similar, but not quite the same. This code won't compile because of this error:

"Reference to captured var 'requestHandle' in concurrently-executing code"

I assume the compiler is trying to protect me from using the requestHandle before it's initialized. But I'm not sure how else to work around this problem. The other examples shown in the Swift Forum discussion thread all seem to have a pattern where the requester object can be initialized before calling its start function.

I also tried to save the requestHandle as a class variable, but I got a different compile error at the same location:

Actor-isolated property 'profileHandle' can not be referenced from a Sendable closure

Jutta answered 16/4, 2022 at 23:1 Comment(0)
S
8

You said:

I assume the compiler is trying to protect me from using the requestHandle before it’s initialized.

Or, more accurately, it is simply protecting you against a race. You need to synchronize your interaction with your “requester” and that Handle.

But I’m not sure how else to work around this problem. The other examples shown in the Swift Forum discussion thread all seem to have a pattern where the requester object can be initialized before calling its start function.

Yes, that is precisely what you should do. Unfortunately, you haven’t shared where your requester is being initialized or how it was implemented, so it is hard for us to comment on your particular situation.

But the fundamental issue is that you need to synchronize your start and cancel. So if your requester doesn’t already do that, you should wrap it in an object that provides that thread-safe interaction. The standard way to do that in Swift concurrency is with an actor.


For example, let us imagine that you are wrapping a network request. To synchronize your access with this, you can create an actor:

actor ResponseDataRequest {
    private var handle: Handle?

    func start(completion: @Sendable @escaping (Data?, Error?) -> Void) {
        // start it and save handle for cancelation, e.g.,
        
        handle = requestor.start(...)
    }

    func cancel() {
        handle?.cancel()
    }
}

That wraps the starting and canceling of a network request in an actor. Then you can do things like:

func doSomething() async throws -> ResultData {
    let responseDataRequest = ResponseDataRequest()

    return try await withTaskCancellationHandler {
        Task { await responseDataRequest.cancel() }
    } operation: {
        return try await withCheckedThrowingContinuation { continuation in
            Task {
                await responseDataRequest.start { result, error in
                    if let error = error {
                        continuation.resume(throwing: error)
                    } else {
                        let resultData = ResultData(result)
                        continuation.resume(returning: resultData)
                    }
                }
            }
        }
    }
}

You obviously can shift to unsafe continuations when you have verified that everything is working with your checked continuations.

Spermatium answered 17/4, 2022 at 5:53 Comment(2)
I failed to mention in question that the doSomething function was already in an actor where I tried to do something similar to what you suggested (I updated question). But it looks like wrapping the cancellation handle block with Task{ await requestHandle?.cancel() } is the key here. Btw, if I remove the Task wrapper in the body of the withCheckedThrowingContinuation closure, it compiles cleanly as well - so not sure if this is essential to your fix? Have yet to test this actually supports cancellation or survives stress testing. Not even sure how to write a unit test for this.Jutta
Correct. The Task wrapper in withCheckedThrowingContinuation body is only needed if one introduces that separate local actor variable to synchronize the cancelation handler.Spermatium
J
-1

After reviewing the Swift discussion thread again, I see you can do this:

...
var requestHandle: Handle?

let onCancel = { profileHandle?.cancel() }

return try await withTaskCancellationHandler {
    onCancel()
}
...
Jutta answered 17/4, 2022 at 0:57 Comment(1)
I would be wary of using this onCancel closure pattern. It actually hides the underlying problem and in Swift 6 this will start generating errors. E.g. try adding the "Other Swift Flags" build settings of -Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks.Spermatium

© 2022 - 2025 — McMap. All rights reserved.