Why doesn't `await task.value` set up a cancellation handler?

Does anyone know why awaiting an unstructured task doesn't automatically bring it back into the "structured" world by setting up a cancellation handler between the current async context and the task? Was this an explicit design choice? Are there common cases where it makes sense to keep cancellation separate?

I find myself writing the following code quite often, usually after I discover a bug around cancellation:

let task = Task { โ€ฆ }
await withTaskCancellationHandler {
  task.cancel()
} operation: {
  await task.value
}

I find that I want this functionality more often than not, but maybe I'm missing something :smile:

1 Like

I'd argue that this falls out of the fact that these things (await, value, withTaskCancellationHandler, ...) are building blocks and what you did here is a composition of them. If a plain value await did all that for you, how would you not participate in such cancellation.

It should be possible to offer a cancellableValue maybe that does the dance you mention here if it is a very common pattern you're hitting.

Yup, I've defined this helper :smile:

I figure the inverse functionality of withTaskCancellationHandler could be defined. Something like:

await withoutHandlingCancellation { await task.value }

// or maybe simply:

await task.valueIgnoringCancellation

That is, if it is more common to want cancellation to propagate, as it has been in my experience.

I'm not sure about that...

Unstructured tasks lose the "tree" of tasks. So it no longer is about "parent is cancelled -> child tasks get cancelled" but suddenly "ANY awaiter is cancelled -> task gets cancelled", which is a completely different thing.

There definitely are cases where that's what you want, but is it always and a good default? I'm, personally, not convinced of that.

Unstructured tasks are also often used to "hold" a bunch of tasks until that other one has finished... Say, connecting to DB while 3 requests came in and all those requests are waiting on it. I would absolutely NOT want to cancel my DB establishing just because a request timeout triggered. I think it'd be very hard to reason about what gets cancelled and "where from" if any await task.value propagated cancellation from the side.

At the very least one would want an explicit API calling out this unusual cancellation propagation behavior.

7 Likes

That makes sense. I wasn't totally convinced, myself, but I've forgotten to set up this kind of cancellation handler enough that I was curious if there were some discussion around it.

It'd be nice to have a more ergonomic, discoverable interface around it, like task.cancellableValue.

I'll continue to define it in my projects for now :slight_smile:

2 Likes

Worth shooting a ticket on GitHub - apple/swift: The Swift Programming Language I suppose, if many people hit the same use-case it might be a good spot signal gathering and maybe offering this built-in sometime :+1:

1 Like

Done, thanks! Add `Task.cancellableValue` ยท Issue #59189 ยท apple/swift ยท GitHub

1 Like

I think our goal here as a language would to figure out ways you could use a structured task in the first place.

2 Likes

Do I understand correctly that you are ~"claiming ownership" of the unstructured task? Or would that be a reasonable way to phrase it?

If await task.value set up a cancellation handler, it's expressing: "If I'm not around to receive this result, don't even bother calculating it". Which is essentially what structured tasks do already, isn't it?

So if you're doing this with an unstructured Task, it means you have a Task that is already running, and is physically capable of outliving the current frame - perhaps it didn't even start in this frame, and somebody passed it in as an argument. And now you want to assert ownership over it.

If so, I agree with @ktoso. It's an interesting operation but I don't think it should be the default (perhaps if Tasks were move-only, there would be a stronger case for it). If we did add it, I would suggest giving it a name that implies "taking ownership of" or otherwise "claiming" the Task.

Another thing to consider is that the implicit cancellation behaviour of async let and friends is very subtle already. Extending that behaviour should be done carefully, IMO.

I agree that steering folks toward structured tasks is a good goal.

Sometimes they're not possible to be used nowadays; I like the move ideas though -- if we had such moves and a consuming alternative to value perhaps that'd be how we could model that :thinking: Interesting idea.

1 Like

After auditing many of my uses of the pattern, they all seem to involve bridging code that needs to hand off cancellation to an additional context, or code that requires separating the observation of synchronous effects from asynchronous side effects. For the former, I assume the problem goes away once the legacy systems can shed off its legacy requirements. For the latter, I hope some kind of executor story will allow for a reliable way of hooking into suspensions to test the full lifecycle of async work.

6 Likes

Along the lines of move-only types, I've taken to doing something like this to address this problem:

public final class Cancellable<Output: Sendable>: Sendable {
    private let _cancel: @Sendable () -> Void
    private let _isCancelled: @Sendable () -> Bool
    private let _value: @Sendable () async throws -> Output
    private let _result: @Sendable () async -> Result<Output, Swift.Error>

    public var isCancelled: Bool {  _isCancelled() }
    public var value: Output {  get async throws { try await _value() } }
    public var result: Result<Output, Swift.Error> {  get async { await _result() } }

    @Sendable public func cancel() -> Void { _cancel() }
    @Sendable public func cancelAndAwaitValue() async throws -> Output {
        _cancel(); return try await _value()
    }
    @Sendable public func cancelAndAwaitResult() async throws -> Result<Output, Swift.Error> {
        _cancel(); return await _result()
    }

    init(
        cancel: @escaping @Sendable () -> Void,
        isCancelled: @escaping @Sendable () -> Bool,
        value: @escaping @Sendable () async throws -> Output,
        result: @escaping @Sendable () async -> Result<Output, Swift.Error>
    ) {
        _cancel = cancel
        _isCancelled = isCancelled
        _value = value
        _result = result
    }

    deinit { if !self.isCancelled { self.cancel() } }
}

public extension Cancellable {
    convenience init(task: Task<Output, Swift.Error>) {
        self.init(
            cancel: { task.cancel() },
            isCancelled: { task.isCancelled },
            value: { try await task.value },
            result: { await task.result }
        )
    }
}

As long as a child cancellable does not escape its parent's closure, cancelling or losing the reference to the parent's Cancellable will result in cancellation of the parent and all descendants. The rationale here is that I was spinning up unstructured Tasks and forgetting to cancel them at times and AFAICT there is no good way to debug this. Basically I stole the idea from Combine.