Axle v0.14.1

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: main calls axle_unhandled_exception() (print + exit 1) followed by unreachable, while any other function returns undef of 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 : Exception matches Exception plus every transitive subclass.
  • catch e : IOException matches IOException and 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 — F runs after the handler body.
  • The uncaught rethrow tail — when no catch matches, F runs 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 / throw leaving the protected body runs the active finally bodies 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.

SymbolRole
__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_exceptioni1 poll, emitted after every call inside a may-throw function.
axle_current_exception_class_idi32 — reads the pending exception’s class id without clearing the slot. The dispatch chain compares against it before committing to a handler.
axle_take_exceptionConsumes 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_exceptiondiverging — 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

LimitWhy it holds
An exception carries its message and nothing elsethere is no cause chain, so a wrapper cannot point at the failure it wrapped
There is no stack trace and no unwindingthrow stashes a slot and returns normally; the model has no frames to walk
An uncaught exception ends at mainit 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 armeach 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 framethe runtime that carries it is the Axle runtime’s, not the C ABI’s

See also

exceptionscodegenruntimeinternals