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 owningTaskto 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
TaskGroupto 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.