DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling exception filters: the catch (e) when (...) that becomes a filter region and the endfilter that runs it

Most of what a decompiler does with exception handling is pattern-matching the obvious. A try/finally is two regions and a leave; a catch (IOException e) is a handler clause tagged with a type token, and the reader just prints the type. The one exception-handling construct that genuinely earns a second look is the filtered catch — catch (E e) when (condition) — because it is not the same kind of clause at all. It has no type token, it runs code before the handler is even chosen, and it ends in an instruction most C# developers have never heard of: endfilter.

This post reads the IL a filtered catch actually emits, walks the endfilter protocol step by step, and shows how Glass.NET rebuilds the clause — including the one real language guarantee the filter hides: exceptions thrown inside it are swallowed.

A filtered catch is a two-region clause with no type

Here is the source we will decompile:

try
{
    DoIo();
}
catch (IOException e) when (e.HResult == 5)
{
    Log("access denied");
}
Enter fullscreen mode Exit fullscreen mode

A plain catch (IOException e) would compile to one handler region with IOException as its clause type. The when changes the clause kind. The metadata now records a filter clause, whose type field is empty, and the method carries two regions instead of one — a filter region and a handler region. Decompiled to ILDasm's rendering, the structure is:

.try IL_0000 to IL_0007 filter IL_0007 handler IL_0021 to IL_002e

IL_0000: call       void DoIo()
IL_0005: leave.s    IL_002e
// ---- filter region: the runtime pushes the exception object here ----
IL_0007: isinst     [System.Runtime]System.IO.IOException
IL_000c: dup
IL_000d: brtrue.s   IL_0013
IL_000f: pop
IL_0010: ldc.i4.0
IL_0011: br.s       IL_001f
IL_0013: callvirt   instance int32 [System.Runtime]System.Exception::get_HResult()
IL_0018: ldc.i4.5
IL_0019: ceq
IL_001b: br.s       IL_001f
IL_001f: endfilter
// ---- handler region: exception object on the stack again ----
IL_0021: pop
IL_0022: ldstr      "access denied"
IL_0027: call       void Log(string)
IL_002c: leave.s    IL_002e
IL_002e: ret
Enter fullscreen mode Exit fullscreen mode

Read the filter region as a tiny program the runtime runs before it decides whether to enter the handler. The exception object is already on the stack when the region starts — the runtime put it there. isinst IOException does the type test the catch (IOException ...) part asked for; dup/brtrue keeps the cast result if it matched; if it did not match, pop and ldc.i4.0 leave 0 and jump to endfilter, abandoning the clause. If it did match, the code evaluates e.HResult == 5, leaving the ceq result — 0 or 1 — on the stack. Either path reaches endfilter with exactly one integer behind it.

endfilter is the instruction that chooses the handler

endfilter is the whole point, and it has a protocol worth stating precisely. The filter region must leave exactly one int32 on the stack; endfilter pops it and hands it to the runtime. 1 (EXCEPTION_EXECUTE_HANDLER) means run the handler; 0 (EXCEPTION_CONTINUE_SEARCH) means this clause does not apply, keep unwinding to find another. That is why every filter region in the world ends the same way — some comparison that produces 0 or 1, then endfilter. For the decompiler, endfilter is the boundary marker: everything from the start of the filter region up to it is the when condition (plus the implicit type test), and everything after the handler label is the catch body.

Then the handler region begins — and the exception object is on the stack again, pushed by the runtime a second time as the handler is entered. In our IL, IL_0021: pop discards it, because the body only uses the literal string. Had the body used e, the compiler would have emitted stloc into the e slot instead of pop. Recognising that leading pop-or-stloc is how Glass.NET knows where the condition ends and the human-written catch body starts, and whether the exception variable is actually read.

What Glass.NET reconstructs, and from where

Put the pieces together and the reconstruction is mechanical. Glass.NET sees a filter clause with no type token and knows immediately it is a when catch. It reads the filter region's prologue — the isinst against IOException, the dup/brtrue/pop/ldc.i4.0 null-check dance — and lifts two things out of it: the isinst target becomes the catch type, and the exception object the runtime pushed becomes the e variable. Everything between that prologue and endfilter is the when expression. The handler region's leading stloc/pop confirms whether e is used. The result:

catch (IOException e) when (e.HResult == 5)
{
    Log("access denied");
}
Enter fullscreen mode Exit fullscreen mode

— recovered faithfully, with no PDB, because every part of it is encoded in the IL and the exception-region metadata that the runtime itself depends on. A decompiler that did not model filters would show you a type-less catch with a block of comparison IL it did not know how to place, which is precisely the kind of "the code runs but the decompilation is wrong" gap a reader navigating an unfamiliar binary cannot afford.

The guarantee hiding in the filter: thrown exceptions are swallowed

One more thing the IL makes legible, and it is a real language rule, not a quirk. If the filter region itself throws — say e.HResult were replaced by a property that could throw — the CLR catches that exception and treats the filter as having returned 0, continue search. The when condition that throws does not crash your program and does not escape; it just causes that catch to be skipped. This is specified behaviour, and it is why a when clause should be cheap and side-effect-free: a condition that can throw is a subtle bug, because it silently disables the catch you thought you wrote.

Glass.NET renders the clause as the catch ... when (...) it was and leaves the swallowing behaviour as what it is — a property of filters the reader needs to understand, not something a decompiler should rewrite away. Making that structure visible is the value: the decompiled source tells you this is a filter, and a reader who knows filters knows a throwing condition here quietly fails the match. That is the difference between decompilation that reproduces the bytes and decompilation that reproduces the meaning.

The takeaway

The filtered catch is the exception construct where IL and source stop being one-to-one, and reading it is a good test of a decompiler. There is no type token — the type moved into an isinst inside a separate filter region. The condition runs before the handler is chosen, against an exception object the runtime pushes onto the stack. endfilter pops the 0-or-1 that decides everything. And a throw inside the filter is swallowed as a language guarantee. Glass.NET models all of it: the two-region structure, the type lifted from the filter, the variable from the pushed exception, the condition up to endfilter, and the handler after. What you get back is the catch (E e) when (…) you wrote — and an accurate picture of the one place C#'s exception handling does something most people never knew it did.

Top comments (0)