The term cancellation "shield" was originally coined in the Trio concurrency project, and we think the term is quite suitable and well-fitting to Swift as well.
Though I’m also considering the following:
Which might work out in the end. I’m not too keen on using the word “ignore” though.
mostly out of ignorance on my part – just how untenable is the 'do nothing' approach to this issue? since people are already functionally able to get much (most?) of this behavior today via unstructured Tasks, does that leave performance (and maybe ergonomics/convenience/aesthetics) as the primary motivator? personally i still don't feel i have a good sense of how pressing the need is after reading the proposal. can anyone speak to that a bit more? the language will take on more complexity to support this right – how does that stack up against the status quo?
Personally I would guess that the status quo is that a lot of code doesn't use shields (using an unstructured Task) when it actually should.
At least on macOS/iOS you could write a lot of code without being bitten by cancellation preventing proper cleanup simply because there is a lot of code that predates Swift concurrency.
A lot of code doesn't run in Tasks or if it does, many Tasks are never be cancelled or only in rare circumstances. And if Tasks, most cleanup code simply never checks for cancellation and doesn't (yet) run into issues.
I would assume it's a lot easier to run into this with Swift server code. It's fairly easy to find versions of this construct across open source packages (including workarounds for limitations of the unstructured Task approach, like Task requiring a Sendable result).
Adding this API would hopefully also raise awareness that this is an issue that needs to be addressed in the first place while offering a relatively straightforward solution.
+1 on the pitch. I encounter this frequently with cleanup code as mentioned here. Any well behaved (read: “cancellation respecting”) HTTP library, like Foundation, is a frequent culprit.
I’ve also very recently encountered a bug where task cancellation was used for debouncing some HTTP requests, but the logic didn’t take into account the fact that cancellation might occur while the HTTP request was ongoing, leading to much longer than intended debouncing windows. I worked around the logic bug with the same unstructured task technique mentioned in the proposal.
The unstructured task workaround is neither clear at the point of use, nor optimal for all the reasons mentioned in the proposal. This proposal is much better & does exactly what I’d like it to.
I guess isTaskCancellationShielded is useful as a debugging facility, but to me it just sort of suggests that LLDB should have better task inspection tools.
I think isCancelled(ignoringCancellationShield:) seems like an attractive nuisance - we really, really should not make it easy to query this. I’d like to see an example of what kind of software could actually benefit from this. If we think it’s a tool that’s only for experts, maybe it belongs only on UnsafeCurrentTask?
Finally about the name: I’ve not used trio before, but the name “shield” makes intuitive sense to me. It suggests a physical barrier through which cancellation cannot pass. I think that’s a very helpful metaphor. And IMO having a short name for this that’s googleable is better than a long name which tries to be its own documentation but cannot express the subtleties.
I think “barrier” has the same benefits as “shield” but I think that since we’re talking about a concurrency feature, it’d be nice to stay away from “barrier.” To me it brings to mind a memory barrier/fence where “shield” has no such connotations. If there were no name as good as "shield" I think "barrier" would be an OK choice though.
I think “cancellation suppressed” and “cancellation ignored” and so on all have the same problem: they feel so clearly named that I have an intuition about what they do, but that intuition is subtly wrong. Specifically I think they suggest that cancelling a task within the closure should always be a no-op, which is incorrect in the case of child tasks!
This example from the proposal just seems confusing if we use the alternate wording:
await withDiscardingTaskGroup { group in
// ❌ has no effect on child task observing cancellation:
withSuppressedTaskCancellation {
group.addTask { ... }
}
// 🟢 does properly shield specific child task observing cancellation:
group.addTask {
withSuppressedTaskCancellation { ... }
}
}
So consider me +1 on “cancellation shield” as the name for this.
Here is another aspect that does not make sense to me: I can understand that cancellation of a child task, triggered from within that task, occurs immediately even under a shield. I do not understand why, for the current task that is shielded, explicit cancellation from within the shielded scope gets deferred—this doesn't seem to square with your model of a 'directed' shield that's only 'at the boundary' and not 'within':
So: Holding up a shield protects children from an arrow at the 'boundary'. But it does not protect you from an arrow, only from the 'effects of being hit by an arrow'; you have actually still been hit by the arrow, although only when you step out from 'within' the shield will you realize that you've already been hit. Still, children cannot be hit by the arrow, because the shield has stopped the arrow (but not for you). Sensibly, a shield does not stop anyone from being hit from other arrows 'within' the shield because it only acts at the boundary. Except, if you are shooting yourself with an arrow from 'within' the shield, the shield does stop you from realizing that you've already been hit, until you step out from the shield?
It sounds like you're talking about "you" here as "the task", or perhaps an observer who is somehow moving with the program counter, but that's not my perspective when thinking about the semantics. Rather, "you"—the thing being protected by the shield—is a particular scope within the task. Since withTaskCancellationShield always occurs within a scope bounded by some task, the task itself is inherently the 'exterior'. The task itself may be the recipient of a cancellation operation at any point during its execution, at which points the 'effects' of cancellation propagate inward—but the scope enclosed by the shield is protected from these effects.
From an internal point of view (as in, internal to the shielded scope) there is not much use differentiating between "the effects of being cancelled" and "being cancelled"—indeed, the purpose of this feature is so that the 'interior' cannot differentiate these scenarios (except by isCancelled(ignoringCancellationShield:) which is not intended to be used as a condition for actual behavior changes). This distinction only makes sense from an exterior view where you can talk about the 'real' state of the task in relation to the behavior observed by any shielded scopes within it.
The difficulty there I have with the mental model is, unless I'm mistaken, that the 'structure' of structured concurrency bounds child tasks to their 'inherently exterior' parent tasks and not to such scope. So if child tasks are really-actually-not-canceled rather than shielded-from-the-effects-of-being-canceled, the non-cancellation can extend beyond the scope of the shield without consequence. But, for the current parent task that is 'inherently exterior' in your mental model, this is not the case: there is no proceeding beyond the scope of the shield without the consequence of 'revealing' true cancellation.
(This is, of course, not to say that I would want an alternative behavior with the same pitched API: the point is rather that the notion of a 'shield' is not at all intuitive here and I am having trouble envisioning a teachable synopsis of this behavior.)
Do you have an example of the confusing scenario you imagine? My impression was that child tasks are inherently scope-bound: async lets are canceled and awaited on scope exit, and child tasks in a group are bounded to the execution scope of with*TaskGroup:
A group always waits for all of its child tasks to complete before it returns.
So: Holding up a shield protects children from an arrow at the 'boundary'. But it does not protect you from an arrow, only from the 'effects of being hit by an arrow'; you have actually still been hit by the arrow, although only when you step out from 'within' the shield will you realize that you've already been hit. Still, children cannot be hit by the arrow, because the shield has stopped the arrow (but not for you). Sensibly, a shield does not stop anyone from being hit from other arrows 'within' the shield because it only acts at the boundary. Except, if you are shooting yourself with an arrow from 'within' the shield, the shield does stop you from realizing that you've already been hit, until you step out from the shield?
Essentially yes . You could simplify the metaphor slightly in that the shield temporarily protects you from cancellation no matter the source (external or self) until it is lifted.
Perhaps a sci-fi shield makes more sense for the metaphor than a physical one, e.g. an energy shield bubble that temporarily halts the arrow in place until the shield expires at which point it continues on its path to hit you (although you will have to accept that your arrows will have to traverse the shield as well when shooting yourself). Child-tasks are protected by the shield from arrows shot from outside the shield as well but they always end before the shield expires so are not impacted by the arrow finally hitting. Meanwhile the child tasks can still be hit by arrows shot at them from inside the bubble.
Ah, I'll have to think about that. This does alleviate some of the confusion if it can be so guaranteed. That does explain this bit of a head-scratcher:
It wouldn't change how code within the 'shield' is written. As you (and others) have mentioned, the code only needs to check Task.isCancelled, and you don't really want to write different code based on whether you're within a shield or not. Fully agree with that.
But modeling this with uncancellable tasks would change how code outside the shield behaves in two very desirable ways:
The value of Task.isCancelled, called from within the shield, would always match the value of task.isCancelled called from outside the shield (here task is the handle of the uncancellable task).
Once you exit the scope of this un-cancellable work, the current task wouldn't possibly be cancelled because of any code written within the uncancellable child task — the 'shielding' works both ways.
Now, there's a lot of compelling reasons why uncancellable child tasks wouldn't work (ie the sending/@escaping requirements of task initializers), but it's unfortunate we can't have those semantics. Particularly the first one.
This did make me wonder if, at some point, one's going to want a different kind of shield, that does stop arrows that come from within.
After all, if you're calling some code that internally cancels the current task through withUnsafeCurrentTask { $0?.cancel() }, the only way to 'shield' you is, again, to create an unstructured task and await its value...
Another question for you @ktoso: how does this feature compose with test cancellation? Right now test cancellation cancels the task associated with the current test, or falls back to cancelling the current task in scenarios where it is invoked without a current test.
My gut says we should amend the behaviour of test cancellation so that it always cancels the current task (regardless of whether it was cancelled by cancelling the test's task.) What do you think?
that is an interesting thought, I like that. We very much don’t want people to start using this variant of the api “randomly”, so it would make sense to move off somewhere far away like that.
The child task is not shielded. Shielding is not inherited. You put a shield in a specific place and task, that’s why the difference.
I don’t think this proposal has any impact on how you should be cancelling tests.
It merely allows people who need something to “always run” to indeed always run. Imagine you cancelled a test task and therefore prevented a “cleanup” like closing a file that someone has put inside the test in a defer — you’d want to run that file closing regardless if the test happily succeeded or was cancelled.
I don’t know if it’s mentioned what happens when you nest two of these calls, but maybe it ought to be explicit… even though the behavior should be pretty easy to guess.
assert(Task.isCancelled) // 🛑
withTaskCancellationShield {
assert(Task.isCancelled == false) // 🟢
withTaskCancellationShield {
// nested call has no effect since there's already a shield in place
assert(Task.isCancelled == false) // 🟢
}
// the shield is still in place after the nested call
assert(Task.isCancelled == false) // 🟢
}
assert(Task.isCancelled) // 🛑
Of course this will mostly happen in less obvious ways, when calling other functions.
i have another edge case behavioral question. note, this is odd and probably a rather 'unrealistic' scenario, but IMO the corner cases help illuminate the semantics.
if a parent Task is nested within both a shielded block and a child task and then used within the child, does that 'see through' the shield? e.g.
@MainActor
func child_referencing_parent() async {
var parent: Task<Void, Never>!
let t = Task { @MainActor in
let parent: Task<Void, Never> = parent
Task.isCancelled // false
parent.isCancelled // false
parent.cancel()
Task.isCancelled // true
parent.isCancelled // true
await withTaskCancellationShield {
Task.isCancelled // false
parent.isCancelled // false
async let child: Void = {
Task.isCancelled // false – now refers to the child task, which is not cancelled
parent.isCancelled // ??
// ^ is this true b/c the query is in the child task that has no shield 'up'
// or false b/c we're still 'scoped' under the outer shield?
}()
await child
}
}
parent = t
await parent.value
}
i was assuming it'd have to be false when queried under the shield for consistency with how the instance-method-based query on the 'equivalent' UnsafeCurrentTask would behave[1]. but, i just double checked, and if i'm not mistaken, that may also be unspecified in the current proposal text (or i've overlooked it). agree that both cases are probably worth clarifying.
i thought Task.isCancelled was basically just sugar for withUnsafeCurrentTask { $0?.isCancelled ?? false }↩︎
I'll use Task.isCancelled to refer to the static property, task.isCancelled for the instance property.
Unless I'm mistaken, the intention is for task.isCancelled to always be true once the task has been cancelled.
I think that's the only reasonable behavior, the instance property should always reflect if the task was cancelled or not and not depend on cancellation shields at all.
We always need to be able to test if a specific task was irrevocably cancelled, which we can do with task.isCancelled.
We always need to be able to determine if we should take the non-cancelled or cancelled execution paths in the current task which Task.isCancelled tells us.
These are different properties with different purposes and mixing their behavior would only make things more confusing in my opinion.
I think the core issue is that these are, while related, very different properties but as they share the same name, it's easy to expect them to behave the same even though they do not and are not interchangeable.
Without cancellation shields, you could use Task.isCancelled and currentTask.isCancelled interchangeably (but probably never should have) but that would no longer be the case with cancellation shields in the mix.
I don't think this is a huge issue in practice, I don't expect there is much use of withUnsafeCurrentTask to get the current task or passing the task handle into the task via a separate reference just to use task.isCancelled when you could just have used Task.isCancelled in the first place.
But it's something that needs to be very clear in the documentation if this proposal is accepted to state very explicitly that these properties are not interchangeable.
In retrospect, these properties should perhaps have had different names to make them more distinctive and avoid this source of confusion.
Renaming and deprecating one of them is perhaps an option but I'm not sure if going that far is warranted, I still think it's unlikely to be misused in practice.
Why `task.isCancelled` taking cancellation shields into account is IMO not a good option
Changing task.isCancelled to return the same cancellation-shield aware value as Task.isCancelled would IMHO be a bad idea for the following reasons:
We would still need to add a new way to check if the task has been cancelled (ignoring cancellation shields).
Within a task task.isCancelled would return the same value as Task.isCancelled, while nicely symmetric it would ultimately be redundant.
task.isCancelled would be mostly meaningless outside of the task as we would be observing how the task moves in and out of cancellation shields. I don't really see a use for that capability in the first place and it would not really be useful as it would be stale immediately anyway.
Alternatively, task.isCancelled could return Task.isCancelled when used for the current task but the cancellation status ignoring cancellation shields otherwise. But now the instance property becomes context sensitive which is IMO just as confusing.