Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Test for UnObserved Exceptions

I have a C# Extension method that can be used with tasks to make sure any exceptions thrown are at the very minimum observed, so as to not crash the hosting process. In .NET4.5, the behavior has changed slightly so this won't happen, however the unobserved exception event is still triggered. My challenge here is writing a test to prove the extension method works. I am using NUnit Test Framework and ReSharper is the test runner.

I have tried:

var wasUnobservedException = false;
TaskScheduler.UnobservedTaskException += (s, args) => wasUnobservedException = true;
var res = TaskEx.Run(() =>
                          {
                              throw new NaiveTimeoutException();
                              return new DateTime?();
                          });
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(wasUnobservedException);

The test always fails on the Assert.IsTrue. When I run this test manually, in something like LINQPad, I get the expected behavior of wasUnobservedException coming back as true.

I am guessing the test framework is catching the exception and observing it such that the TaskScheduler.UnobservedTaskException is never being triggered.

I have tried modifying the code as follows:

var wasUnobservedException = false;
TaskScheduler.UnobservedTaskException += (s, args) => wasUnobservedException = true;
var res = TaskEx.Run(async () =>
                          {
                              await TaskEx.Delay(5000).WithTimeout(1000).Wait();
                              return new DateTime?();
                          });
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(wasUnobservedException);

The attempt I made in this code was to cause the task to get GC'd before the exception was thrown, so that the finalizer would see an uncaught, unobserved exception. However this resulted in the same failure described above.

Is there, in fact, some sort of exception handler hooked up by the Test Framework? If so, is there a way around it? Or am I just completely messing something up and there is a better/easier/cleaner way of doing this?

like image 877
Pete Garafano Avatar asked Jan 21 '14 18:01

Pete Garafano


2 Answers

I see a few problems with this approach.

First, there's a definite race condition. When TaskEx.Run returns a task, it has merely queued a request to the thread pool; the task has not necessarily yet completed.

Second, you're running into a some garbage collector details. When compiled in debug - and really, whenever else the compiler feels like it - the lifetimes of local variables (i.e., res) are extended to the end of the method.

With these two problems in mind, I was able to get the following code to pass:

var wasUnobservedException = false;
TaskScheduler.UnobservedTaskException += (s, args) => wasUnobservedException = true;
var res = Task.Run(() =>
{
    throw new NotImplementedException();
    return new DateTime?();
});
((IAsyncResult)res).AsyncWaitHandle.WaitOne(); // Wait for the task to complete
res = null; // Allow the task to be GC'ed
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(wasUnobservedException);

However, there are still two problems:

There is still (technically) a race condition. Although UnobservedTaskException is raised as a result of a task finalizer, there is no guarantee AFAIK that it is raised from the task finalizer. Currently, it appears to be, but that seems to me to be a very unstable solution (considering how restricted finalizers are supposed to be). So, in a future version of the framework, I wouldn't be too surprised to learn that the finalizer merely queues an UnobservedTaskException to the thread pool instead of executing it directly. And in that case you can no longer depend on the fact that the event has been handled by the time the task is finalized (an implicit assumption made by the code above).

There is also a problem regarding modification of global state (UnobservedTaskException) within a unit test.

Taking both of these problems into account, I end up with:

var mre = new ManualResetEvent(initialState: false);
EventHandler<UnobservedTaskExceptionEventArgs> subscription = (s, args) => mre.Set();
TaskScheduler.UnobservedTaskException += subscription;
try
{
    var res = Task.Run(() =>
    {
        throw new NotImplementedException();
        return new DateTime?();
    });
    ((IAsyncResult)res).AsyncWaitHandle.WaitOne(); // Wait for the task to complete
    res = null; // Allow the task to be GC'ed
    GC.Collect();
    GC.WaitForPendingFinalizers();
    if (!mre.WaitOne(10000))
        Assert.Fail();
}
finally
{
    TaskScheduler.UnobservedTaskException -= subscription;
}

Which also passes but is of rather questionable value considering its complexity.

like image 193
Stephen Cleary Avatar answered Oct 22 '22 05:10

Stephen Cleary


Just adding a complement to @Stephen Cleary's solution (please, do not upvote my answer).

As he mentioned before, the lifetime of local variables is extended to the end of the method when compiled in "Debug" mode, so the proposed solution only works when the code is compiled in "Release" mode (debugger not attached).

If you really need to unit test this behavior (or make it work in "Debug" mode), you can "trick" the GC putting the code that starts the Task inside an Action (or local function). Doing this, after the action is called, the Task will be available to be collected by GC.

var mre = new ManualResetEvent(initialState: false);
EventHandler<UnobservedTaskExceptionEventArgs> subscription = (s, args) => mre.Set();
TaskScheduler.UnobservedTaskException += subscription;
try
{
    Action runTask = () =>
    {
        var res = Task.Run(() =>
        {
            throw new NotImplementedException();
            return new DateTime?();
        });
        ((IAsyncResult)res).AsyncWaitHandle.WaitOne(); // Wait for the task to complete
    };

    runTask.Invoke();

    GC.Collect();
    GC.WaitForPendingFinalizers();

    if (!mre.WaitOne(10000))
        Assert.Fail();
}
finally
{
    TaskScheduler.UnobservedTaskException -= subscription;
}
like image 6
Carlos Henrique Lopes Avatar answered Oct 22 '22 05:10

Carlos Henrique Lopes