Search code examples
c#.nettask-parallel-librarywaitcancellation

Task.Wait unexpected behavior in case of OperationCanceledException


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?


Solution

  • 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