Clause
← All documentation

Generated-code ABI (revision 4)

Private contract between compiler output and the runtime. It is project-internal C++23, not BEAM-compatible and not a general FFI. Objects and runtime must come from the same build; older descriptor revisions are rejected before use.

Headers: v1.hpp (term/context/function types), term.hpp (immediate integer codec), status.hpp, builtins.hpp, startup.hpp (program startup).

Terms

A term is one unsigned target-pointer-width word (32 or 64 bits). Layouts derive from the configured LLVM target, so cross-target code uses target widths.

Functions and symbols

Entry signature (native C calling convention, no C linkage required):

TermWord function(ProcessContext *context, const TermWord *arguments);

Arguments are a borrowed, word-aligned array in source order (null at arity 0). The context is live and passed unchanged through calls. A returned word is usable only after checking the failure channel.

Symbols: clausev1_<hex module>_<hex function>_<arity>, lowercase hex of UTF-8 bytes, canonical decimal arity; reversible and host-independent. Exported entries are external, others internal.

Module registration

Each module emits clausev1_<hex module>__0.descriptor and .register. The descriptor holds ABI version, term width, export table (name, arity, host entry and the FrameDescriptor dynamic calls enter), atom spellings (UTF-8 pointer/size pairs), native record descriptors (module, name and field atom slots, export flag; native records) in an external <prefix>.records table that other modules of a batch reference, and fun descriptors in a private <prefix>.funs table (funs). Registration calls CLAUSE_register_module_v4(Runtime*), which validates version/width and all exports, interns atoms, builds a frozen registry and publishes it with the code image in one transaction. Duplicate modules never replace code; any failure publishes nothing (already interned atoms stay in the bounded table).

Failure channel (revision 2)

Errors are not encoded in term bits. After every non-tail generated call the caller checks CLAUSE_call_failed_v2(context) before using the result or evaluating the next argument. On failure the callee returns an invalid zero word.

OutcomeTransport
Pattern mismatch, guard rejectionContinuation to next candidate; channel untouched
Exhausted clauseserror:function_clause; error:{case_clause, Value} with owned payload for a case; error:if_clause; error:{try_clause, Value} for a try's of clauses; error:{else_clause, Value} for a maybe's else clauses
Body match failureerror:{badmatch, Value} with owned payload
Comprehensionserror:{bad_generator, Tail}, error:{bad_filter, Value}, error:{bad_generators, Inputs} (ErrorReason 16-18); a strict generator's rejection is {badmatch, Element}
Record access, bad arguments, arithmetic, mapsbadrecord, badarg, badarith, badmap/badkey
Native record field missingErrorReason::badfield (20), payload {{Module, Name}, Field}
Calling a value (F(Args))ErrorReason::badfun (22, payload the value), badarity (23, payload {Fun, Args}), undef (24), recorded by CLAUSE_apply_v1 (funs)
Dynamic calls (M:F(Args), apply/2,3, fun M:F/A with variables)badarg for a non-atom module or function, an improper argument list or an invalid arity; undef when no module of the program exports the function; badfun/badarity as above (funs)
External native construction without a valueErrorReason::novalue (21), payload {{Module, Name}, Field}
Integer result past the size limitService outcome ValueOutcome::system_limit (3): a guard rejects, a body raises error:system_limit (ErrorReason 19)
Invalid lazy left operand{badarg, Value}
Infrastructure (OOM, limits, ownership, internal)CallError::runtime_failure with exact Status
erlang:error/1,2,3, exit/1, throw/1, erlang:raise/3raised_error/raised_exit/raised_throw: class from the ID, owned payload is the whole reason
erlang:halt/0,1CallError::halted with halt_status (and slogan)

Reasons are typed IDs recorded by CLAUSE_raise_v2; the three raised_* IDs select class exit or throw (otherwise error) and carry any term as the reason. error/2,3 raise through CLAUSE_error_v1(context, reason, args), which also keeps a list args for the top stack frame. First failure wins; nested invocations share the channel. GeneratedInvocation is the host scope: it checks pending failures before entry and after return, copies result or error, and clears only at the outermost exit (also on C++ exceptions). No exception crosses generated entries. Raw entry callers must open a GeneratedInvocation; normal hosts use ResolvedFunction::call.

catch Expr redirects every failure check and raise inside Expr to a handler block that calls CLAUSE_catch_v1(context, slot). For a pending Erlang exception it writes the catch value to the root slot and clears the channel: the thrown term, {'EXIT', Reason} for an exit, or {'EXIT', {Reason, []}} for an error (typed reasons become their OTP terms such as {badmatch, V}; the stack is described below). Halts and infrastructure failures stay pending, and the handler's own check continues to the enclosing handler or function exit. Bindings made inside Expr are unsafe afterwards, so the join only merges the value.

try Body of ... catch ... end protects only Body the same way. Its handler calls CLAUSE_exception_v2(context, class_slot, reason_slot, stack_slot), which writes the class atom (error, exit or throw), the reason and the stack trace term to root slots and clears the channel (halts and infrastructure failures stay pending as for catch). Catch clauses then match Class:Reason with ordinary patterns and guards; an omitted class matches throw, and a named stack variable binds the stack term. When none matches, CLAUSE_reraise_v2(context, class, reason, stack) records the exception again with a raised_* reason and the same stack, which reports and catches exactly like the original. of clauses select on the body value and raise {try_clause, Value} (ErrorReason::try_clause = 14); exceptions inside of clauses and handlers go to the enclosing handler.

