I've found that I can't distinguish controlled/cooperative from "uncontrolled" cancellation of Tasks/delegates without checking the source behind the specific Task or delegate.
Specifically, I've always assumed that when catching an OperationCanceledException
thrown from a "lower level operation" that if the referenced token cannot be matched to the token for the current operation, then it should be interpreted as a failure/error. This is a statement from the "lower level operation" that it gave up (quit), but not because you asked it to do so.
Unfortunately, TaskCompletionSource
cannot associate a CancellationToken
as the reason for cancellation. So any Task not backed by the built in schedulers cannot communicate the reason for its cancellation and could misreport cooperative cancellation as an error.
UPDATE: As of .NET 4.6 TaskCompletionSource can associate a CancellationToken
if the new overloads for SetCanceled
or TrySetCanceled
are used.
For instance the following
public Task ShouldHaveBeenAsynchronous(Action userDelegate, CancellationToken ct)
{
var tcs = new TaskCompletionSource<object>();
try
{
userDelegate();
tcs.SetResult(null); // Indicate completion
}
catch (OperationCanceledException ex)
{
if (ex.CancellationToken == ct)
tcs.SetCanceled(); // Need to pass ct here, but can't
else
tcs.SetException(ex);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
return tcs.Task;
}
private void OtherSide()
{
var cts = new CancellationTokenSource();
var ct = cts.Token;
cts.Cancel();
Task wrappedOperation = ShouldHaveBeenAsynchronous(
() => { ct.ThrowIfCancellationRequested(); }, ct);
try
{
wrappedOperation.Wait();
}
catch (AggregateException aex)
{
foreach (var ex in aex.InnerExceptions
.OfType<OperationCanceledException>())
{
if (ex.CancellationToken == ct)
Console.WriteLine("OK: Normal Cancellation");
else
Console.WriteLine("ERROR: Unexpected cancellation");
}
}
}
will result in "ERROR: Unexpected cancellation" even though the cancellation was requested through a cancellation token distributed to all the components.
The core problem is that the TaskCompletionSource does not know about the CancellationToken, but if THE "go to" mechanism for wrapping asynchronous operations in Tasks can't track this then I don't think one can count on it ever being tracked across interface(library) boundaries.
In fact TaskCompletionSource CAN handle this, but the necessary TrySetCanceled overload is internal so only mscorlib components can use it.
So does anyone have a pattern that communicates that a cancellation has been "handled" across Task and Delegate boundaries?
I've found that I can't distinguish controlled from "uncontrolled" cancellation of Tasks/delegates without checking the details of how they are implemented.
Moreover, the fact that you have caught an OperationCanceledException
exception while awaiting or waiting the task doesn't necessarily mean the task's Status
is TaskStatus.Canceled
. It may as well be TaskStatus.Faulted
.
There are probably a few options to implement what you're after. I'd do it using ContinueWith
and pass that continuation task to the client code, rather than the original TaskCompletionSource.Task
:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApplication
{
public static class TaskExt
{
public static Task<TResult> TaskWithCancellation<TResult>(
this TaskCompletionSource<TResult> @this,
CancellationToken token)
{
var registration = token.Register(() => @this.TrySetCanceled());
return @this.Task.ContinueWith(
task => { registration.Dispose(); return task.Result; },
token,
TaskContinuationOptions.LazyCancellation |
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}
class Program
{
static async Task OtherSideAsync(Task task, CancellationToken token)
{
try
{
await task;
}
catch (OperationCanceledException ex)
{
if (token != ex.CancellationToken)
throw;
Console.WriteLine("Cancelled with the correct token");
}
}
static void Main(string[] args)
{
var cts = new CancellationTokenSource(1000); // cancel in 1s
var tcs = new TaskCompletionSource<object>();
var taskWithCancellation = tcs.TaskWithCancellation(cts.Token);
try
{
OtherSideAsync(taskWithCancellation, cts.Token).Wait();
}
catch (AggregateException ex)
{
Console.WriteLine(ex.InnerException.Message);
}
Console.ReadLine();
}
}
}
Note the use of TaskContinuationOptions.LazyCancellation
, it's there to make sure the continuation task never gets completed before the tcs.Task
task (when the cancellation has been requested via token
).
Note also that if tcs.TrySetCanceled
is called before the cancellation has been requested via token
, the resulting task will be in faulted rather than cancelled state (taskWithCancellation.IsFaulted == true
but taskWithCancellation.IsCancelled == false
). If you want the cancellation status to be propagated for both implicit token
and explicit tcs.TrySetCanceled
cancellations, change the TaskWithCancellation
extension like this:
public static Task<TResult> TaskWithCancellation<TResult>(
this TaskCompletionSource<TResult> @this,
CancellationToken token)
{
var registration = token.Register(() => @this.TrySetCanceled());
return @this.Task.ContinueWith(
task => { registration.Dispose(); return task; },
token,
TaskContinuationOptions.LazyCancellation |
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default).Unwrap();
}
Updated to address comments:
A typical design of a Task
-based library API is that the client code supplies a cancellation token to the API, and the API returns a Task
, which is associated with the supplied token. The client code of the API can then do the token matching when catching cancelation exceptions.
The exact purpose of the TaskWithCancellation
is to create such Task
and return it to the client. The original TaskCompletionSource.Task
is never exposed to the client. The cancelation happens because the token was passed to ContinueWith
, that's how it gets associated with the continuation task. OTOH, token.Register
, TrySetCanceled
and TaskContinuationOptions.LazyCancellation
are used just to make sure the things happen in the right order, including the registration clean-up.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With