Section 01: C Personality Function
Context: Code Journey 3 identified that Ori’s LLVM codegen emits personality ptr @rust_eh_personality on every function containing invoke/landingpad. This symbol comes from Rust’s standard library panic infrastructure, making every AOT binary depend on Rust’s runtime. Ori needs its own personality function to be a standalone language.
The personality function is called by the platform unwinder (libunwind) during exception handling. It reads the Language-Specific Data Area (LSDA) — metadata compiled into the .eh_frame section by LLVM — and tells the unwinder what to do at each stack frame: run cleanup code, catch the exception, or continue unwinding.
Reference implementations:
- GCC
gcc_personality_v0.c(259 lines): Minimal C-only personality. Handles cleanup only (no catch). Simplest reference for LSDA parsing. - Rust
library/std/src/sys/personality/gcc.rs(341 lines): Full search + cleanup phases. Handles catch-all viattype_index == 0. Closest to what Ori needs. - Rust
library/std/src/sys/personality/dwarf/eh.rs(272 lines): DWARF LSDA parsing utilities (ULEB128, SLEB128, encoded pointer reader). Reusable patterns.
01.1 LSDA Parser and Personality Implementation
File(s): compiler/ori_rt/src/eh_personality.c (NEW)
The personality function must implement the Itanium EH ABI (_Unwind_Personality_Fn signature) and parse the DWARF LSDA to handle Ori’s two landing pad types.
Background: How LLVM Exception Handling Works
When Ori’s codegen emits invoke + landingpad, LLVM generates:
- Call-site table in
.eh_frame/.gcc_except_table: maps instruction ranges to landing pads - Action table: what each landing pad does (cleanup vs. catch, indexed by
ttype_index) - Type info table: exception type metadata (unused for catch-all)
During unwinding, the platform unwinder calls our personality function twice per frame:
- Phase 1 (Search): “Is there a handler here?” — return
_URC_HANDLER_FOUNDor_URC_CONTINUE_UNWIND - Phase 2 (Cleanup): “Run cleanup and/or install handler” — set registers, return
_URC_INSTALL_CONTEXT
Forced unwind (_UA_FORCE_UNWIND): When the unwinder is performing a forced unwind (e.g. pthread_cancel, longjmp-based cleanup), the personality must NOT install catch-all handler contexts. Catch-all landing pads jump into handler code that absorbs the exception (no resume), so installing them would swallow the forced unwind and hang the thread. During _UA_FORCE_UNWIND:
- Cleanup pads: install normally (they end with
resume, which continues unwinding) - Catch-all pads: skip entirely — return
_URC_CONTINUE_UNWINDin both phases; do NOT set the landing pad IP
This means: Phase 1 → never return _URC_HANDLER_FOUND; Phase 2 → only install cleanup pads. Rust’s personality does this same check.
Ori’s Landing Pad Types
Type 1: Cleanup (landingpad { ptr, i32 } cleanup)
- Generated for ARC cleanup (decrement RC on unwind)
- Followed by
resume(re-raise exception) - Action:
cs_action_entry == 0(no action record, pure cleanup) - Personality response: Phase 1 →
_URC_CONTINUE_UNWIND; Phase 2 → set LP and_URC_INSTALL_CONTEXT
Type 2: Catch-all (landingpad { ptr, i32 } catch ptr null)
- Generated for
catch()pattern and top-level exception handling - Catches any exception regardless of type
- Action:
ttype_index == 0in the action table (catch-all in Itanium ABI) - Personality response: Phase 1 →
_URC_HANDLER_FOUND; Phase 2 → set LP and_URC_INSTALL_CONTEXT - Forced unwind: If
actions & _UA_FORCE_UNWIND, skip entirely —_URC_CONTINUE_UNWINDin both phases (catch pads don’tresume, so installing them would swallow the unwind)
Implementation
-
Create
compiler/ori_rt/src/eh_personality.cwith the following components: (2026-03-03)ULEB128 decoder (~15 lines):
static uintptr_t read_uleb128(const uint8_t **p) { uintptr_t result = 0; int shift = 0; uint8_t byte; do { byte = **p; (*p)++; result |= (uintptr_t)(byte & 0x7F) << shift; shift += 7; } while (byte & 0x80); return result; }SLEB128 decoder (~20 lines):
static intptr_t read_sleb128(const uint8_t **p) { intptr_t result = 0; int shift = 0; uint8_t byte; do { byte = **p; (*p)++; result |= (intptr_t)(byte & 0x7F) << shift; shift += 7; } while (byte & 0x80); if ((shift < (int)(sizeof(intptr_t) * 8)) && (byte & 0x40)) result |= -(((intptr_t)1) << shift); return result; }Encoded pointer reader (~40 lines): Handle DWARF pointer encodings that LLVM may use in the LSDA:
DW_EH_PE_absptr(0x00) — absolute pointerDW_EH_PE_uleb128(0x01) — ULEB128DW_EH_PE_udata2/4/8(0x02/0x03/0x04) — fixed-width unsignedDW_EH_PE_pcrel(0x10) — PC-relativeDW_EH_PE_omit(0xFF) — no value present
LSDA header parser (~25 lines): Parse the header at the start of the LSDA:
byte: lp_start_encoding (usually DW_EH_PE_omit) byte: ttype_encoding (for type info table, may be omit) uleb128: ttype_base_offset (if ttype_encoding != omit) byte: call_site_encoding uleb128: call_site_table_lengthCall-site table walker (~30 lines): Linear scan through call-site entries. Critical: use
ip - 1(not raw IP) for matching. The unwinder provides the return address (instruction after the call), but the call-site table maps the calling instruction range. Subtracting 1 maps back into the caller’s range. Every production personality does this (GCC, Rust, libcxxabi).// IMPORTANT: adjust IP before matching uintptr_t ip = _Unwind_GetIP(context) - 1; for each entry: start = read_encoded(call_site_encoding) // range start (relative to function) length = read_encoded(call_site_encoding) // range length lpad = read_encoded(call_site_encoding) // landing pad offset (0 = no LP) action = read_uleb128() // action table index (0 = cleanup only) if ip is in [func_start+start, func_start+start+length): this is our entryAction classifier (~15 lines):
// action == 0 → cleanup only (no action record) // action > 0 → read action table at (action_table + action - 1) // ttype_index from SLEB128: // == 0 → catch-all // > 0 → catch specific type (not needed for Ori MVP) // < 0 → exception spec filter (not needed for Ori MVP)Main personality function (~30 lines):
_Unwind_Reason_Code ori_eh_personality( int version, _Unwind_Action actions, uint64_t exception_class, struct _Unwind_Exception *exception_object, struct _Unwind_Context *context) { // 0. Version gate: if (version != 1) return _URC_FATAL_PHASE1_ERROR // 1. Get LSDA pointer from context; if NULL return _URC_CONTINUE_UNWIND // 2. Parse LSDA header // 3. Get current IP via _Unwind_GetIP(context) - 1 (return addr → call site) // find matching call-site entry // 4. Classify action (cleanup vs catch-all) // 5. Phase 1 (search): // - catch-all AND NOT _UA_FORCE_UNWIND → HANDLER_FOUND // - catch-all AND _UA_FORCE_UNWIND → CONTINUE_UNWIND (skip — no resume) // - cleanup only → CONTINUE_UNWIND // 6. Phase 2 (cleanup): // - cleanup pad: set GR[0]=exception, GR[1]=selector, IP=landing_pad // → INSTALL_CONTEXT (pad will run ARC cleanup then `resume`) // - catch-all pad (normal unwind only, never forced): // set GR[0]=exception, GR[1]=selector, IP=landing_pad // → INSTALL_CONTEXT (pad will jump to handler code) // - catch-all pad AND _UA_FORCE_UNWIND → CONTINUE_UNWIND (never install) }Register setup (inside phase 2):
_Unwind_SetGR(context, __builtin_eh_return_data_regno(0), (uintptr_t)exception_object); _Unwind_SetGR(context, __builtin_eh_return_data_regno(1), (uintptr_t)selector); _Unwind_SetIP(context, landing_pad); return _URC_INSTALL_CONTEXT; -
Include
<unwind.h>for Itanium EH ABI types (_Unwind_Context,_Unwind_Exception,_Unwind_Action, etc.) (2026-03-03) -
Mark function with
__attribute__((used))to prevent dead-code elimination by the C compiler, and ensure it’s exported from the static library. (2026-03-03) -
Add a header comment explaining: this is Ori’s exception handling personality function, it handles cleanup and catch-all only, and references the Itanium EH ABI spec. (2026-03-03)
01.2 Build System Integration
File(s): compiler/ori_rt/build.rs (NEW), compiler/ori_rt/Cargo.toml (MODIFY)
The C file must be compiled and linked into libori_rt.a. The standard way to do this in Rust is via the cc crate in a build script.
-
Add
ccas a build dependency incompiler/ori_rt/Cargo.toml: (2026-03-03)[build-dependencies] cc = "1" -
Create
compiler/ori_rt/build.rs: (2026-03-03)fn main() { let mut build = cc::Build::new(); build .file("src/eh_personality.c") .flag("-std=c11") .warnings(true) .extra_warnings(true); // These flags are standard on GCC/Clang but may not exist on all // toolchains. flag_if_supported avoids hard failures on exotic compilers. build.flag_if_supported("-fno-exceptions"); build.compile("ori_eh"); // produces libori_eh.a, merged into final lib }Notes:
-fno-rttiis omitted — it’s a C++ flag, not applicable to C code.-fno-exceptionsusesflag_if_supportedfor cross-toolchain portability.
The
cccrate compiles the C file into a static archive and tells Cargo to link it. When Cargo buildslibori_rt.a(staticlib), the C object gets bundled in. -
Verify that both build outputs contain the symbol: (2026-03-03)
libori_rt.a(staticlib for AOT):nm target/debug/libori_rt.a | grep ori_eh_personalitylibori_rt.rlib(for JIT): symbol available via Cargo linking
01.3 JIT Symbol Bridge
File(s): compiler/ori_rt/src/lib.rs (MODIFY)
The JIT execution engine needs to find ori_eh_personality at runtime. Since the C function is compiled into the same library, we need a Rust-side way to get its address.
-
Add an
extern "C"declaration and address-getter inori_rt/src/lib.rs: (2026-03-03)extern "C" { /// Ori's Itanium EH ABI personality function (implemented in eh_personality.c). /// Required by any LLVM function containing `invoke`/`landingpad`. fn ori_eh_personality(); } /// Get the address of `ori_eh_personality` for JIT symbol mapping. /// /// The personality function is implemented in C (`src/eh_personality.c`) and /// compiled into this library. This function provides its address so the /// LLVM MCJIT engine can resolve the symbol. #[must_use] pub fn ori_eh_personality_addr() -> usize { ori_eh_personality as *const () as usize }This follows the exact same pattern as the existing
rust_eh_personality_addr()inevaluator/runtime_mappings.rs, but moves the address resolution toori_rtwhere the symbol lives. -
Verify the function is accessible from
ori_llvmviaruntime::ori_eh_personality_addr(). (2026-03-03)
01.4 Forced-Unwind Test Harness
File(s): compiler/ori_rt/src/test_forced_unwind.c (NEW), compiler/ori_rt/src/test_frames_x86_64.S (NEW), compiler/ori_rt/src/test_frames_aarch64.S (NEW), compiler/ori_rt/build.rs (MODIFY), compiler/ori_rt/tests/forced_unwind.rs (NEW)
A C + assembly test that verifies ori_eh_personality correctly handles _UA_FORCE_UNWIND — catch-all pads are skipped, cleanup pads still run, and the unwind completes without hanging.
Target scope: x86_64-unknown-linux-* and aarch64-unknown-linux-*. Per-architecture assembly stubs use native registers and calling conventions, but share the same DWARF CFI directives (.cfi_personality, .cfi_lsda) and ELF LSDA format (.section .gcc_except_table). The C harness and Rust test file are architecture-independent. build.rs selects the correct .S file based on CARGO_CFG_TARGET_ARCH. Non-Linux targets (macOS Mach-O) are deferred — they use different section names and would need a third variant.
Why C + assembly, not Ori: Forced unwind is triggered by _Unwind_ForcedUnwind, a C API not exposed to Ori. Attaching ori_eh_personality to specific test frames requires .cfi_personality directives in assembly — the standard DWARF CFI mechanism supported by all ELF assemblers. This avoids depending on recent compiler extensions (__attribute__((personality(...))) requires GCC 14+/Clang 18+).
Architecture differences:
| x86-64 | aarch64 | |
|---|---|---|
| First arg register | %rdi | x0 |
| Return value register | %eax/%rax | w0/x0 |
| Frame pointer | %rbp | x29 |
| Link register | (on stack via call) | x30 (aka lr) |
| Indirect call | callq *%rdi | blr x0 |
| PC-relative global | symbol(%rip) | adrp + str |
| Exception object in LP | %rax (from _Unwind_SetGR → data reg 0) | x0 (data reg 0) |
| Resume call | callq _Unwind_Resume@PLT | bl _Unwind_Resume |
The LSDA format (.gcc_except_table section) is identical on both architectures — it’s DWARF, not architecture-specific.
-
Create
compiler/ori_rt/src/test_frames_x86_64.S: x86-64 assembly stubs with PIC-compatible personality encoding (0x9b=DW_EH_PE_indirect | DW_EH_PE_pcrel | DW_EH_PE_sdata4),DW.ref.ori_eh_personalityCOMDAT section, and CFI restore directives at landing pads. (2026-03-03) -
Create
compiler/ori_rt/src/test_frames_aarch64.S: aarch64 equivalent with AAPCS64 calling convention, same PIC encodings and COMDAT section. (2026-03-03) -
Create
compiler/ori_rt/src/test_forced_unwind.c: C harness with_Unwind_ForcedUnwind+setjmp/longjmpescape, CFA-based frame detection, and_UA_END_OF_STACKsafety net. (2026-03-03)Key implementation notes (deviations from plan):
- Assembly uses PIC encoding
0x9b(not0) for.cfi_personality— required for PIE executables - Landing pads have
.cfi_def_cfarestore directives — CFI must describe prologue state for_Unwind_Resumeto work - Stop function handles
_UA_END_OF_STACKas safety net alongside CFA comparison
- Assembly uses PIC encoding
-
Create
compiler/ori_rt/src/test_forced_unwind.c(~80 lines):C harness that sets up the exception object, triggers forced unwind, and checks results.
#include <unwind.h> #include <stdint.h> #include <string.h> #include <setjmp.h> // Defined in test_frames_{x86_64,aarch64}.S extern int catch_handler_entered; extern int cleanup_handler_entered; extern int frame_with_catch_all(void (*trigger)(void)); extern int frame_with_cleanup(void (*trigger)(void)); // Non-local escape. The stop callback longjmps when the unwinder // reaches the target frame (identified by CFA >= saved frame pointer), // BEFORE that frame is unwound — so the setjmp context is still valid. static jmp_buf escape_buf; static uintptr_t escape_target_fp; static struct _Unwind_Exception test_exc; // Exception cleanup callback (required by _Unwind_Exception contract) static void exc_cleanup(_Unwind_Reason_Code reason, struct _Unwind_Exception *exc) { (void)reason; (void)exc; } // Stop function: allows unwinding through inner frames. When the // unwinder reaches the frame that called setjmp, longjmp escapes // before that frame is destroyed. // // Frame detection: the stop function is called BEFORE each frame // is unwound, with _Unwind_GetCFA(ctx) giving that frame's CFA. // The stack grows downward, so CFA increases as we unwind outward. // Inner frames (trigger, assembly) have CFA < test function's FP. // The test function's CFA = FP + 2*sizeof(void*) on both x86-64 // and aarch64, so CFA >= FP fires exactly when the unwinder reaches // the test function's frame — while it is still live. static _Unwind_Reason_Code force_unwind_stop( int version, _Unwind_Action actions, uint64_t exception_class, struct _Unwind_Exception *exc, struct _Unwind_Context *ctx, void *stop_parameter) { if (_Unwind_GetCFA(ctx) >= escape_target_fp) { longjmp(escape_buf, 1); } return _URC_NO_REASON; } static void trigger_forced_unwind(void) { memset(&test_exc, 0, sizeof(test_exc)); test_exc.exception_cleanup = exc_cleanup; _Unwind_ForcedUnwind(&test_exc, force_unwind_stop, NULL); __builtin_unreachable(); // _Unwind_ForcedUnwind does not return } // Returns 0 on success: catch-all handler was NOT entered int test_forced_unwind_skips_catch(void) { catch_handler_entered = 0; escape_target_fp = (uintptr_t)__builtin_frame_address(0); if (setjmp(escape_buf) == 0) { frame_with_catch_all(trigger_forced_unwind); __builtin_unreachable(); } // Reached via longjmp — unwind stopped at our frame (still live) return catch_handler_entered; // 0 = pass, 1 = fail } // Returns 0 on success: cleanup handler WAS entered int test_forced_unwind_runs_cleanup(void) { cleanup_handler_entered = 0; escape_target_fp = (uintptr_t)__builtin_frame_address(0); if (setjmp(escape_buf) == 0) { frame_with_cleanup(trigger_forced_unwind); __builtin_unreachable(); } // Reached via longjmp — unwind stopped at our frame (still live) return cleanup_handler_entered ? 0 : 1; // 0 = pass, 1 = fail }Key details:
- CFA-based frame detection: The stop callback must
longjmpwhen the unwinder reaches thesetjmpframe, not at_UA_END_OF_STACK(where the frame is already destroyed). Each test function saves its frame pointer via__builtin_frame_address(0)intoescape_target_fp. The stop callback compares_Unwind_GetCFA(ctx)against this value — on both x86-64 and aarch64, the CFA of the target frame (FP + 2*sizeof(void*)) is always>= FP, while all inner frames haveCFA < FP(stack grows down). The stop callback is called BEFORE each frame is unwound, so at the match point thesetjmpcontext is still live andlongjmpis well-defined. - Cleanup sequencing is preserved: For the cleanup test, the stop callback returns
_URC_NO_REASONfor the assembly frame (allowing the personality to install the cleanup pad), the cleanup runs and sets the flag,_Unwind_Resumecontinues the forced unwind, and THEN the stop callback matches the test function’s frame and longjmps — so the flag is already set when we check it. test_excis zero-initialized withmemset, thenexception_cleanupis set to a no-op callback. The_Unwind_Exceptioncontract requires this field — without it, the unwinder may call through a null pointer when the exception is “caught” or the unwind completes.test_forced_unwind_skips_catchreturns 0 (pass) if the catch-all landing pad was never entered.test_forced_unwind_runs_cleanupreturns 0 (pass) if the cleanup landing pad was entered.
- CFA-based frame detection: The stop callback must
-
Update
build.rsto compile test files for supported architectures: (2026-03-03)fn main() { // --- Personality function (always built, all targets) --- let mut build = cc::Build::new(); build .file("src/eh_personality.c") .flag("-std=c11") .warnings(true) .extra_warnings(true); build.flag_if_supported("-fno-exceptions"); build.compile("ori_eh"); // --- Forced-unwind test harness (Linux x86-64 + aarch64) --- // Per-arch assembly stubs; C harness is arch-independent. // Always compiled (not #[cfg(test)] — that gates build-script-self-tests, // not `cargo test -p ori_rt`). Linker dead-strips unreferenced symbols. let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "linux" { let asm_file = match target_arch.as_str() { "x86_64" => Some("src/test_frames_x86_64.S"), "aarch64" => Some("src/test_frames_aarch64.S"), _ => None, }; if let Some(asm) = asm_file { cc::Build::new() .file("src/test_forced_unwind.c") .file(asm) .flag("-std=c11") .warnings(true) .compile("ori_eh_test"); } } }Target gating:
build.rsreadsCARGO_CFG_TARGET_ARCHandCARGO_CFG_TARGET_OS(set by Cargo for the target platform, not the host). On Linux, it selects the architecture-specific assembly file. On non-Linux or unsupported architectures, the test harness is skipped entirely.#[cfg(test)]inbuild.rswould not work because it gates build-script-self-tests, notcargo test -p ori_rt. -
Create unit test module in
compiler/ori_rt/src/lib.rs(moved from integration test — unit tests see native symbols fromccbuild): (2026-03-03)//! Tests that ori_eh_personality handles _UA_FORCE_UNWIND correctly. //! //! Linux only (x86-64 + aarch64) — the test frames use per-arch assembly //! stubs with ELF-specific directives. Skipped on other targets via #[cfg]. //! //! These tests call C/assembly test frames (compiled from test_forced_unwind.c //! and test_frames_{x86_64,aarch64}.S) that use ori_eh_personality with //! hand-written LSDA entries. _Unwind_ForcedUnwind is triggered through frames //! with catch-all and cleanup landing pads to verify the personality does not //! install catch handlers during forced unwind, while still running cleanup pads. #![cfg(all( target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") ))] extern "C" { fn test_forced_unwind_skips_catch() -> i32; fn test_forced_unwind_runs_cleanup() -> i32; } /// Single test function to avoid data races on shared C globals /// (`catch_handler_entered`, `cleanup_handler_entered`). /// /// Rust's default test runner executes #[test] functions in parallel. /// The C test harness uses unsynchronized global flags written by /// assembly landing pads, so concurrent execution would race. /// A single test function serializes both checks naturally. #[test] fn forced_unwind_personality_behavior() { // Catch-all pads must NOT be installed during forced unwind let result = unsafe { test_forced_unwind_skips_catch() }; assert_eq!(result, 0, "catch-all handler should not run during forced unwind"); // Cleanup pads MUST still run during forced unwind let result = unsafe { test_forced_unwind_runs_cleanup() }; assert_eq!(result, 0, "cleanup pads should still run during forced unwind"); }The
#![cfg(...)]at the crate level ensures the entire test file is skipped on non-matching targets. This matches thebuild.rsgate — both accept(x86_64 | aarch64) + linux. On skipped targets,cargo test -p ori_rtreports 0 tests from this file (not a failure).ARM testing: Run
cargo test -p ori_rton a native aarch64 Linux runner (local hardware, CI arm64 worker, or cloud VM). Cross-compilation alone is insufficient; the assembly stubs must execute natively.
01.5 Completion Checklist
-
eh_personality.cexists incompiler/ori_rt/src/and compiles without warnings (2026-03-03) -
build.rsusescccrate to compile the C file (2026-03-03) -
nm target/debug/libori_rt.a | grep ori_eh_personalityreturns the symbol (T = text section) (2026-03-03) -
ori_eh_personality_addr()is exported fromori_rtand callable fromori_llvm(2026-03-03) -
cargo build -p ori_rtsucceeds (both rlib and staticlib) (2026-03-03) - No new Clippy warnings in
ori_rt(2026-03-03) -
cargo test -p ori_rtpasses forced-unwind tests on x86-64 Linux (catch skipped, cleanup ran) (2026-03-03) -
cargo test -p ori_rtpasses forced-unwind tests on aarch64 Linux (native execution required; cross-compilation alone is insufficient) - Unsupported targets (non-Linux or unsupported arch): forced-unwind tests cleanly skipped (0 tests, not failure) —
#[cfg]gate on unit test module (2026-03-03)
Exit Criteria: ori_eh_personality symbol is present in both libori_rt.a and the rlib, and its address is obtainable via ori_rt::ori_eh_personality_addr(). The function implements correct LSDA parsing for cleanup and catch-all landing pads per the Itanium EH ABI. On x86-64 and aarch64 Linux: forced-unwind tests verify catch-all pads are skipped and cleanup pads still run under _UA_FORCE_UNWIND. On unsupported targets (non-Linux or unsupported arch): forced-unwind tests are skipped cleanly. Verified via nm, cargo build -p ori_rt, and cargo test -p ori_rt.