1

I'd like to know if I need to use EnumeratorCancellation when passing a cancellation token to my local function. I am thinking of using this code pattern often in the future:

public static IAsyncEnumerable<string> MyOuterFunctionAsync(
this Client client, 
CancellationToken cancellationToken,
int? limit = null) 
{
    return MyLocalFunction().
        TakeWhile(
            (_, index) => 
                limit is null ||
                index < limit.Value);
 
    async IAsyncEnumerable<string> MyLocalFunction()
    {
       var request = CreateRequest();

       do 
       {
            var page = await request.GetAsync(cancellationToken);
            foreach (var item in page) 
            {
                yield return item;
            }
            request = GetNextPageRequest();
       }
       while (request is not null)
    }
}

Resharper doesn't mention the need for EnumeratorCancellation, and when I try to add it to the outer function it says it will have no effect, but if I try adding it to the local function Resharper stays happy, as without. Should I use it anywhere? I checked the IL viewer but I don't see any difference between the versions.

Will MyOuterFunctionAsync work properly? Do I need to change MyLocalFunction signature to

async IAsyncEnumerable<string> MyLocalFunction(
    [EnumeratorCancellation] CancellationToken cancellationToken)

1 Answer 1

1

To answer directly your question, the MyOuterFunctionAsync will recognize correctly a CancellationToken passed as argument, but not if the CancellationToken is passed with the WithCancellation operator. For example if:

var sequence = client.MyOuterFunctionAsync(CancellationToken.None)
    .WithCancellation(token);

...the token will be ignored. For correct behavior you do have to add the EnumeratorCancellation attribute in the MyLocalFunction.

You could consider adopting the pattern used in the System.Linq.Async library. For example the Where LINQ operator:

public static IAsyncEnumerable<TSource> Where<TSource>(
    this IAsyncEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    if (source == null)
        throw Error.ArgumentNull(nameof(source));
    if (predicate == null)
        throw Error.ArgumentNull(nameof(predicate));

    return Core(source, predicate);

    static async IAsyncEnumerable<TSource> Core(IAsyncEnumerable<TSource> source,
        Func<TSource, bool> predicate,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await foreach (var element in source.WithCancellation(cancellationToken)
            .ConfigureAwait(false))
        {
            if (predicate(element))
            {
                yield return element;
            }
        }
    }
}

As you see the outer Where is not async, and the inner Core is async. The public signature of the Where doesn't include a CancellationToken parameter. The caller can always use the WithCancellation operator to attach a CancellationToken to an asynchronous sequence, so including a parameter is redundant. For example:

var query = sequence.Where(x => x.IsAvailable).WithCancellation(token);

On the other hand the local Core implementation of the operator does include a CancellationToken parameter, which is also decorated with the EnumeratorCancellation attribute. When the caller uses the Where and attaches a token with the WithCancellation, this token is automatically passed to the Core implementation because of the EnumeratorCancellation attribute.

So the general rule is: A method that returns an IAsyncEnumerable<T> should include an CancellationToken parameter only if it's implemented with async, in which case the parameter should also be decorated with the EnumeratorCancellation attribute.

Ideally public APIs that return IAsyncEnumerable<T> should not be implemented with async. That's because giving to the caller two different options to pass a token, either directly or through the WithCancellation, creates confusion without adding any value to the API. As an example of what not to do, see the implementation of the ChannelReader<T>.ReadAllAsync API.

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for the thorough response! While this is highly enlightening, and I'll definitely use the approach you provided in my code, it does not answer my question. The code I posted is used in a very specific environment and complies with the specific company's guidelines. Will the snippet I posted work, or do I need to add a CancellationToken to the local function with EnumeratorCancellation attribute?
I just found this Roslyn issue response by jcouv. It looks like I should add a CancellationToken parameter with [EnumeratorCancellation] attribute to the local function, but the reason is left unexpained
@Zapdor ah, yes, sorry, I was carried away. I edited my answer and added a direct answer to your question at the bottom.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.