- Published on
withTaskCancellationShield: Swift 6.4 new feature
- Authors

- Name
- Omar Elsayed
Introduction
A couple of days ago, Swift 6.4 was released, and honestly, I love it. It's mostly a collection of small enhancements, but they're the kind we've been needing for a while: improvements to modern concurrency, cleaner syntax, a better debugging experience, a smoother migration path to Swift Testing, and much more.
On the debugging side, LLDB now imports modules through precise dependency tracking instead of by-name lookups, and debug builds on Linux and Windows along with dSYM bundles on Darwin are significantly smaller because binary Swift modules are no longer embedded in them.
As for Swift Testing, you can now safely use XCTAssert inside Swift Testing tests or #expect inside XCTests, which makes incremental migration much easier, and swift test can now repeat test cases so you can focus on flaky ones.
But two features really caught my attention.
The first is the ability to call async methods inside a defer block, something I've personally been waiting on for a long time.
The second is withTaskCancellationShield, which lets you run a block of code to completion even if the surrounding task has been cancelled.
There's just one catch: it's only available on the 27 OS releases (iOS 27, macOS 27, and friends). That got me asking two questions: why is it limited to the newest OSes, and how can we mimic its behavior on earlier versions?
Acknowledgement: Most the code examples are taken from swift propoasel.
withTaskCancellationShield
Let's start with the first question:
Why is it limited to the newest OSes?
To answer that, we first need to understand what withTaskCancellationShield actually does and how it works, there's no better place to start than the Swift Evolution proposal for task cancellation shields (SE-0504).
How it works
Simply withTaskCancellationShield prevents observing task's cancellation status from the code running inside of it, that means the code inside the shield keeps running normally, even if the surrounding task has already been cancelled.
The API comes in two flavors, synchronous one and asynchronous one:
public func withTaskCancellationShield<Value, Failure>(
_ operation: () throws(Failure) -> Value
) throws(Failure) -> Value
public nonisolated(nonsending) func withTaskCancellationShield<Value, Failure>(
_ operation: nonisolated(nonsending) () async throws(Failure) -> Value
) async throws(Failure) -> Value
One thing needs to be crystal clear: withTaskCancellationShield does not prevent a task from being cancelled.
The task is still cancelled; the code inside the shield just can't see it, as soon as you step outside the shield the cancellation is visible again:
print(Task.isCancelled) // true
withTaskCancellationShield {
print(Task.isCancelled) // false
}
print(Task.isCancelled) // true
The shield goes beyond the code directly inside it, if you create child tasks within the shield whether through async let or a task group, the cancellation won't propagate to them either:
Task {
withUnsafeCurrentTask { $0?.cancel() } // immediately cancel the Task
// Without a shield:
async let a = compute() // π async let child task is immediately cancelled
await withDiscardingTaskGroup { group in // π task group is immediately cancelled
group.addTask { compute() } // π child task is immediately cancelled
group.addTaskUnlessCancelled { compute() } // π child task is not started at all
}
// With a shield:
await withTaskCancellationShield {
async let a = compute() // π’ async let child task is NOT cancelled
await withDiscardingTaskGroup { group in // π’ not cancelled
group.addTask { compute() } // π’ not cancelled
group.addTaskUnlessCancelled { compute() } // π’ not cancelled
}
}
}
So why only the 27 OSes?
Now that we know how it works, the answer starts to make sense, a task's cancellation status is managed by the Swift concurrency runtime and on Apple platforms that runtime ships as part of the operating system itself.
withTaskCancellationShield isn't just a new function you can drop into your code; it changes how the runtime reports cancellation to the code inside the shield and to any child tasks created there.
And since older OS versions ship an older runtime without that behavior, the feature can't be back-deployed.
To confirm this hypothesis, I asked on the Swift Forums, and the answer confirmed it: it relies on new runtime support.
That brings us to the more interesting question: how can we get the same behavior on earlier OS versions? π€
withTaskCancellationShield on earlier OS versions
Now we need a way to make a block of code keep executing even after its task has been cancelled π€
... Thinking (fitting for the AI era we're living in π )
Got it!
We create a new Task and await its value, that gives us behavior very close to withTaskCancellationShield.
You might be surprised the solution is that simple, you were probably expecting something fancy but sometimes the simplest tool is the right one.
Let's look at why it works.
func processFile(at url: URL) async throws {
let handle = try FileHandle(forReadingFrom: url)
defer {
await Task {
await flushMetrics(for: url)
try? handle.close()
}.value
}
try await processContents(of: handle)
}
When we create a Task inside the defer block, we're creating an unstructured task.
Unlike async let or task group children an unstructured task isn't a child of the current task. It starts its own, separate task tree. Cancellation in Swift propagates down a task tree from parent to children.
Since our new task isn't part of the tree that belongs to processFile, cancelling processFile never reaches it. Inside the new task, Task.isCancelled is false, so flushMetrics(for:) and handle.close() run to completion.
β cancel()
β
βΌ
ββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ
β Task tree 1: processFile β β Task tree 2: Task in defer β
β (structured) β β (unstructured) β
β β β β
β ββββββββββββββββββββββββββββ β β ββββββββββββββββββββββββββββ β
β β processFile task β β β β flushMetrics(for:) β β
β β π cancelled βββββΌβ β β β βΌβββΆβ handle.close() β β
β ββββββββββββββ¬ββββββββββββββ β awaits β β π’ not cancelled β β
β β β .value β ββββββββββββββββββββββββββββ β
β βΌ β β β
β ββββββββββββββββββββββββββββ β β Cancellation never crosses β
β β processContents(of:) β β β into this tree, so the β
β β π cancellation β β β cleanup runs to completion. β
β β propagates down β β β β
β ββββββββββββββββββββββββββββ β β β
ββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ
Why do we await the task's value?
You might be wondering why we don't just fire off the Task and move on.
Without the await, the cleanup becomes fire-and-forget, processFile would return immediately while the flushing and closing happen at some unknown point later.
That means the caller can't rely on the file handle being closed or the metrics being flushed when the function returns, if you call processFile several times in a row, the cleanups from different calls could also finish in any order.
By awaiting .value, we guarantee the cleanup is finished before processFile returns, which is exactly what you want from a defer block.
Full discussion about that is right here.
Conclusion
Swift 6.4 might look like a release full of small enhancements, but features like async calls in defer and withTaskCancellationShield solve real problems we've been working around for years, cleanup code that needs to finish even when a task gets cancelled is now something the language supports directly.
We saw that withTaskCancellationShield is limited to the 27 OSes because it isn't just a new function; it relies on new behavior in the Swift concurrency runtime which ships with the operating system, that's why it can't simply be back-deployed.
But that doesn't mean we're stuck, by wrapping our code in an unstructured Task and awaiting its value we step outside the cancelled task tree and get behavior very close to the real shield. And with a small Result trick, we can even keep typed throws working.
Still, it's a workaround, not a replacement. It runs your code on a different task, adds a small cost, and requires your captured values to be Sendable.
So once you can raise your minimum deployment target to the 27 OSes, switch to the real withTaskCancellationShield.
I hope this article helped you understand not just how to mimic withTaskCancellationShield, but why it works the way it does.
If you have a different approach, or you spot something I missed, I'd love to hear from you. Happy coding! π