Testing Concurrency's Implicit Cancellation

In adding Concurrency support to Alamofire, I've run into trouble contriving test scenarios that properly exercise my underlying Task's handling of implicit cancellation. I've created a DataTask type which wraps an internal Task and provides async access to the network response, result, or value. These Tasks are created from DataRequests like this:

private func dataTask<Value>(forResponse onResponse: @escaping (@escaping (DataResponse<Value,
AFError>) -> Void) -> Void) -> DataTask<Value> {
    let task = Task {
        await withTaskCancellationHandler {
            self.cancel()
        } operation: {
            await withCheckedContinuation { continuation in
                onResponse {
                    continuation.resume(returning: $0)
                }
            }
        }
    }

    return DataTask<Value>(request: self, task: task)
}

In the manual case (dataTask.cancel()) which forwards to the stored task.cancel(), the cancellation handler is hit just fine and the underlying DataRequest is properly cancelled, as demonstrated by this simple test.

func testThatDataTaskCancellationCancelsRequest() async {
    // Given
    let session = stored(Session())
    let task = session.request(.get).decode(TestResponse.self)

    // When
    task.cancel()
    let response = await task.response

    // Then
    XCTAssertTrue(response.error?.isExplicitlyCancelledError == true)
    XCTAssertTrue(task.isCancelled, "Underlying DataRequest should be cancelled.")
}

However, I can come up with no construct that properly demonstrates the behavior of the cancellation handler implicitly. Nor does the proposal or documentation I've found fully outline the expected behavior here. I've tried all of these approaches without success:

  • Cancelling a wrapping Task:
func testThatDataTaskIsCancelledInChildTask() async {
    // Given
    let session = stored(Session())
    let request = session.request(.get)

    // When
    let task = Task {
        _ = await request.decode(TestResponse.self).response
    }
    task.cancel()
    _ = await task.value

    // Then
    XCTAssertTrue(request.error?.isExplicitlyCancelledError == true)
    XCTAssertTrue(task.isCancelled, "Parent Task should be cancelled.")
}

In this case, task.isCancelled is still false, which doesn't make sense to me. However, the proposal text is unclear whether this should result in implicit cancellation, as cancellation is not mentioned in the context properties that are shared in unstructured tasks.

  • Double wrapping the Tasks, thinking that I might need an owning Task to properly propagate the cancellation.
func testThatDataTaskIsCancelledInChildTask() async {
    // Given
    let session = stored(Session())
    let request = session.request(.get)

    // When
    let task = Task {
        _ = await Task {
            _ = await request.decode(TestResponse.self).response
        }.value
    }
    task.cancel()
    _ = await task.value

    // Then
    XCTAssertTrue(request.error?.isExplicitlyCancelledError == true)
    XCTAssertTrue(task.isCancelled, "Parent Task should be cancelled.")
}

Same failures.

  • Putting the work in a TaskGroup to create a child relationship.
func testThatDataTaskIsCancelledInChildTask() async {
    // Given
    let session = stored(Session())
    let request = session.request(.get)

    // When
    let task = Task {
        await withTaskGroup(of: AFDataResponse<TestResponse>.self) { group in
            group.addTask {
                await request.decode(TestResponse.self).response
            }
        }
    }
    task.cancel()
    _ = await task.value

    // Then
    XCTAssertTrue(request.error?.isExplicitlyCancelledError == true)
    XCTAssertTrue(task.isCancelled, "Parent Task should be cancelled.")
}

Same failures as before, despite the fact I can check Task.isCancelled after awaiting the completion of the group and it's properly true.

At the very least the task.isCancelled value always being false seems like a bug, as it should be true immediately. But I can't explain the other behavior. Does anyone have any guidance on implicit cancellation propagation or working test examples of it? At this point this is one of the last blocking issues for Alamofire's concurrency support.

4 Likes

There was a somewhat recent thread which revealed that the initial implementation of the instance variable task.isCancelled always returned the value of the type variable Task.isCancelled. You could be experiencing that bug.