maybe needs no service: each ?= is an ordinary match whose mismatch edge leaves the body for the maybe's exit with the unmatched (already rooted) value. Without else that value is the result; otherwise else clauses select on it like case clauses and raise {else_clause, Value} (ErrorReason::else_clause = 15) when none matches.

Comprehensions use the existing services with a few operations: the accumulated elements are reversed by the construction reverse (ContainerConstruction::reverse = 2, values {List, Tail}); a binary comprehension joins its pieces with BitOperation::concat ({List}) and a map comprehension builds its map with MapOperation::from_list ({Pairs}, later keys win). Map generators read MapOperation::key_at/value_at ({Map, Position} in canonical key order) and show a zip's remaining input with MapOperation::iterator, OTP's {K, V, Next} chain ending in none. Bitstring generators use ordinary pattern extraction plus a final binary/all segment for the rest.

try ... after A end adds a second protection around the body and all of and catch clauses. On the normal path A runs after the selected value is rooted and its value is discarded. The after handler takes the exception with CLAUSE_exception_v2, runs a second copy of A and re-raises with CLAUSE_reraise_v2; an exception or failure inside A leaves through the enclosing handler instead, replacing the original. Halts and infrastructure failures skip A. Root slots belong to the function frame, so every path releases them at the function exit.

Stack traces

Each root frame names its generated function with a private abi::v1::FrameDescriptor (module descriptor, module and function name atom slots, arity). When an Erlang exception is recorded, the channel copies the innermost 8 named frames (BEAM's default backtrace_depth); the term [{Module, Function, Arity, []}, ...] is built only when a handler, catch or report asks for it. The top frame shows the error/2,3 argument list instead of the arity when that argument is a list.

erlang:raise(Class, Reason, Stack) (CLAUSE_reraise_v2) accepts the stacks BEAM accepts: a proper list of {M, F, A} (completed with a [] location) or {M, F, A, Location} with atom M, F and a list Location, cut to 8 entries; the stack is then kept as given and frames are no longer captured. An invalid class or stack records nothing and the call evaluates to badarg, as in OTP. erlang:get_stacktrace/0 is rejected with OTP 29's "removed" lint text.

Differences from OTP, all visible only in the stack term:

Frames and transfers

Generated functions run on explicit frames (execution model, step 19; services in frames.hpp). Each function has a <symbol>.frame descriptor (FrameDescriptor: module descriptor, module and function atom slots, arity, body code, slot count, term slot count; external for exported functions) and an internal <symbol>.body of type void(void *context). An exported <symbol> keeps the TermWord(Context *, const TermWord *) signature as a host entry calling CLAUSE_invoke_v1(context, frame, arguments).

Runtime services

Generated code calls checked C++ services: CLAUSE_exact_v1 (exact equality), CLAUSE_immediate_v1 (immediate predicates/queries), CLAUSE_construct_v1, CLAUSE_inspect_v1, CLAUSE_integer_v1, CLAUSE_float_v1, CLAUSE_map_v1, CLAUSE_bits_v1, CLAUSE_record_v1 (native record make/get/update/match/test under a RecordCheck; outcomes bad_record, bad_field, no_match), CLAUSE_make_fun_v1 (build a fun of a FunDescriptor). Each returns success, semantic error (badarg/badarith/...) or infrastructure failure and writes output only on success. CLAUSE_display_v1 (output.hpp) prints one erlang:display/1 line and yields true; it has no semantic error. CLAUSE_halt_v1 never succeeds: it records a halt request (CallError::halted with the exit status) or badarg, so the caller unwinds. Linker spellings follow the target's Itanium or Microsoft C++ mangling.

CLAUSE_builtin_frame_v1(context, builtin) (builtins.hpp) returns the FrameDescriptor of the production builtin with index builtin in abi::v1::bridge_builtins (append-only); generated code enters it like a function with the arguments in the registers, and errors and failures go to the checked channel (builtins). A FrameDescriptor with a null body is a builtin: entering it runs the builtin on the registers and returns into the caller, or suspends the process at the builtin's continuation when it trapped (portions).

abi::v1::dispatch_builtin calls host-registered builtins by module/function bytes, argument array and arity, returning a Status; output is written only on success.

StatusValueMeaning
ok0Success
not_implemented1Known deferred feature, reported once
invalid_argument2Invalid input
diagnostic_failure3Report delivery failed
out_of_memory4Allocation failed, state rolled back
busy5Live contexts or running dispatch
wrong_owner6Object belongs to another runtime/context
resource_limit7Cap or budget exhausted
stopped8Owner shut down
abi_mismatch9Version or term width differs
internal_error10Unexpected failure contained
unknown_builtin11Signature not registered or catalogued
erlang_error12Structured Erlang error recorded
output_failure13Standard output rejected a write

Revisions

RevisionChange
1Initial descriptors and entries
2Checked failure channel
3Atom spellings and slots in descriptors
4Mandatory generated root scopes
5Explicit process frames and transfers
6Native record descriptors in module descriptors, CLAUSE_record_v1
7Fun descriptors in module descriptors, CLAUSE_make_fun_v1, CLAUSE_apply_v1
8Export descriptors name their FrameDescriptor; dynamic call services CLAUSE_call_v1, CLAUSE_apply_list_v1, CLAUSE_call_list_v1, CLAUSE_make_external_fun_v1