BS
BleepingSwift
Published on
5 min read
Intermediate

> Task Cancellation Shields in Swift 6.4

Share:

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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.