- Published on
- 5 min read Intermediate
> Task Cancellation Shields in Swift 6.4
Cancellation in Swift is sticky. Once a task is cancelled, every cancellation check inside it returns true forever. Usually that is exactly what you want, because the whole point of cancellation is to stop doing work as soon as possible. Sometimes it is the wrong thing. When you are trying to flush a buffer to disk or roll back a half-finished database transaction, the last thing you need is your cleanup code bailing out partway through because something further up the call stack pulled the plug.
Swift 6.4 introduces SE-0504: Task Cancellation Shields, a small but very practical tool for exactly this case.
The Old Workaround
Today, the usual way to force cleanup to run inside a cancelled task is to spawn a fresh unstructured task:
func cleanup(connection: DatabaseConnection) async {
await Task {
// This task starts fresh, not cancelled
try? await connection.rollback()
try? await connection.close()
}.value
}
It works, but you are paying for an extra task allocation and a hop through the executor every time. And if the cleanup happens to be synchronous, the trick does not help you at all. Synchronous code has no way to escape the cancelled context.
Enter withTaskCancellationShield
withTaskCancellationShield is the structured version of the same idea. Inside the block, cancellation checks see a fresh, uncancelled task:
print(Task.isCancelled) // true
withTaskCancellationShield {
print(Task.isCancelled) // false
performCleanup()
}
print(Task.isCancelled) // true
There are two overloads: a synchronous one for sync cleanup work, and an async one for cleanup that needs to await something. When the block returns, the task's actual cancelled state is restored. The shield only changes what Task.isCancelled and Task.checkCancellation() report from inside the scope. The task itself is still cancelled, and anything that already exited the call stack stays exited.
Cleanup in Defer Blocks
The most natural place to reach for this is a defer block at the top of a function that owns a resource:
func writeReport(to url: URL) async throws {
let handle = try FileHandle(forWritingTo: url)
defer {
withTaskCancellationShield {
try? handle.synchronize()
try? handle.close()
}
}
while let chunk = try await nextChunk() {
try handle.write(contentsOf: chunk)
}
}
Without the shield, a mid-write cancellation would propagate into synchronize and close. Both of those have internal cancellation checks, and on a cancelled task they could bail out early. You would end up with an open file handle and an unsynced buffer. With the shield, the defer block runs exactly as if cancellation never happened.
The async overload covers cases where the cleanup itself needs to await. A common pattern is wrapping a rollback inside a do/catch:
func processOrder(_ order: Order) async throws {
let txn = try await database.beginTransaction()
do {
try await database.write(order, in: txn)
try await database.commit(txn)
} catch {
await withTaskCancellationShield {
await database.rollback(txn)
}
throw error
}
}
If the task gets cancelled mid-write, control falls through to the catch branch, the shield kicks in, and the rollback runs to completion. The original error then propagates as usual.
What the Shield Does Not Do
The shield is a polite request for code inside it to ignore cancellation. It does not change the task's cancellation state, and it does not stop someone outside the task from observing it. There are two specific gotchas worth knowing about.
First, instance methods on a Task handle keep returning the truth. If you hold a Task reference somewhere else and ask it task.isCancelled, you get the real answer regardless of any shield the task is currently inside. The static methods on Task read the shield, the instance ones do not. This is intentional, and it avoids races where an outside observer would see different state depending on whether the task happened to be in a shielded scope at that exact moment.
Second, child tasks created inside a shield are not automatically cancelled when the outer task gets cancelled. This applies to both async let and task groups:
await withTaskCancellationShield {
async let result = compute() // not auto-cancelled
await withDiscardingTaskGroup { group in
group.addTask { compute() } // not auto-cancelled
group.addTaskUnlessCancelled { compute() } // executes
}
}
This is occasionally useful, but it is also a way to accidentally do a lot of work after the user has already pressed cancel. If you fan out work inside a shield, make sure you actually want all of it to finish.
For debugging, Swift 6.4 also exposes Task.hasActiveTaskCancellationShield, which lets you assert from inside a function that you really are running inside a shielded scope before you do something that depends on it.
When to Reach for It
Use withTaskCancellationShield when you have cleanup work that absolutely must run to completion: closing file handles, releasing locks, flushing a write-ahead log, rolling back a transaction, telling a peer that you are disconnecting cleanly. These are the moments where letting cancellation propagate would leave the system in an invalid state.
Do not use it as a general-purpose "make my code uninterruptible" hammer. The whole point of cooperative cancellation is that long-running work can stop quickly when it is no longer needed. Wrapping a download or a render pass in a shield just means the user waits longer when they ask you to stop. Save it for the small, bounded cleanup steps that actually need the guarantee.
// Continue_Learning
Using @concurrent in Swift 6.2
Swift 6.2 changes how async functions pick a thread by default. The new @concurrent attribute lets you explicitly opt into background execution when you actually need it.
Error Handling in Swift: From Basics to Typed Throws
A practical guide to Swift error handling covering throw/try/catch fundamentals, custom error types, Result, async error patterns, and typed throws introduced in Swift 6.
TaskGroup and Structured Concurrency Patterns in Swift
Master Swift's TaskGroup for parallel async work with proper cancellation, error handling, and result collection. Learn common patterns for batch operations and concurrent pipelines.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.