How C# Exception Filters Preserve Stack Traces
In C#, handling errors effectively requires maintaining accurate diagnostic data about where an exception originated. This article explains how C# exception filters leverage the .NET runtime's two-pass exception handling system to inspect exceptions without unwinding the call stack, preventing stack trace rewriting and preserving the original execution context for debugging.
The Problem with Traditional Catch Blocks
When an exception is thrown in C#, the Common Language Runtime (CLR)
looks for a handling block. In a traditional try-catch
structure, once execution enters a catch block, the CLR has
already committed to unwinding the call stack to that frame.
try
{
PerformOperation();
}
catch (CustomException ex)
{
if (!ex.IsRecoverable)
{
throw; // The stack has already begun unwinding
}
Handle(ex);
}If the code determines inside the block that it cannot handle the
error and calls throw;, the stack trace retains the
original throw point, but the runtime state between the throw site and
the catch block has already been torn down. Local variables, execution
frames, and forensic state useful in post-mortem debugging or crash
dumps are permanently lost.
Worse, developers sometimes write throw ex;, which
completely resets the stack trace to the catch block's location,
obliterating all records of where the exception originally occurred.
The Two-Pass Exception Architecture
The .NET runtime processes exceptions using a two-pass mechanism defined by the underlying operating system (Structured Exception Handling, or SEH):
- Pass 1: The Search Pass. The runtime traverses up the call stack to locate an exception handler willing to handle the exception. The stack is not unwound during this pass; the program state remains paused exactly as it was when the error occurred.
- Pass 2: The Unwind Pass. Once a handler is
identified, the runtime walks the stack a second time, executing
finallyblocks and unwinding stack frames up to the handlingcatchblock.
Standard catch (ExceptionType ex) blocks unconditionally
accept the exception during Pass 1, triggering Pass 2 immediately.
How Exception Filters Prevent Rewriting
Exception filters, introduced in C# 6 using the when
keyword, run code during Pass 1 rather than Pass 2.
try
{
PerformOperation();
}
catch (CustomException ex) when (ex.IsRecoverable)
{
Handle(ex);
}When an exception occurs:
- The runtime reaches the search pass and evaluates the expression
inside the
when (...)clause. - If the expression evaluates to
false, the catch block is rejected. - The runtime continues searching up the stack for another handler without unwinding the stack.
Because no unwinding occurs when the filter evaluates to
false, no stack frames are removed, and the exception
remains entirely untouched. If an unhandled exception reaches the root
of the process, a diagnostic tool or minidump captures the exact memory
state and call stack at the original point of failure.
Logging Without Modifying Stack State
A major practical application of this behavior is logging exceptions without intercepting them:
try
{
RunWorkload();
}
catch (Exception ex) when (LogException(ex))
{
// Never reached if LogException returns false
}private static bool LogException(Exception ex)
{
Logger.LogError(ex, "Unexpected failure.");
return false; // Tells the runtime to keep looking
}By returning false, the filter allows developers to
inspect the exception, log full details, and leave the original stack
trace intact for subsequent handlers or crash analysis tools.