Search code examples
c#async-awaittask-parallel-librarycancellation-tokenvaluetask

How to return a canceled ValueTask<T> that propagates an OperationCanceledException, without async/await?


I am writting an API that has a ValueTask<T> return type, and accepts a CancellationToken. In case the CancellationToken is already canceled upon invoking the method, I would like to return a canceled ValueTask<T> (IsCanceled == true), that propagates an OperationCanceledException when awaited. Doing it with an async method is trivial:

async ValueTask<int> MyMethod1(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    //...
    return 13;
}

ValueTask<int> task = MyMethod1(new CancellationToken(true));
Console.WriteLine($"IsCanceled: {task.IsCanceled}"); // True
await task; // throws OperationCanceledException

I decided to switch to a non-async implementation, and now I have trouble reproducing the same behavior. Wrapping a Task.FromCanceled results correctly to a canceled ValueTask<T>, but the type of the exception is TaskCanceledException, which is not desirable:

ValueTask<int> MyMethod2(CancellationToken token)
{
    if (token.IsCancellationRequested)
        return new ValueTask<int>(Task.FromCanceled<int>(token));
    //...
    return new ValueTask<int>(13);
}

ValueTask<int> task = MyMethod2(new CancellationToken(true));
Console.WriteLine($"IsCanceled: {task.IsCanceled}"); // True
await task; // throws TaskCanceledException (undesirable)

Another unsuccessful attempt is to wrap a Task.FromException. This one propagates the correct exception type, but the task is faulted instead of canceled:

ValueTask<int> MyMethod3(CancellationToken token)
{
    if (token.IsCancellationRequested)
        return new ValueTask<int>(
            Task.FromException<int>(new OperationCanceledException(token)));
    //...
    return new ValueTask<int>(13);
}

ValueTask<int> task = MyMethod3(new CancellationToken(true));
Console.WriteLine($"IsCanceled: {task.IsCanceled}"); // False (undesirable)
await task; // throws OperationCanceledException

Is there any solution to this problem, or I should accept that my API will behave inconsistently, and sometimes will propagate TaskCanceledExceptions (when the token is already canceled), and other times will propagate OperationCanceledExceptions (when the token is canceled later)?

Try it on Fiddle.


Update: As a practical example of the inconsistency I am trying to avoid, here is one from the built-in Channel<T> class:

Channel<int> channel = Channel.CreateUnbounded<int>();

ValueTask<int> task1 = channel.Reader.ReadAsync(new CancellationToken(true));
await task1; // throws TaskCanceledException

ValueTask<int> task2 = channel.Reader.ReadAsync(new CancellationTokenSource(100).Token);
await task2; // throws OperationCanceledException

The first ValueTask<int> throws a TaskCanceledException, because the token is already canceled. The second ValueTask<int> throws an OperationCanceledException, because the token is canceled 100 msec later.

Try it on Fiddle.


Solution

  • The best approach here is probably to simply have an async helper method you can defer to, here; i.e.:

    ValueTask<int> MyMethod3(CancellationToken token)
    {
        if (token.IsCancellationRequested) return Cancelled(token);
        // ... the rest of your non-async code here
    
        static async ValueTask<int> Cancelled(CancellationToken token)
        {
            token.ThrowIfCancellationRequested();
            // some dummy await, or just suppress the compiler warning about no await
            await Task.Yield(); // should never be reached
            return 0; // should never be reached
        }
    }
    

    There is a third option that might work, but it is a lot more complicated and doesn't avoid allocation (IValueTaskSource<TResult> - noting that you'd still need somewhere to stash the relevant token)

    Sneakier version:


    #pragma warning disable CS1998
    static async ValueTask<int> Cancelled(CancellationToken token)
    #pragma warning restore CS1998
    {
        token.ThrowIfCancellationRequested();
        return 0; // should never be reached
    }