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?
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.
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;
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With