Ah, thanks. Looks like that was merged into the 5.5 branch three weeks ago, so hopefully we'll see it in a release soon.

That explains why checking the property always fails, but doesn't explain why I can't get the propagation to work. Unless the propagation relies on that property somehow. Sorry to ping, but @ktoso, can you confirm any of the expectations or bugs here?

1 Like

The double-wrapped task is not a child, not implicitly cancelled in that case.

regarding this:

That is a bug. Try this instead:

func testThatDataTaskIsCancelledInChildTask() async {
    // Given
    let session = stored(Session())
    let request = session.request(.get)

    // When
    let task = Task {
        await Task.yield()
        // Task, not task
        XCTAssertTrue(Task.isCancelled, "Current Task should be cancelled.")
        _ = await request.decode(TestResponse.self).response
    }
    task.cancel()
    _ = await task.value

    // Then
    XCTAssertTrue(request.error?.isExplicitlyCancelledError == true)
}

Also, if I understand this code correctly:

private func dataTask<Value>(forResponse onResponse: @escaping (@escaping (DataResponse<Value,
AFError>) -> Void) -> Void) -> DataTask<Value> {
    let task = Task {
        await withTaskCancellationHandler {
            self.cancel()
        } operation: {
            await withCheckedContinuation { continuation in
                onResponse {
                    continuation.resume(returning: $0)
                }
            }
        }
    }

    return DataTask<Value>(request: self, task: task)
}

Then you will need whatever object (looks like DataTask) that is using that task instance to have somewhere a withCancellationHandler that cancels that task instance.

Isn't that what the wrapped logic does? If I manually cancel() my captured Task, my cancellation handler is properly called.

No it doesn't. I explained that in a previous post a few minutes ago which the spam filter is temporarily hiding for some reason, sorry.

I'm not sure what you mean then. Manual cancellation of the Task works correctly with the current construction.

You are creating an unstructured task in that code and giving it to DataTask<Value>. You must manually cancel that task.

That doesn't happen in this code:

    let task = Task {
        _ = await request.decode(TestResponse.self).response
    }
    task.cancel()

Because of this and this.

So no automatic cancellation can happen at all, ever? That seems... not ideal. How am I supposed to create a Task I can wrap that properly follows implicit cancellation? Or, more generally, how can I wrap a completion handler API in a way that handles implicit cancellation?

1 Like

No, cancellation propagates automatically for structured tasks. For unstructured tasks, you can use withTaskCancellationHandler in the Task that you want to link cancellation up to.

I think that's the question. Because I have a use-case where we need to propagate cancellation from an async context into an unstructured task, but we are using withTaskCancellationHandler to do that. From a completion handler API, I'm not sure how you would do it. It seems like you need some kind of token or cancellable.

1 Like

Which I have, so what are you talking about here? Do you mean that users would need to wrap my DataTasks in a cancellation handler? That seems awful.

I mean, I have a token, my DataTask, but that doesn't help with implicit cancellation, apparently. I guess I assumed withTaskCancellationHandler would enable the automatic cancellation but apparently not.

Is this really not possible right now?

Maybe I don't understand your specific situations. But here are some examples you might be able to apply to your situation.

Here is basic cancellation:


    @Sendable func hello() async throws -> String {
        await Task.yield()
        try Task.checkCancellation()
        return "Hello"
    }
    
    func testTaskCancellation() async throws {
        let t = Task<String, Error> {
            return try await hello()
        }
        t.cancel()
        
        do {
            _ = try await t.value
            XCTFail("expected an error to be thrown")
        } catch {
            XCTAssertTrue(error is CancellationError)
        }
    }

Here is cancellation of a child task, which is what I thought is similar to your situation:

    // This is different than `hello()` above because it creates an unstructured task and
    // thus needs to propagate cancellation.
    @Sendable func hello2() async throws -> String {
        let task = Task<String, Error> {
            await Task.yield()
            try Task.checkCancellation()
            return "Hello"
        }
        return try await withTaskCancellationHandler {
            task.cancel()
        } operation: {
            try await task.value
        }
    }
    
    func testChildTaskCancellation() async throws {
        let t = Task<String, Error> {
            return try await hello2()
        }
        t.cancel()
        
        do {
            _ = try await t.value
            XCTFail("expected an error to be thrown")
        } catch {
            XCTAssertTrue(error is CancellationError)
        }
    }

