S9426 — Available cancellation tokens should be passed to "await foreach" via "WithCancellation"

Language
C#
Type
Bug
Severity
Major
Tags
async, concurrency

Why is this an issue?

Enumerating an IAsyncEnumerable<T> with await foreach does not automatically propagate a CancellationToken that is available in the enclosing scope. Async-iterator methods only receive a forwarded cancellation token through the compiler-generated plumbing triggered by IAsyncEnumerable<T>.GetAsyncEnumerator(CancellationToken), which WithCancellation(token) calls under the hood.

Without an explicit WithCancellation(token) at the consumption site, that plumbing never runs, and the enumeration cannot be cancelled even if the producer method correctly supports cancellation: it continues running after cancellation is requested, wasting resources.

This is silent: no compiler warning is raised for this case. The compiler only warns (CS8425) about the opposite, producer-side mistake of declaring a CancellationToken parameter on an async-iterator method without decorating it with [EnumeratorCancellation] — it does not inspect await foreach call sites.

Exceptions

The rule does not flag an await foreach loop that already calls .WithCancellation(…​) — except when the argument is a bare default, which is not treated as a deliberate cancellation opt-out (unlike an explicitly passed CancellationToken.None).

The rule also does not flag a call that already passes some CancellationToken directly into a parameter of the producer method decorated with [EnumeratorCancellation], e.g. GetItemsAsync(token) where GetItemsAsync is declared as IAsyncEnumerable<T> GetItemsAsync([EnumeratorCancellation] CancellationToken token = default). In that case, the compiler-generated GetAsyncEnumerator(CancellationToken) override keeps the already-supplied value when called with the default token, so the enumeration is already cancellable without WithCancellation.

Both exceptions above still apply when the enumerated expression is further wrapped in a trailing .ConfigureAwait(false) (a common idiom in library code, to avoid capturing the synchronization context on every await in the loop), in either order, e.g. GetItemsAsync().WithCancellation(token).ConfigureAwait(false) or GetItemsAsync().ConfigureAwait(false).WithCancellation(token) are both treated as compliant, as is GetItemsAsync(token).ConfigureAwait(false) when the token parameter of GetItemsAsync is decorated with [EnumeratorCancellation].

How to fix it

Pass the accessible CancellationToken to the enumeration via WithCancellation.

Code examples

Noncompliant code example


public async Task ProcessAsync(CancellationToken token = default)
{
    await foreach (var item in GetItemsAsync()) // Noncompliant - pass 'token' to allow cancellation
    {
        Process(item);
    }
}

Compliant solution


public async Task ProcessAsync(CancellationToken token = default)
{
    await foreach (var item in GetItemsAsync().WithCancellation(token))
    {
        Process(item);
    }
}

await foreach (var item in GetItemsAsync().WithCancellation(default)) // Noncompliant - not a deliberate opt-out

await foreach (var item in GetItemsAsync().WithCancellation(CancellationToken.None)) // Compliant - deliberate opt-out

Resources

Documentation

↑ Back to top