Is it possible to cancel a Task when its parent is cancelled?

This post originally had a different title and first sentence, I edited it as in retrospect it was very confusing what I was asking. I kept the rest relatively unchanged so the subsequent discussion makes sense.

Currently the only way to find out when the Task/async context you're currently running in is cancelled is to use withTaskCancellationHandler(). Based on the Swift stdlib source code, this does not have to be async; both the functions it uses internally are synchronous.

I can see some API design reasons for this; for example, even if this existed the withTaskGroup family of functions should probably be preferred over it.

The context for this is that I have tried to write a few "operator"s on AsyncSequence and I've repeatedly found myself needing a sort of half-unstructured concurrency; where async work is occurring that does not block the consumer, but should be cancelled when the consumer's Task is cancelled. flatmapLatest is one such example.

So I'm curious why this isn't offered. Is hard to implement correctly or performantly? Does it somehow violate a property of Swift Concurrency like the forward progress guarantee?

1 Like

Is there a general case for a consumer to want to use this API in a nonisolated context? If it’s not async you lose the propagation of current context.

withTaskCancellationHandler must be async because its operation parameter is an async function and wTCH calls this function. It's happening in the last line of the function body you linked to (return try await operation()):

public func withTaskCancellationHandler<T>(
  operation: () async throws -> T,
  onCancel handler: @Sendable () -> Void,
  isolation: isolated (any Actor)? = #isolation
) async rethrows -> T {
  // unconditionally add the cancellation record to the task.
  // if the task was already cancelled, it will be executed right away.
  let record = _taskAddCancellationHandler(handler: handler)
  defer { _taskRemoveCancellationHandler(record: record) }

  return try await operation()
}

I think I worded the question poorly: I know it takes an async function, but if we don't allow this function to return anything but void (which is what my use case requires) then this is irrelevant. That said, in trying to write an example to clarify the question I think I've figured out why this function can't be written in a way that's robust enough to include in the stdlib (or in most programs, for that matter).

// please excuse the terrible name
public func taskCancelledWhenParentTaskIsCancelled(
    operation: @escaping () async throws -> Void,
  isolation: isolated (any Actor)? = #isolation
) rethrows {
    let task = Task { // probably should pass isolation in explicitly here?
        try await operation()
        // we should ideally remove ourselves after the work is done,
        // but there is "no safe way to get a long lived reference to the current task"
        // which could be why this function shouldn't be written
    }
    _ = _taskAddCancellationHandler {
        task.cancel()
    }
}

I doesn’t matter whether return type is some value or Void, the operation in either way needs to be called in the same manner.

This is not equal to the stdlib function, your option creates new unstructured task inside function in an implicit way, breaking structured approach of the original, since the goal of withTaskCancellationHandler is to provide ability to handle cancellation of whatever top level task the call would be, keeping structured concurrency flow.

So like I said in my last reply, the original question was confusing. With that said:

This is not equal to the stdlib function, your option creates new unstructured task inside function in an implicit way

That's exactly what I want.

breaking structured approach of the original, since the goal of withTaskCancellationHandler is to provide ability to handle cancellation of whatever top level task the call would be, keeping structured concurrency flow.

What I've written should do that. When top level task is cancelled, the Task I created inside is as well.

I doesn’t matter whether return type is some value or Void , the operation in either way needs to be called in the same manner.

It matters a great deal for my requirements; if you don't have to return something, you can use the implicit Task to get the behavior I want, if you need to produce a value from the async work in order to return, then you might as well use the current stdlib version.

At the bare minimum you have lost error propagation.

Using unstructured Task do make a difference. If you need to handle unstructured task up in the chain, I’d better used an explicit way to achieve that by returning Task and handling cancellation in the caller explicitly.

@benpious Thanks for rephrasing your post. I don’t think I understood before what you were asking. Still, I want to make a clarification about your new post title:

Is it possible to cancel a Task when its parent is cancelled?

Sorry to be a stickler for terminology, but I think it's important to not talk about parent tasks in this context. Perhaps something like "originating task" would be better?

Swift uses the terms parent task and child task strictly for structured concurrency, where cancellation is in fact automatically propagated from parent to child.

An unstructured task is defined by the fact that it has no parent, i.e. it becomes the root of a new, independent task tree.

4 Likes