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
- Microsoft Learn - EnumeratorCancellationAttribute Class
- Microsoft Learn - Iterators - C#
- Microsoft Learn - Generate and consume async streams
- dotnet/csharplang - Async Streams proposal
- S8949 - The overload accepting a 'CancellationToken' should be used