Multiple exceptions yet again… this time with a resolution

I’ve had a wonderful day with Mads Torgersen, and amongst other things, we discussed multiple exceptions and the way that the default awaiter for Task<T> handles an AggregateException by taking the first exception and discarding the rest.

I now have a much clearer understanding of why this is the case, and also a workaround for the cases where you really want to avoid that truncation.

Why truncate in the first place?

(I’ll use the term "truncate" throughout this post to mean "when an AggregatedException with at least one nested exception is caught by EndAwait, throw the first nested exception instead". It’s just a shorthand.)

Yesterday’s post on multiple exceptions showed what you got if you called Wait() on a task returned from an async method. You still get an AggregateException, so why bother to truncate it?

Let’s consider a slightly different situation: where we’re awaiting an async method that throws an exception, and you want to be able to catch some specific exception that will be thrown by that asynchronous method. Imagine we used my NaiveAwaiter class. That would mean we would have to catch AggregateException, check whether one of those exceptions was actually present, and then handle that. There’d then be an open question about what to do if there were other exceptions as well… but that would be a relatively rare case. (Remember, we’re talking about multiple "top level" exceptions within the AggregateException – not just one exception nested in another, nested in another etc.)

With the current awaiter behaviour, you can catch the exception exactly as you would have done in synchronous code. Here’s an example:

using System;
using System.Threading.Tasks;
using System.Collections.Generic;

public class BangException : Exception 
{
    public BangException(string message) : base(message) {}
}

public class Test
{
    public static void Main()
    {
        FrobAsync().Wait();
    }
    
    private static async Task FrobAsync()
    {
        Task fuse = DelayedThrow(500);
        try
        {
            await fuse;
        }
        catch (BangException e)
        {
            Console.WriteLine("Caught it! ({0})", e.Message);
        }
    }
    
    static async Task DelayedThrow(int delayMillis) 
    { 
        await TaskEx.Delay(delayMillis);
        throw new BangException("Went bang after " + delayMillis + "ms");
    }
}

Nice and clean exception handling… assuming that the task we awaited asynchronously didn’t have multiple exceptions. (Note the improved DelayedThrow method, by the way. Definitely cleaner than my previous version.)

This aspect of "the async code looks like the synchronous code" is the important bit. One of the key aims of the language feature is to make it easy to write asynchronous code as if it were synchronous – because that’s what we’re used to, and what we know how to reason about. We’re fairly used to the idea of catching one exception… not so much on the "multiple things can go wrong at the same time" front.

So that handles the primary case where we really expect to only have one exception (if any) because we’re only performing one job.

What about cases where multiple exceptions are somewhat expected?

Let’s go back to the case where we really to propagate multiple exceptions. I think it’s reasonable that this should be an explicit opt-in, so let’s think about an extension method. For the sake of simplicity I’ll use Task – in real life we’d want Task<T> as well, of course. So for example, this line:

await TaskEx.WhenAll(t1, t2);

would become this:

await TaskEx.WhenAll(t1, t2).PreserveMultipleExceptions();

(Yes, the name is too long… but you get the idea.)

Now, there are two ways we could make this work:

  • We could make the extension method return something which had a GetAwaiter method, returning something which in turn had BeginAwait and EndAwait methods. This means making sure we get all of the awaiter code right, of course – and the returned value has little meaning outside an await expression.
  • We could wrap the task in another task, and use the existing awaiter code. We know that the EndAwait extension method associated with Task (and Task<T>) will go into a single level of AggregateException – but I don’t believe it will do any more than that. So if it’s going to strip one level of exception aggregation off, all we need to do is add another level.

According to Mads, the latter of these is easier. Let’s see if he’s right.

We need an extension method on Task, and we’re going to return Task too. How can we implement that?

  • We can’t await the task, because that will strip the exception before we get to it.
  • We can’t write an async task but call Wait() on the original task, because that will block immediately – we still want to be async.
  • We can use a TaskCompletionSource<T> to build a task. We don’t care about the actual result, so we’ll use TaskCompletionSource<object>. This will actually build a Task<object>, but we’ll return it as a Task anyway, and use a null result if it completes with no exception. (This was Mads’ suggestion.)

So, we know how to build a Task, and we’ve been given a Task – how do we hook the two together? The answer is to ask the original task to call us back when it completes, via the ContinueWith method. We can then set the result of our task accordingly. Without further ado, here’s the code:

public static Task PreserveMultipleExceptions(this Task originalTask)
{
    var tcs = new TaskCompletionSource<object>();
    originalTask.ContinueWith(t => {
        switch (t.Status) {
            case TaskStatus.Canceled:
                tcs.SetCanceled();
                break;
            case TaskStatus.RanToCompletion:
                tcs.SetResult(null);
                break;
            case TaskStatus.Faulted:
                tcs.SetException(originalTask.Exception);
                break;
        }
    }, TaskContinuationOptions.ExecuteSynchronously);
    return tcs.Task;
}

