TAP global exception handler

This is actually a good question, if I understood it correctly. I initially voted to close it, but now retracted my vote.

It is important to understand how an exception thrown inside an async Task method gets propagated outside it. The most important thing is that such exception needs to be observed by the code which handles the completion of the task.

For example, here is a simple WPF app, I’m on NET 4.5.1:

using System;
using System.Threading.Tasks;
using System.Windows;

namespace WpfApplication_22369179
{
    public partial class MainWindow : Window
    {
        Task _task;

        public MainWindow()
        {
            InitializeComponent();

            AppDomain.CurrentDomain.UnhandledException +=
                CurrentDomain_UnhandledException;
            TaskScheduler.UnobservedTaskException +=
                TaskScheduler_UnobservedTaskException;

            _task = DoAsync();
        }

        async Task DoAsync()
        {
            await Task.Delay(1000);

            MessageBox.Show("Before throwing...");

            GCAsync(); // fire-and-forget the GC

            throw new ApplicationException("Surprise");
        }

        async void GCAsync()
        {
            await Task.Delay(1000);

            MessageBox.Show("Before GC...");

            // garbage-collect the task without observing its exception 
            _task = null;
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
        }

        void TaskScheduler_UnobservedTaskException(object sender,
            UnobservedTaskExceptionEventArgs e)
        {
            MessageBox.Show("TaskScheduler_UnobservedTaskException:" +
                e.Exception.Message);
        }

        void CurrentDomain_UnhandledException(object sender,
            UnhandledExceptionEventArgs e)
        {
            MessageBox.Show("CurrentDomain_UnhandledException:" +
                ((Exception)e.ExceptionObject).Message);
        }
    }
}

Once ApplicationException has been thrown, it goes unobserved. Neither TaskScheduler_UnobservedTaskException nor CurrentDomain_UnhandledException gets invoked. The exception remains dormant until the _task object gets waited or awaited. In the above example it never gets observed, so TaskScheduler_UnobservedTaskException will be invoked only when the task gets garbage-collected. Then this exception will be swallowed.

The old .NET 4.0 behavior, where the AppDomain.CurrentDomain.UnhandledException event gets fired and the app crashes, can be enabled by configuring ThrowUnobservedTaskExceptions in app.config:

<configuration>
    <runtime>
      <ThrowUnobservedTaskExceptions enabled="true"/>
    </runtime>
</configuration>

When enabled this way, AppDomain.CurrentDomain.UnhandledException will still be fired after TaskScheduler.UnobservedTaskException when the exception gets garbage-collected, rather than on the spot where it thrown.

This behavior is described by Stephen Toub in his “Task Exception Handling in .NET 4.5” blog post. The part about task garbage-collection is described in the comments to the post.

That’s the case with async Task methods. The story is quite different for async void methods, which are typically used for event handlers. Let’s change the code this way:

public MainWindow()
{
    InitializeComponent();

    AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
    TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;

    this.Loaded += MainWindow_Loaded;
}

async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
    await Task.Delay(1000);

    MessageBox.Show("Before throwing...");

    throw new ApplicationException("Surprise");
}

Because it’s async void there’s no Task reference to hold on to (so there’s nothing to be possibly observed or garbage-collected later). In this case, the exception is thrown immediately on the current synchronization context. For a WPF app, Dispatcher.UnhandledException will be fired first, then Application.Current.DispatcherUnhandledException, then AppDomain.CurrentDomain.UnhandledException. Finally, if none of these events are handled (EventArgs.Handled is not set to true), the app will crash, regardless of the ThrowUnobservedTaskExceptions setting. TaskScheduler.UnobservedTaskException is not getting fired in this case, for the same reason: there is no Task.

Leave a Comment