Search code examples
c#tasktask-parallel-librarycancellation

Task.WhenAll AggregateException not capturing TaskCancelledException\OperationCanceledException when other tasks faulting


When catching the Task.WhenAll AggregateException the TaskCancelledException is not available when other tasks are faulted. Running below code I get the following output:

TaskException
Caught in AggregateException
System.ArgumentOutOfRangeException

TaskCanceledException
Caught in TaskCanceledException

TaskException-TaskCanceledException
Caught in AggregateException
System.ArgumentOutOfRangeException

In the last test TaskCancelledException is not thrown, nor is it in the AggregateException.

Is there a means to also have the TaskCanceledException in the AggregateException, or do I always have to check the tasks exception when other tasks have errors?

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;

public static class Program
{
    public static void Main()
    {
        var task1 = Task.FromException(new ArgumentOutOfRangeException());
        var task2 = Task.FromCanceled(new CancellationToken(true));
        Test("TaskException", new[] { task1 });
        Test("TaskCanceledException", new[] { task2 });
        Test("TaskException-TaskCanceledException", new Task[] { task1, task2 });

        static async void Test(string title, Task[] tasks)
        {
            Console.WriteLine();
            Console.WriteLine(title);
            var task = Task.WhenAll(tasks);
            try { await task; }            
            catch (TaskCanceledException)
            {
                Console.WriteLine($"Caught in TaskCanceledException");
            }
            catch
            {
                Console.WriteLine($"Caught in AggregateException");
                if (task.Exception is not null)
                {
                    var t = task.Exception.Flatten();
                    foreach (var x in t.InnerExceptions)
                    {
                        Console.WriteLine(x.GetType());
                    }                   
                }
            }
        }        
    }
}

Solution

  • The behavior of the Task.WhenAll method is to return a canceled Task in case some of the tasks are canceled, and a faulted Task in case some of the tasks are faulted. In case the tasks array contains both canceled and faulted tasks, the Task.WhenAll ignores the canceled tasks, and returns a faulted Task that contains the exceptions of the faulted tasks.

    If you wonder why the Task.WhenAll behaves this way, you could think that a canceled task contains no exception. Its Exception property is null. The TaskCanceledException emerges only when you await a canceled task, and the only information that it conveys is the CancellationToken that caused the cancellation. Any textual information that was present in the original OperationCanceledException, is lost.

    If you want to change the behavior of the Task.WhenAll so that it treats the canceled tasks as faulted, one idea is to pass the tasks through a converter that changes their completion status. Something like this:

    public static Task CanceledToFaulted(Task task)
    {
        ArgumentNullException.ThrowIfNull(task);
        return task.ContinueWith(t =>
        {
            if (t.IsCanceled)
                return Task.FromException(new TaskCanceledException(t));
    
            // In any other case, propagate the task as is.
            return t;
        }, CancellationToken.None, TaskContinuationOptions.DenyChildAttach |
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default).Unwrap();
    }
    

    Usage example:

    Task task = Task.WhenAll(tasks.Select(t => CanceledToFaulted(t)));