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 Task
s are created from DataRequest
s 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
Task
s, thinking that I might need an owningTask
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.