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)?
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.