Search code examples
swiftasync-awaitcancellation

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


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


Solution

  • 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.