Understanding Task cancellation

This async function responds to cancellation fine when cancelled via a parent Task:

func get(url: URL) async throws -> Bool {
    do {
        let (_, response) = try await URLSession.shared.data(from: url)
        let httpResponse = response as! HTTPURLResponse
        return httpResponse.statusCode == 200
    } catch URLError.cancelled {
        throw CancellationError()
    } catch {
        throw error
    }
}

but when it is wrapped in another function and within another Task, no error gets thrown by the original function:

func getWrapped(url: URL) async throws -> Bool {
    let task = Task {
        try await get(url: url)
    }
    return try await task.value
}

I thought the Task cancellation would get passed down, and then bubble back up as the thrown error (without needing to explicitly check for cancellation). What am I missing here?

Unstructured tasks do not get cancelled whenever their originating task gets cancelled, you need to explicitly call cancel() on the handle.

There's a handy table from a WWDC talk that summarizes the differences:

2 Likes

Ah ok, I was misunderstanding. So to propagate cancellation through an intermediate function Task? Using withTaskCancellationHandler?

In the case you have, you'd typically just call the function directly: return try await get(url: url). But yes, if you want to propagate cancellation to an unstructured task, you need to catch it in withTaskCancellationHandler first:

func getWrapped(url: URL) async throws -> Bool {
    let task = Task {
        try await get(url: url)
    }
    return try await withTaskCancellationHandler {
        try await task.value
    } onCancel: {
        task.cancel()
    } 
}
2 Likes

Ok, this makes more sense to me now, thanks for the help!

This package from PointFree defines an extension on Task that, when awaited on, will propagate cancellation automatically (by using withTaskCancellationHandler). If you don't want to use the whole package, it's a pretty small extension you could copy into your project.

extension Task {
  public var cancellableValue: Success { 
    get async throws {
      try await withTaskCancellationHandler {
        try await self.value
      } onCancel: {
        self.cancel()
      }
    }
  }
}

FWIW, I'd carefully advise against using this pervasively, since typically the point of creating an unstructured Task is to create some sort of a "future" that multiple places would want to await (as again, for singular points of use, the standard try await or async let is sufficient).

If you enable cancelling a task this easily, you can have very weird effects where one piece of code that doesn't really need this value would happily cancel the task, but other places that depend on it more heavily wouldn't ever cancel it, yet the task still gets cancelled by that low-priority caller. It is as if an instance of a class was to be destroyed as soon as the first of potentially hundreds of references to it gets released — this is very asymmetric and can be very hard to debug.

4 Likes