Consider the following piece of code:
CancellationTokenSource cts0 = new CancellationTokenSource(), cts1 = new CancellationTokenSource();
try
{
var task = Task.Run(() => { throw new OperationCanceledException("123", cts0.Token); }, cts1.Token);
task.Wait();
}
catch (AggregateException ae) { Console.WriteLine(ae.InnerException); }
Due to MSDN task should be in Faulted
state because it's token does not match exception's token (and also IsCancellationRequested
is false
):
If the token's IsCancellationRequested property returns false or if the exception's token does not match the Task's token, the OperationCanceledException is treated like a normal exception, causing the Task to transition to the Faulted state.
When I launch this code in console app using .NET 4.5.2 I get task in Canceled
state (aggregate exception contains unknown TaskCanceledExeption
, not the original). And all information of original exception is lost (message, inner exception, custom data).
I also noticed that behavior of Task.Wait
differs from await task
in case of OperationCanceledException
.
try { Task.Run(() => { throw new InvalidOperationException("123"); }).Wait(); } // 1
catch (AggregateException ae) { Console.WriteLine(ae.InnerException); }
try { await Task.Run(() => { throw new InvalidOperationException("123"); }); } // 2
catch (InvalidOperationException ex) { Console.WriteLine(ex); }
try { Task.Run(() => { throw new OperationCanceledException("123"); }).Wait(); } // 3
catch (AggregateException ae) { Console.WriteLine(ae.InnerException); }
try { await Task.Run(() => { throw new OperationCanceledException("123"); }); } // 4
catch (OperationCanceledException ex) { Console.WriteLine(ex); }
Cases 1
and 2
produce almost identical result (differ only in StackTrace
), but when I change exception to OperationCanceledException
, then I get very different results: an unknown TaskCanceledException
in case 3
without original data, and expected OpeartionCanceledException
in case 4
with all original data (message, etc.).
So the question is: Does MSDN contain incorrect information? Or is it a bug in .NET? Or maybe it's just I don't understand something?
It is a bug. Task.Run
under the hood calls Task<Task>.Factory.StartNew
. This internal Task is getting the right Status of Faulted. The wrapping task is not.
You can work around this bug by calling
Task.Factory.StartNew(() => { throw new OperationCanceledException("123", cts0.Token); }, cts1.Token, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
Though, you'll lose the other feature of Task.Run
which is unwrapping. See:
Task.Run vs Task.Factory.StartNew
More Details:
Here's the code of Task.Run
where you see that it is creating a wrapping UnwrapPromise
(which derives from Task<TResult>
:
public static Task Run(Func<Task> function, CancellationToken cancellationToken)
{
// Check arguments
if (function == null) throw new ArgumentNullException("function");
Contract.EndContractBlock();
cancellationToken.ThrowIfSourceDisposed();
// Short-circuit if we are given a pre-canceled token
if (cancellationToken.IsCancellationRequested)
return Task.FromCancellation(cancellationToken);
// Kick off initial Task, which will call the user-supplied function and yield a Task.
Task<Task> task1 = Task<Task>.Factory.StartNew(function, cancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
// Create a promise-style Task to be used as a proxy for the operation
// Set lookForOce == true so that unwrap logic can be on the lookout for OCEs thrown as faults from task1, to support in-delegate cancellation.
UnwrapPromise<VoidTaskResult> promise = new UnwrapPromise<VoidTaskResult>(task1, lookForOce: true);
return promise;
}
The Task constructor which it calls does not take a cancellation token (and thus it does not know about the inner Task's cancellation token). Notice it creates a default CancellationToken instead. Here's the ctor it calls:
internal Task(object state, TaskCreationOptions creationOptions, bool promiseStyle)
{
Contract.Assert(promiseStyle, "Promise CTOR: promiseStyle was false");
// Check the creationOptions. We only allow the AttachedToParent option to be specified for promise tasks.
if ((creationOptions & ~TaskCreationOptions.AttachedToParent) != 0)
{
throw new ArgumentOutOfRangeException("creationOptions");
}
// m_parent is readonly, and so must be set in the constructor.
// Only set a parent if AttachedToParent is specified.
if ((creationOptions & TaskCreationOptions.AttachedToParent) != 0)
m_parent = Task.InternalCurrent;
TaskConstructorCore(null, state, default(CancellationToken), creationOptions, InternalTaskOptions.PromiseTask, null);
}
The outer task (the UnwrapPromise
adds a continuation). The continuation examines the inner task. In the case of the inner task being faulted, it consideres finding a a OperationCanceledException as indicating cancellation (regardless of a matching token). Below is the UnwrapPromise<TResult>.TrySetFromTask
(below is also the call stack showing where it gets called). Notice the Faulted state:
private bool TrySetFromTask(Task task, bool lookForOce)
{
Contract.Requires(task != null && task.IsCompleted, "TrySetFromTask: Expected task to have completed.");
bool result = false;
switch (task.Status)
{
case TaskStatus.Canceled:
result = TrySetCanceled(task.CancellationToken, task.GetCancellationExceptionDispatchInfo());
break;
case TaskStatus.Faulted:
var edis = task.GetExceptionDispatchInfos();
ExceptionDispatchInfo oceEdi;
OperationCanceledException oce;
if (lookForOce && edis.Count > 0 &&
(oceEdi = edis[0]) != null &&
(oce = oceEdi.SourceException as OperationCanceledException) != null)
{
result = TrySetCanceled(oce.CancellationToken, oceEdi);
}
else
{
result = TrySetException(edis);
}
break;
case TaskStatus.RanToCompletion:
var taskTResult = task as Task<TResult>;
result = TrySetResult(taskTResult != null ? taskTResult.Result : default(TResult));
break;
}
return result;
}
Call stack:
mscorlib.dll!System.Threading.Tasks.Task<System.Threading.Tasks.VoidTaskResult>.TrySetCanceled(System.Threading.CancellationToken tokenToRecord, object cancellationException) Line 645 C#
mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.TrySetFromTask(System.Threading.Tasks.Task task, bool lookForOce) Line 6988 + 0x9f bytes C#
mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.ProcessCompletedOuterTask(System.Threading.Tasks.Task task) Line 6956 + 0xe bytes C#
mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.InvokeCore(System.Threading.Tasks.Task completingTask) Line 6910 + 0x7 bytes C#
mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.Invoke(System.Threading.Tasks.Task completingTask) Line 6891 + 0x9 bytes C#
mscorlib.dll!System.Threading.Tasks.Task.FinishContinuations() Line 3571 C#
mscorlib.dll!System.Threading.Tasks.Task.FinishStageThree() Line 2323 + 0x7 bytes C#
mscorlib.dll!System.Threading.Tasks.Task.FinishStageTwo() Line 2294 + 0x7 bytes C#
mscorlib.dll!System.Threading.Tasks.Task.Finish(bool bUserDelegateExecuted) Line 2233 C#
mscorlib.dll!System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref System.Threading.Tasks.Task currentTaskSlot) Line 2785 + 0xc bytes C#
mscorlib.dll!System.Threading.Tasks.Task.ExecuteEntry(bool bPreventDoubleExecution) Line 2728 C#
mscorlib.dll!System.Threading.Tasks.Task.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem() Line 2664 + 0x7 bytes C#
mscorlib.dll!System.Threading.ThreadPoolWorkQueue.Dispatch() Line 829 C#
mscorlib.dll!System.Threading._ThreadPoolWaitCallback.PerformWaitCallback() Line 1170 + 0x5 bytes C#
It notices the OperationCanceledException and calls TrySetCanceled to put the task into the cancelled state.
An aside:
Another thing to note is that when you start using async
methods, there isn't really a way to register a cancellation token with an async
method. Thus, any OperationCancelledException that gets encountered in an async methods is considered a cancellation.
See
Associate a CancellationToken with an async method's Task