My ideal is to allow Alamofire users to enter the async world synchronously, as they can with our completion handler and Combine APIs already. Currently, you can create a DataRequest, vend the DataTask, and then await the output of your choosing.

let task = AF.request(...)
             .decode(Type.self)
let response = await task.response // or .result, or try await .value

or

await AF.request(...)
        .decode(Type.self)
        .response // or .result, or try await .value

Internally the DataTask keeps a reference to the Task that wraps Alamofire's completion handler API. DataTask also provides it's own lifetime methods, with cancel() calling into the Task for cancellation, which cancels the underlying DataRequest through the cancellation handler. DataTask also exposes the DataRequest's properties through @dynamicMemberLookup, so you can access various bits of state there as well.

I'd like to keep this interaction synchronous until the user decides to enter the async world by awaiting one of the outputs. I'd especially like to avoid the try await syntax, as it can be confusing with all of the other APIs that you can chain onto Alamofire's DataRequest.

I supposed I could just not provide implicit cancellation support. It doesn't exist on DataRequest normally, though our Combine publishers support it. This does seem like a missing part of the concurrency story though.

task.isCancelled has a bug that was fixed in [Concurrency] SR-15309: Fix instance task.isCancelled impl to use _task by ktoso ยท Pull Request #39710 ยท apple/swift ยท GitHub

Unstructured tasks, i.e. Task {} don't get cancelled automagically, since... they're unstructured.

There does not seem to be a non-async withCancellationHandler which is what you'd need here... and it'd only fire if you are actually inside a task... Worth filing an issue for on bugs.swift.org` Meh, nope, we would not be able to implement that.

This works correctly, aside from the task.isCancelled bug.

func testThatDataTaskIsCancelledInChildTask() async {
    // Given
    let session = stored(Session())
    let request = session.request(.get)

    // When
    let dataTask = request.decode(TestResponse.self)
    let task = Task {
        await withTaskCancellationHandler {
            dataTask.cancel()
        } operation: {
            await dataTask.response
        }
    }
    task.cancel()
    let response = await task.value

    // Then
    XCTAssertTrue(response.error?.isExplicitlyCancelledError == true)
    XCTAssertTrue(task.isCancelled, "Parent Task should be cancelled.")
    XCTAssertTrue(request.isCancelled, "Underlying DataRequest should be cancelled.")
}

Doesn't really prove anything about my implementation though, so it doesn't seem useful. Seems I'll have to go without automatic cancellation.

Wrapping DataTask's async properties vended from the Task in their own cancellation handler seems to get the behavior I want here.

/// `DataResponse` produced by the `DataRequest` and its response handler.
public var response: DataResponse<Value, AFError> {
    get async {
        await withTaskCancellationHandler {
            self.cancel()
        } operation: {
            await task.value
        }
    }
}
1 Like

Though since users could await any or all of the values, I wonder whether it's desirable to cancel the overall request because one await was cancelled.

This is similar to the code I posted above in the testChildTaskCancellation() test. I hope I was able to help.

We were faced with a similar situation in our code-base and we decided that one cancellation from a consumer would cancel the overall task, and thus other consumers would get a CancellationError. We felt that cancellation was fairly explicit (as you are now keenly aware by just trying to implement it), so if one consumer cancels, the task all consumers are waiting on is cancelled.

Yes, I think your sample code lead to my realization that it wasn't just Task that could have a cancellation handler but any await statement in general. So while there's no auto cancellation for my DataTask if you aren't awaiting any of its properties, I can at least add support once you actually await something. I've also decided to make auto-cancellation configurable and opt-in so users start with explicit cancellation only. That should properly cover the needed bases without too much complexity.

1 Like