Exception dispatch
Axle’s throw / catch uses a polling model — not the strict
LLVM exception-handling model (invoke / landingpad / resume). This page describes what the compiler emits and how
catches dispatch.
What this page covers: why polling instead of LLVM EH · how a throw lowers · the poll after every may-throw call · how a catch picks its
handler · what finally runs on · every runtime symbol the model uses.
Why polling
Strict LLVM-EH requires invoke / landingpad / resume plus the
MSVC SEH funclet primitives (catchpad / catchswitch / cleanuppad) on Windows. The codegen kernel opens no door onto the
funclet primitives, and an Itanium-style personality function isn’t available
on an MSVC linker — so LLVM-native EH cannot be emitted. A setjmp / longjmp scheme is also ruled out (setjmp needs SEH
unwind metadata LLVM doesn’t emit, and the jump buffer must live in
the caller’s own frame). Polling sidesteps all of that and is Windows MSVC-compatible out of the box.
How a throw flows
fn save() : void ! IOException {
throw IOException("disk full");
} The compiler lowers the throw into two shapes depending on context :
- Inside a try-scope. Build the exception object, write
(value_ptr, class_id)into the active try-scope’s value and class slots, and branch straight to its dispatch block. Control never leaves the function — the dispatch block takes over. - Outside any try-scope. Stash
(value_ptr, class_id)into a thread-local slot via__axle_throw, which returns normally (it does not abort), then terminate the block:maincallsaxle_unhandled_exception()(print + exit 1) followed byunreachable, while any other function returnsundefof its return type so the caller’s post-call poll picks the pending exception up and the chain unwinds frame by frame.
A throw of a stdlib exception class on the abort path routes
through axle_native_throw(class_name, message) instead — the class
name and the real message, loaded from the constructed instance — so
the runtime resolves the class id from its name registry — the stdlib
module and the user program assign different local TypeIds to the
same class, and the name lookup canonicalises across that boundary.
Post-call poll
After every call to a function the compiler has proven may throw
(through its declared ! error set or an uncaught throwing call it
makes), the emitted IR includes a poll :
%has_exc = call i1 @axle_has_exception()
br i1 %has_exc, label %dispatch.exc, label %normal.continue If has_exc is true, control jumps to the enclosing try-scope’s
dispatch block (after reading the class id and taking the exception
into SSA), or — with no try-scope active — runs the cleanup chain
and propagates outward. Calls to fns proven non-throwing skip the
poll entirely — zero hot-path cost for the common case.
The unwind edge is tagged with a cold branch weight (≈ 1 / 2000) so
LLVM’s block layout pushes the propagation path off the hot path. axle_has_exception carries nounwind, which lets LLVM drop the
unwind tables around the poll. The poll itself is one load + one
branch on the hot path (predicted false forever when nothing
throws). It carries no memory(read) effect — which would let LLVM fold
the load across consecutive call sites — because the runtime-function table
names a signature and nothing else: axle_has_exception is declared with no
memory-effect annotation.
Catch dispatch
try { stmts } catch e : X { handler } catch e : Y { handler } lowers to a value slot, a class slot, and a chain of i32 compares :
try.body:
<stmts>
br label %try.exit
try.dispatch: ; reached via in-scope throw or post-call poll
%cls = load i32, ptr %class_slot
%eq0 = icmp eq i32 %cls, <id in X's closure>
; … OR-chained against every id in X's subclass closure …
br i1 %match0, label %catch.0, label %try.dispatch.next.0
try.dispatch.next.0:
; same shape for Y's closure
br i1 %match1, label %catch.1, label %try.rethrow The expected class ids for each catch arm come from a precomputed subclass-closure table — the transitive subclass set of every
exception, built once by the compiler after type resolution. The dispatch
reads that closure for each catch’s declared type and OR-chains an icmp eq against every id in it, so:
catch e : ExceptionmatchesExceptionplus every transitive subclass.catch e : IOExceptionmatchesIOExceptionand its subclasses (FileNotFoundException,EOFException, …).- A throw raised inside a nested function call is caught just
the same — the polling model carries it up the call chain to the
enclosing
try, where the same class-id matching runs.
A multi-catch clause (catch e : X | Y) carries several types; the
dispatch unions the subclass closure of every member into one
id-set before building the compare chain, so the arm fires on any
id in either closure.
Dispatch is entirely compile-time class-id comparison: there is
no RTTI global, no landingpad, and no stack unwinding. Each id in
each closure is a constant baked into the IR.
finally
try { B } catch … finally { F } runs F on every path out of
the construct :
- Normal completion — the try-body fall-through and each catch
handler’s fall-through run
F, then continue past the construct. - A matched catch —
Fruns after the handler body. - The uncaught rethrow tail — when no catch matches,
Fruns inline (the pending exception survives in the slots) before the exception propagates to the outer try-scope or aborts. - An escaping transfer — any
return/break/continue/throwleaving the protected body runs the activefinallybodies inline before the transfer.
A finally that itself exits abruptly supersedes the original
transfer: once F returns / throws, the block is terminated and the
pending transfer is dropped. A throw inside F is not covered
by the same try’s catches (it runs outside the protected region), so
an uncaught throw there is a compile error (E0005).
Runtime ABI symbols
Every symbol name below is a stable runtime ABI constant — the compiler never types them as inline string literals.
| Symbol | Role |
|---|---|
__axle_throw | (value_ptr, class_id) — stashes the payload + class id into the thread-local exception slots and returns normally. No abort, no print. |
axle_native_throw | (class_name_ptr, message_ptr) — called from Rust-side native code and from the stdlib-exception abort path. The runtime resolves class_name → class_id and hands off to __axle_throw. |
axle_has_exception | i1 poll, emitted after every call inside a may-throw function. |
axle_current_exception_class_id | i32 — reads the pending exception’s class id without clearing the slot. The dispatch chain compares against it before committing to a handler. |
axle_take_exception | Consumes the thread-local slot ; returns the ptr payload and clears both slots so a call inside the handler (or a finally) doesn’t re-observe the same exception. |
axle_unhandled_exception | diverging — terminal abort (print + exit 1), emitted on the no-catch path of main. |
axle_register_exception_class | (class_name_ptr, class_id) — emitted at main’s prologue for every user-visible exception class, populating the name → id registry. |
There is no axle_is_subclass runtime helper. Every class id is
known at compile time and stamped into the IR directly ; the
dispatch chain is a plain i32 == expected_id compare OR-chained
over the subclass closure.
Limitations
| Limit | Why it holds |
|---|---|
An exception carries its message and nothing else | there is no cause chain, so a wrapper cannot point at the failure it wrapped |
| There is no stack trace and no unwinding | throw stashes a slot and returns normally; the model has no frames to walk |
An uncaught exception ends at main | it prints a diagnostic and exits with a non-zero code — no catch further out can exist |
| The dispatch is a linear compare chain per catch arm | each arm OR-chains an icmp eq over its type’s subclass closure rather than jumping through a table |
| An Axle exception must not cross a C frame | the runtime that carries it is the Axle runtime’s, not the C ABI’s |
See also
- Reading compiler errors — the user side of compile-time exception checking.
- Error handling — the patterns Axle programmers actually write.
- Compiler internals overview — index of all internals pages.
- Concept index — every exception concept on one page.