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 TaskCanceledException
s (when the token is already canceled), and other times will propagate OperationCanceledException
s (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.
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
}