.NET exceptions are built on a Win32 concept called structured exception handling (SEH).
SEH has a "two-step" system for managing thrown exceptions. The first step is walking up the stack searching for a handler. This first step is weird because it will execute exception filters before unwinding the stack. (On a side note, exception filters are a new addition to the C# language in C# 6 / VS2015). So the first step walks up the stack executing filters until it finds a catch
block with a matching filter. Only then does the second step take place: unwinding the stack and starting execution of the catch
block. (On a side note, SEH exception filters can also return a value meaning "resume execution at the point where it was thrown", but this is advanced and esoteric behavior, not available in C#).
The final piece of the puzzle is conjecture on my part, but it seems reasonable: the debugger hooks (or watches / intercepts / whatever) all thread entry points, and if an exception escapes to that point it will kick off its "unhandled exception" workflow. If I had to guess, the debugger implements its "hook" as an SEH filter (at least, it appears to behave like one).
That's sufficient to describe the observed behavior.
For example, consider what happens when a catch
block contains a throw;
statement:
static void Main()
{
TestA();
}
static void TestA()
{
try
{
TestB();
}
catch
{
throw; // Debugger breaks here
}
}
static void TestB()
{
throw new InvalidCastException();
}
When the exception is thrown, the first step of SEH searches up the call stack and finds the catch
block. Then the second step of SEH unwinds the stack up to that catch
block and executes it.
When the throw;
executes, it actually kicks off a different SEH exception. The first step of SEH searches up the call stack and triggers the debugger hook. The debugger then stops everything and steps in before the stack is unwound. But note that the debugger breaks at the throw;
statement, because that's where the stack actually is.
Now, we did not stomp the stack in the Exception
object (in other words, we were sure to use throw;
and not throw exception;
), and if you examine the exception details you will indeed see the entire stack. This stack was captured and placed on the Exception
object at the point it was originally thrown. But the debugger does not examine the Exception
object instance; it's only looking at the actual thread stack, which ends at throw;
.
Note the difference in behavior with the new exception filters:
static void Main(string[] args)
{
TestA();
}
static void TestA()
{
try
{
TestB();
}
catch when (false)
{
throw;
}
}
static void TestB()
{
throw new InvalidCastException(); // Debugger breaks here
}
Now, when the exception is thrown, SEH performs its first step and searches up the call stack. It finds the catch
but the filter returns false
, so it just continues searching and runs into the debugger thread hook. At that point, the debugger stops everything and breaks at the current stack, which is where the exception was thrown. The stack was never unwound to the catch
so it doesn't end up breaking there.
So, to apply this to your specific question...
The WPF event handler has nothing between it and the UI thread proc to catch the exception, so any exceptions will take their first step all the way to the debugger hook:
private async void SomeButton_Click(object sender, RoutedEventArgs e)
{
throw new NotImplementedException();
}
On the controller action, things are quite different:
public async Task<SomeReturnValue> SomeAction()
{
throw new NotImplementedException();
}
First, the method is returning a Task<T>
. When you have an async
method that returns a task, the compiler rewriting of the async
method will catch any exceptions and just place them on the task. Internally, ASP.NET then observes those exceptions and sends a 500. Even if this was a synchronous method, ASP.NET would still catch your exceptions automatically and translate them into a 500 response.
Those exceptions are never raised all the way to the thread proc. Which is a good thing, because that would cause your entire web app to crash. :)
Unfortunately, this means that the debugger "unhandled exception" workflow is never kicked off, either.