withTaskCancellationShield in Swift 6.4


Greetings, traveler!

Swift cancellation is cooperative. A task can be cancelled, but Swift does not stop it by force. Your code has to observe cancellation explicitly:

Task.isCancelled
try Task.checkCancellation()

Most of the time, this is exactly what you want. If the user leaves the screen, the network request can stop. If a parent task is cancelled, child tasks usually should stop too.

Cleanup code is different. Imagine a task that opens a resource, performs some work, and then needs to close that resource. The cleanup should happen even if the task was cancelled before the function reached the end.

Swift 6.4 adds withTaskCancellationShield(operation:) for this exact case.

What withTaskCancellationShield does

withTaskCancellationShield creates a scope where the current task cannot observe cancellation.

Inside the shield:

Task.isCancelled // false
try Task.checkCancellation() // does not throw

After the shield exits, the real cancellation state becomes visible again.

The shield does not uncancel the task. It only hides cancellation from code running inside the shielded scope.

let task = Task {
    print(Task.isCancelled)

    await withTaskCancellationShield {
        print(Task.isCancelled)
    }

    print(Task.isCancelled)
}

task.cancel()

If the task was cancelled before it entered the shield, the middle print behaves as if cancellation is not active. Once the shield ends, the task can observe cancellation again.

A cleanup example

This is the kind of code where the new API makes sense:

func processUpload() async throws {
    let session = try await UploadSession.start()

    defer {
        await withTaskCancellationShield {
            await session.finish()
        }
    }

    try await session.uploadChunks()
}

Without the shield, finish() might return early if it checks Task.isCancelled internally:

struct UploadSession {
    func finish() async {
        guard !Task.isCancelled else {
            return
        }

        await closeConnection()
        await removeTemporaryFiles()
    }
}

That is a problem. The task was cancelled, but the resource still needs to be closed.

With withTaskCancellationShield, finish() can run without being interrupted by cancellation checks based on the current task context.

Why this works well with async defer

Swift 6.4 also supports await inside defer. That makes cancellation shields much more useful in everyday code.

Before this, async cleanup often required awkward control flow. You had to move cleanup to a separate path, duplicate it in several branches, or start an unstructured task from defer.

Now the code can stay local:

func writeCacheFile(_ data: Data) async throws {
    let file = try await TemporaryFile.open()

    defer {
        await withTaskCancellationShield {
            try? await file.close()
        }
    }

    try await file.write(data)
}

The intent is clear: writing can be cancelled, but closing the file should still happen.

It is not a general ignore cancellation button

The dangerous use case looks like this:

await withTaskCancellationShield {
    try await loadFeed()
    try await decodeImages()
    try await updateDatabase()
}

That is usually the wrong shape.

If the user left the screen, loading and decoding the feed probably should stop. Wrapping the whole operation in a cancellation shield keeps work alive after it may no longer be needed.

A better rule is simple: shield cleanup, not business logic.

Good candidates:

await withTaskCancellationShield {
    await transaction.rollback()
}
await withTaskCancellationShield {
    await resource.release()
}
await withTaskCancellationShield {
    await temporaryDirectory.remove()
}

Bad candidates:

await withTaskCancellationShield {
    await viewModel.reloadEverything()
}

Static Task.isCancelled and task.isCancelled are not the same

Inside a task, this is contextual:

Task.isCancelled

It respects the active cancellation shield. But this checks the actual cancellation state of a task handle:

task.isCancelled

That means a cancelled task can report different values depending on where you check from:

let task = Task {
    await withTaskCancellationShield {
        print(Task.isCancelled) // false
    }
}

task.cancel()

print(task.isCancelled) // true

This is intentional. Code inside the shield gets protected cancellation checks. Code holding a task handle still sees the real task state.

Child tasks inside a shield

A cancellation shield also affects structured child tasks created inside the shielded scope.

If the parent task is already cancelled, a child task created inside the shield does not automatically start as cancelled:

await withTaskCancellationShield {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            print(Task.isCancelled) // false
        }
    }
}

Explicit cancellation still works:

await withTaskCancellationShield {
    await withTaskGroup(of: Void.self) { group in
        group.cancelAll()

        group.addTask {
            print(Task.isCancelled) // true
        }
    }
}

The shield protects against incoming cancellation from the outer context. It does not make child tasks impossible to cancel.

Shield the work, not the addTask call

This is another small trap.

Shielding addTask itself does not shield the body of the child task:

await withTaskGroup(of: Void.self) { group in
    group.cancelAll()

    withTaskCancellationShield {
        group.addTask {
            print(Task.isCancelled) // true
        }
    }
}

The shield is active while addTask is called, but the child task runs later, outside that scope.

If the child task itself needs a shield, put the shield inside the child task:

await withTaskGroup(of: Void.self) { group in
    group.cancelAll()

    group.addTask {
        withTaskCancellationShield {
            print(Task.isCancelled) // false
        }
    }
}

That version protects the code that actually runs in the child task.

Checking whether a shield is active

Swift 6.4 also adds a way to ask whether the current task is running inside a cancellation shield.

There are two related APIs:

Task.hasActiveCancellationShield

and the lower-level version:

withUnsafeCurrentTask { task in
    task?.hasActiveCancellationShield
}

The important part is that this API is mainly for debugging and understanding cancellation behavior in complex call hierarchies. The Swift stdlib documentation explicitly says it should not be used for regular control flow.

A simple example:

func printCancellationState(_ label: String) {
    print(label)
    print("isCancelled:", Task.isCancelled)
    print("has shield:", Task.hasActiveCancellationShield)
}

let task = Task {
    printCancellationState("Before shield")

    await withTaskCancellationShield {
        printCancellationState("Inside shield")
    }

    printCancellationState("After shield")
}

task.cancel()

Inside the shield, Task.isCancelled returns false, while Task.hasActiveCancellationShield returns true.

The UnsafeCurrentTask variant is useful when you are already working with withUnsafeCurrentTask, but I would avoid it in normal app code:

withUnsafeCurrentTask { task in
    print(task?.hasActiveCancellationShield ?? false)
}

UnsafeCurrentTask references are only valid inside the closure passed to withUnsafeCurrentTask, storing them for later is unsafe and has undefined behavior.

For most code, prefer the safer spelling:

Task.hasActiveCancellationShield

Use it to debug. Do not write app logic that depends on it.

How to use withTaskCancellationShield in iOS code

In an iOS app, you can reach for withTaskCancellationShield around cleanup that must happen after a cancelled operation.

For example, a screen starts an export task. The user closes the screen. The export can stop, but the temporary files still need to be removed.

final class ExportService {
    func exportVideo() async throws -> URL {
        let workspace = try await ExportWorkspace.create()

        defer {
            await withTaskCancellationShield {
                try? await workspace.removeTemporaryFiles()
            }
        }

        try Task.checkCancellation()

        let renderedURL = try await renderVideo(in: workspace)

        try Task.checkCancellation()

        return renderedURL
    }
}

The render operation respects cancellation. The cleanup does not get skipped just because the render was cancelled.

Final thought

withTaskCancellationShield is a small API, but it fixes a real edge case in Swift Concurrency.

Cancellation should usually stop work. Cleanup is the exception. When a task has already opened a resource, created temporary files, started a transaction, or acquired some kind of lease, cancellation should not prevent the app from putting things back in a safe state.

Swift 6.4 gives us a direct way to express that:

await withTaskCancellationShield {
    await cleanup()
}