This was thrown together in 5 minutes (in the middle of a user group talk by Mads) so it’s probably not as robust as it might be… but the idea is that when the original task completes, we just piggy-back on the same thread very briefly to make our own task respond appropriately. Now when some code awaits our returned task, we’ll add an extra wrapper of AggregateException on top, ready to be unwrapped by the normal awaiter.

Note that the extra wrapper is actually added for us really, really easily – we just call TaskCompletionSource<T>.SetException with the original task’s AggregateException. Usually we’d call SetException with a single exception (like a BangException) and the method automatically wraps it in an AggregateException – which is exactly what we want.

So, how do we use it? Here’s a complete sample (just add the extension method above):

using System;
using System.Threading.Tasks;

public class BangException : Exception  

    public BangException(string message) : base(message) {} 
}

public class Test
{
    public static void Main()
    {
        FrobAsync().Wait();
    }
    
    public static async Task FrobAsync()
    {
        try
        {
            Task t1 = DelayedThrow(500);
            Task t2 = DelayedThrow(1000);
            Task t3 = DelayedThrow(1500);
            
            await TaskEx.WhenAll(t1, t2, t3).PreserveMultipleExceptions();
        }
        catch (AggregateException e)
        {
            Console.WriteLine("Caught {0} aggregated exceptions", e.InnerExceptions.Count);
        }
        catch (Exception e)
        {
            Console.WriteLine("Caught non-aggregated exception: {0}", e.Message);
        }
    }
    
    static async Task DelayedThrow(int delayMillis)  
    {  
        await TaskEx.Delay(delayMillis); 
        throw new BangException("Went bang after " + delayMillis + "ms"); 
    }
}

The result is what we were after:

Caught 3 aggregated exceptions

The blanket catch (Exception e) block is there so you can experiment with what happens if you remove the call to PreserveMultipleExceptions – in that case we get the original behaviour of a single BangException being caught, and the others discarded.

Conclusion

So, we now have answers to both of my big questions around multiple exceptions with async:

  • Why is the default awaiter truncating exceptions? To make asynchronous exception handling look like synchronous exception handling in the common case.
  • What can we do if that’s not the behaviour we want? Either write our own awaiter (whether that’s invoked explicitly or implicitly via "extension method overriding" as shown yesterday) or wrap the task in another one to wrap exceptions.

I’m happy again. Thanks Mads :)

5 thoughts on “Multiple exceptions yet again… this time with a resolution”

  1. I noticed that TaskCompletionSource with an always-null result was also used in the AwaitButtonClick example – I guess that’s one way to avoid the duplicity between Task and Task…

    Like

  2. I certainly understand the reasoning behind the truncation approach, but I’m surprised that you accept it so easily.

    I think there are a few flaws with truncation, but the most important one is that your code might fail surprisingly.

    For example, consider the following:
    try
    {
    await TaskEx.WhenAll (t1, t2, t3);
    }
    catch (InvalidOperationException ex)
    {
    // handle gracefully
    }

    Now, further consider that t1 often throws an InvalidOperationException, and t2 always throws a DifferentException.
    It might well be that you don’t notice that you forgot to handle the DifferentException at first. Only in production, there is no InvalidOperationException, and suddenly, boom, there is an unhandled exception. Which was there all along, you just didn’t notice.

    Of course, this would not occur if the awaiter for WhenAll didn’t truncate all exceptions but the first. The problem is really that an exception that always was there is allowed to go undetected even though it might bring down the program. I think basing a C# feature on this is definitely problematic.

    Sure, there’s the work-around; but what’s really the problem with making the AggregateException the default and the truncation opt-in?
    I mean, when you construct a task using WhenAll, I don’t really see a problem with demanding that you need to deal with AggregateExceptions – unless you explicitly opt to ignore them. (Of course, C# has no good syntax to deal with AggregateExceptions; but this is a different problem.)

    There are other areas in the .NET framework where the “just rethrow it” approach was _not_ used on purpose. Calling methods via Reflection, for example. There are good reasons for this – preservation of the original call stack, ability to differentiate whether the exception comes from the code being called or from the entity calling the code, and so on. (By the way, what happens to the call stack when the awaiter re-throws an exception? Is it overwritten – which would be bad for debugging -, or is it kept by some framework-internal magic?)

    The argument that with truncation, “the async code looks like the synchronous code” is misleading, I think. Mainly, there is no way to write exception handlers for non-general exceptions but general tasks. Either you know what the task does, then you can catch exactly those exceptions the task could throw. Or you don’t know what the task does, then you can only really catch Exception. Since in the first case you would need to know what the task does, anyway, there’s no problem with catching AggregateException here.

    What am I missing?

    Best regards,
    Fabian

    Like

  3. @Fabian: I suspect that when awaiting multiple tasks, I’ll use my workaround – because that’s the rare case. Even though Task exposes AggregateException, I strongly suspect it’ll be rarely used for more than one exception.

    Exceptions are maintained using code that I believe was originally designed for remoting – I don’t know what it’s like in terms of debugging though.

    I would say that the problem of not knowing whether to catch one thing, Exception, or nothing at all goes beyond await – it’s a general problem with how exception handling works. As I’ve become increasingly fond of saying, we’re just not there yet as an industry when it comes to error handling.

    Like

Leave a comment