relon_codegen_llvm/state.rs
1//! Minimal runtime state for the LLVM AOT backend's buffer-protocol
2//! entries. **Phase B.**
3//!
4//! The buffer-protocol entry signature mirrors the cranelift-native
5//! backend's `EntryShape::BufferProtocol`:
6//!
7//! ```text
8//! fn run_main(state: *const SandboxState,
9//! in_ptr: i32, in_len: i32,
10//! out_ptr: i32, out_cap: i32,
11//! caps: i64) -> i32;
12//! ```
13//!
14//! `LoadField` / `StoreField` ops resolve to absolute host addresses
15//! through the formula `arena_base + buf_ptr + offset`, where
16//! `arena_base` lives at a stable offset on the state. The LLVM
17//! emitter loads it through a `ptrtoint`/`inttoptr` round-trip.
18//!
19//! We **do not** reuse `relon_codegen_cranelift::SandboxState` here on
20//! purpose:
21//!
22//! - It would require pulling cranelift-native as a hard dependency of
23//! the LLVM crate just to share an opaque struct layout. The LLVM
24//! backend is meant to stand on its own.
25//! - The LLVM backend keeps its sandbox state local: arena bounds,
26//! capability trap codes, host-fn dispatch, and the step-budget fuel
27//! live in this C-layout `ArenaState` instead of depending on the
28//! cranelift crate's `SandboxState`.
29//! - Keeping the layout local to this crate makes the offsets we
30//! embed in emitted LLVM IR self-contained — if the cranelift
31//! crate ever rearranges `SandboxState` it cannot accidentally
32//! miscompile our IR.
33//!
34//! Phase C (when sandbox traps + closures land) is the right time to
35//! revisit the dep direction; for Phase B this stays self-contained.
36
37use std::cell::UnsafeCell;
38use std::collections::HashMap;
39use std::sync::Arc;
40
41use relon_eval_api::{NativeArgs, NativeFnCaps, RelonFunction, RuntimeError, Value};
42use relon_parser::TokenRange;
43
44/// Per-call arena state handed to the LLVM JIT-compiled entry. The
45/// emitter reads `arena_base` (at offset 0 on a 64-bit host) and
46/// `arena_len` (offset 8) to resolve every buffer-protocol load /
47/// store; everything past those two fields is reserved for Phase C
48/// (sandbox traps, deadline, closure table).
49///
50/// `#[repr(C)]` because the LLVM emitter hard-codes the field
51/// offsets through `inttoptr(arena_base_ptr + N)` style address
52/// arithmetic.
53///
54/// `UnsafeCell` on the live fields because the JIT thread mutates
55/// them through a raw pointer; Rust's borrow checker cannot see the
56/// emitted machine code. The per-call ownership model (one
57/// `ArenaState` per `run_main` dispatch) means no aliasing race
58/// can occur — the LLVM evaluator allocates a fresh state on the
59/// stack before each call.
60///
61/// ## Phase 0b: native-call dispatch
62///
63/// `host_fns` + `trap_code` mirror the cranelift backend's
64/// `SandboxState` so the LLVM JIT path can dispatch a source-lowered
65/// `Op::CallNative` through the host-fn registry the same way (see
66/// [`relon_llvm_call_native`]). `host_fns` is a raw pointer (not an
67/// `Arc` slot) because the registry is owned by the evaluator and
68/// outlives every per-call state; the emitter loads it by offset and
69/// hands it back to the helper verbatim. `0` (null) means "no
70/// registry installed" — a `CallNative` then records
71/// [`NativeTrap::HostFnMissing`] in `trap_code`.
72#[repr(C)]
73pub struct ArenaState {
74 /// Base pointer of the arena bytes the host owns. The emitted
75 /// LLVM IR reads this through `load i64, ptr %state` (offset 0),
76 /// then `inttoptr` to a byte pointer + i64-extended `buf_ptr` +
77 /// `field_offset`. The pointer is `usize`-wide so the cast
78 /// matches the host's pointer width.
79 pub arena_base: UnsafeCell<usize>,
80 /// Length of the arena in bytes. The LLVM emitter uses this for
81 /// arena-relative bounds guards before forming host pointers.
82 pub arena_len: UnsafeCell<u32>,
83 /// Phase E.1: tail cursor used by pointer-indirect StoreField
84 /// (`String` / `ListInt` / `ListFloat` / `ListBool`) to bump-
85 /// allocate records inside the output buffer's tail region.
86 /// Counts buffer-relative bytes from `out_ptr`. Reset to 0 at the
87 /// start of every dispatch.
88 pub tail_cursor: UnsafeCell<u32>,
89 /// Phase E.1: scratch bump cursor used by stdlib bodies (`concat`,
90 /// `substring`, ...) and `Op::StrConcatN` to allocate temporary
91 /// records inside the arena's scratch region. Counts bytes from
92 /// `scratch_base`. Reset to 0 per dispatch.
93 pub scratch_cursor: UnsafeCell<u32>,
94 /// Phase E.1: arena-relative byte offset at which the scratch
95 /// region starts (= `out_ptr + out_cap`). The bump path reads
96 /// `scratch_base + scratch_cursor` as the i32 pointer returned to
97 /// the stdlib body.
98 pub scratch_base: UnsafeCell<u32>,
99 /// Phase 0b: trap code recorded by [`relon_llvm_call_native`] on a
100 /// failed dispatch (host-fn missing / host-fn error / unsupported
101 /// arg shape). `0` = no trap. The `Op::CallNative` lowering loads
102 /// this right after the helper returns and routes a non-zero value
103 /// to an `llvm.trap`. Mirrors `SandboxState::trap_code`.
104 pub trap_code: UnsafeCell<u64>,
105 /// Phase 0b: raw pointer to the host-fn registry installed by the
106 /// evaluator before dispatch. Null when no registry was supplied.
107 /// The emitter loads this word and hands it to the helper; the
108 /// helper re-derives `&HostFnRegistry`. Lives outside the
109 /// `#[repr(C)]` codegen-visible prefix only through its offset —
110 /// it is a plain pointer-width field the JIT never dereferences
111 /// directly (only the helper does, on the Rust side).
112 pub host_fns: UnsafeCell<usize>,
113 /// Remaining loop/entry budget for the current dispatch. `0`
114 /// means "unlimited"; positive values are decremented by the LLVM
115 /// emitter at the entry prologue and loop headers; negative values
116 /// trap `ResourceExhausted`.
117 pub step_budget: UnsafeCell<i64>,
118}
119
120/// Byte offset of [`ArenaState::arena_base`] inside the `#[repr(C)]`
121/// layout. Used by the LLVM emitter to materialise the load.
122pub const ARENA_STATE_OFFSET_BASE: u32 = 0;
123
124/// Byte offset of [`ArenaState::arena_len`]. The LLVM emitter reads it
125/// before arena-relative host-pointer formation.
126pub const ARENA_STATE_OFFSET_LEN: u32 = std::mem::size_of::<usize>() as u32;
127
128/// Byte offset of [`ArenaState::tail_cursor`]. The pointer-indirect
129/// StoreField path loads and stores this u32 to bump-allocate the
130/// output buffer's tail region.
131pub const ARENA_STATE_OFFSET_TAIL_CURSOR: u32 = ARENA_STATE_OFFSET_LEN + 4;
132
133/// Byte offset of [`ArenaState::scratch_cursor`]. Loaded / stored by
134/// the `Op::AllocScratch` / `Op::AllocScratchDyn` lowering.
135pub const ARENA_STATE_OFFSET_SCRATCH_CURSOR: u32 = ARENA_STATE_OFFSET_TAIL_CURSOR + 4;
136
137/// Byte offset of [`ArenaState::scratch_base`]. Loaded by the scratch
138/// allocator to compute the arena-relative offset of a freshly-
139/// reserved scratch block (`scratch_base + scratch_cursor`).
140pub const ARENA_STATE_OFFSET_SCRATCH_BASE: u32 = ARENA_STATE_OFFSET_SCRATCH_CURSOR + 4;
141
142/// Byte offset of [`ArenaState::trap_code`]. The three trailing u32
143/// fields (`arena_len`, `tail_cursor`, `scratch_cursor`,
144/// `scratch_base`) total 16 bytes past `arena_base`; the `u64`
145/// `trap_code` follows on its natural 8-byte boundary. The
146/// `Op::CallNative` lowering reads / writes this offset; a runtime
147/// assert in [`ArenaState`]'s test module pins the layout.
148pub const ARENA_STATE_OFFSET_TRAP_CODE: u32 = 24;
149
150/// Byte offset of [`ArenaState::host_fns`]. The `usize`-wide registry
151/// pointer follows `trap_code` on its natural boundary. Only the Rust
152/// helper [`relon_llvm_call_native`] dereferences this field (via
153/// `state.host_fns.get()`), so the emitter never materialises the
154/// offset — it exists for the layout assertion + documentation.
155#[allow(dead_code)]
156pub const ARENA_STATE_OFFSET_HOST_FNS: u32 = ARENA_STATE_OFFSET_TRAP_CODE + 8;
157
158/// Byte offset of [`ArenaState::step_budget`]. Appended after the
159/// existing host-fn word so the earlier ABI offsets stay stable.
160pub const ARENA_STATE_OFFSET_STEP_BUDGET: u32 =
161 ARENA_STATE_OFFSET_HOST_FNS + std::mem::size_of::<usize>() as u32;
162
163/// Phase 0b native-dispatch trap codes recorded in
164/// [`ArenaState::trap_code`] by [`relon_llvm_call_native`]. Mirrors the
165/// cranelift backend's `TrapKind` numbering for the subset the LLVM
166/// dynamic-dispatch path can raise. `0` is reserved for "no trap".
167#[repr(u64)]
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum NativeTrap {
170 /// Division (`Op::Div` / `Op::Mod`) by zero. Matches cranelift's
171 /// `TrapKind::DivisionByZero` (= 1); lifts to
172 /// `RuntimeError::DivisionByZero`.
173 DivisionByZero = 1,
174 /// Pointer dereference walked past the arena bounds. Matches
175 /// cranelift's `TrapKind::BoundsViolation` (= 2); lifts to
176 /// `RuntimeError::IndexOutOfBounds`.
177 BoundsViolation = 2,
178 /// Per-call resource budget exhausted. LLVM currently raises this
179 /// from deterministic step-budget fuel; a future wall-clock deadline
180 /// can reuse the same trap code.
181 ResourceExhausted = 4,
182 /// The `Op::CheckCap` gate denied a gated native call (the granted
183 /// `caps` bitmask had the required bit clear). Matches cranelift's
184 /// `TrapKind::CapabilityDenied` (= 3); lifts to
185 /// `RuntimeError::CapabilityDenied`.
186 CapabilityDenied = 3,
187 /// A checked Int reduction overflowed i64 (`list_int_sum`'s
188 /// per-iteration guard). Matches cranelift's
189 /// `TrapKind::NumericOverflow` (= 6); lifts to
190 /// `RuntimeError::NumericOverflow`, the same typed error the
191 /// tree-walk oracle's checked `+` raises. Routed through
192 /// `state.trap_code` + the negative sentinel (not `llvm.trap`)
193 /// so the host can surface the typed error instead of a SIGILL.
194 NumericOverflow = 6,
195 /// No host fn registered at the requested `import_idx`, or no
196 /// registry installed at all. Surfaces as
197 /// `RuntimeError::Unsupported`. Matches cranelift's
198 /// `TrapKind::Unreachable` (= 5) so the host-observable outcome
199 /// class is identical across backends.
200 HostFnMissing = 5,
201 /// The host fn returned an error, or a value outside the phase-0b
202 /// scalar return envelope (`Int` / `Bool` / `Unit`). Surfaces as
203 /// `RuntimeError::Unsupported`. A distinct code from `HostFnMissing`
204 /// only for post-mortem readability — both lift to `Unsupported`.
205 HostFnError = 7,
206 /// A strict-mode `match` fell through every arm with no `_`
207 /// catch-all and no arm matched at runtime. Lifts to
208 /// `RuntimeError::TypeMismatch { expected: "a matching arm", .. }`,
209 /// byte-aligned (modulo range) with the tree-walk oracle and the
210 /// cranelift `TrapKind::NoMatch`. The LLVM `Op::Trap` path can't use
211 /// `llvm.trap` (a `ud2` SIGILL the host can't decode into a typed
212 /// error), so the no-match trap records this code in
213 /// `state.trap_code` + returns the negative sentinel, which
214 /// `run_buffer_main` already lifts via `runtime_error_from_code`.
215 NoMatch = 8,
216}
217
218impl NativeTrap {
219 /// Lift a trap code recorded in [`ArenaState::trap_code`] into the
220 /// matching [`RuntimeError`]. Unknown / `0` codes are treated as
221 /// `Unsupported` (defensive — the JIT only ever stores the codes
222 /// above). Mirrors cranelift's `TrapKind::to_runtime_error` for the
223 /// subset the LLVM dynamic-dispatch path raises.
224 pub fn runtime_error_from_code(code: u64) -> RuntimeError {
225 match code {
226 1 => RuntimeError::DivisionByZero(TokenRange::default()),
227 2 => RuntimeError::IndexOutOfBounds {
228 range: TokenRange::default(),
229 },
230 3 => RuntimeError::CapabilityDenied {
231 cap_bit: None,
232 reason: "llvm-aot: host-fn call denied by capability gate".to_string(),
233 range: TokenRange::default(),
234 },
235 // Checked-reduction overflow — same typed error class as the
236 // tree-walk oracle's checked `+` and cranelift's
237 // `TrapKind::NumericOverflow::to_runtime_error`.
238 6 => RuntimeError::NumericOverflow(TokenRange::default()),
239 8 => RuntimeError::TypeMismatch {
240 // Byte-aligned with the tree-walk oracle's `Expr::Match`
241 // no-match path and the cranelift `TrapKind::NoMatch`
242 // mapping. `found` cannot reproduce the oracle's
243 // value-dependent `format!("value {}", val)` from a static
244 // trap; it states the structural cause instead.
245 expected: "a matching arm".to_string(),
246 found: "no matching arm".to_string(),
247 range: TokenRange::default(),
248 },
249 4 => RuntimeError::StepLimitExceeded {
250 limit: None,
251 range: TokenRange::default(),
252 },
253 _ => RuntimeError::Unsupported {
254 reason: "llvm-aot: native-fn dispatch failed (host fn missing / errored / \
255 returned a non-scalar value)"
256 .to_string(),
257 },
258 }
259 }
260}
261
262impl ArenaState {
263 /// Construct a state that points at `arena[0..]` for a single
264 /// dispatch. The caller owns the backing storage; this struct
265 /// only borrows it through a raw pointer for the JIT's
266 /// lifetime.
267 ///
268 /// `scratch_base` is the arena-relative offset where temporary
269 /// allocations (string concat, ...) live; pass `arena.len()` to
270 /// disable the scratch path. The cursors are reset to 0 so the
271 /// JIT bump path starts fresh on every dispatch.
272 ///
273 /// # Safety
274 ///
275 /// The caller must keep `arena` live and exclusively owned by the
276 /// `run_main` invocation that consumes this state. The emitted
277 /// JIT code reads and writes through `arena_base` without
278 /// touching the Rust borrow checker.
279 pub fn new(arena: &mut [u8], scratch_base: u32) -> Self {
280 Self {
281 arena_base: UnsafeCell::new(arena.as_mut_ptr() as usize),
282 arena_len: UnsafeCell::new(arena.len() as u32),
283 tail_cursor: UnsafeCell::new(0),
284 scratch_cursor: UnsafeCell::new(0),
285 scratch_base: UnsafeCell::new(scratch_base),
286 trap_code: UnsafeCell::new(0),
287 host_fns: UnsafeCell::new(0),
288 step_budget: UnsafeCell::new(0),
289 }
290 }
291
292 /// Set the remaining step budget for this dispatch. `0` disables
293 /// budget checks; negative values are already exhausted.
294 pub fn set_step_budget(&self, budget: i64) {
295 unsafe {
296 *self.step_budget.get() = budget;
297 }
298 }
299
300 /// Point the state at a host-fn registry for the duration of one
301 /// dispatch. Pass `0` (or skip the call) to leave the registry
302 /// unset — a `CallNative` then traps `HostFnMissing`.
303 ///
304 /// # Safety
305 ///
306 /// `registry` must outlive the JIT dispatch that consumes this
307 /// state, and must be a valid `*const HostFnRegistry` (or null).
308 /// The per-call ownership model keeps the `UnsafeCell` unaliased.
309 pub unsafe fn install_host_fns(&self, registry: *const HostFnRegistry) {
310 unsafe {
311 *self.host_fns.get() = registry as usize;
312 }
313 }
314
315 /// Read the trap code recorded by the JIT-side `Op::CallNative`
316 /// dispatch. `0` means no native-dispatch trap fired.
317 pub fn trap_code(&self) -> u64 {
318 // SAFETY: the dispatch has returned, so the cell is unaliased.
319 unsafe { *self.trap_code.get() }
320 }
321
322 /// Read the current tail-cursor value. Used by the evaluator
323 /// after a dispatch returns to know how much was written into the
324 /// tail region (for `String` return-value decoding).
325 #[allow(dead_code)]
326 pub fn tail_cursor(&self) -> u32 {
327 // SAFETY: caller owns the state exclusively for a single
328 // dispatch — no aliasing read can happen.
329 unsafe { *self.tail_cursor.get() }
330 }
331}
332
333/// Phase 0b host-fn registry: `import_idx -> Arc<dyn RelonFunction>`.
334///
335/// Mirrors the `host_fns` half of the cranelift backend's
336/// `CapabilityVtable`. The LLVM evaluator owns one of these (built via
337/// [`Self::with_host_fns`]) and points each per-call [`ArenaState`] at
338/// it through [`ArenaState::install_host_fns`]; a source-lowered
339/// `Op::CallNative` then resolves the `import_idx`-keyed callable via
340/// [`relon_llvm_call_native`].
341///
342/// Keying off `import_idx` (the IR-side private namespace) keeps it
343/// distinct from the capability-bit namespace the `Op::CheckCap`
344/// gate consumes — exactly the cranelift split.
345#[derive(Default, Clone)]
346pub struct HostFnRegistry {
347 host_fns: HashMap<u32, Arc<dyn RelonFunction>>,
348}
349
350impl std::fmt::Debug for HostFnRegistry {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 f.debug_struct("HostFnRegistry")
353 .field("host_fn_count", &self.host_fns.len())
354 .finish()
355 }
356}
357
358impl HostFnRegistry {
359 /// Build an empty registry.
360 pub fn new() -> Self {
361 Self {
362 host_fns: HashMap::new(),
363 }
364 }
365
366 /// Register a callable at `import_idx`. Overwrites any prior entry.
367 pub fn register(&mut self, import_idx: u32, func: Arc<dyn RelonFunction>) {
368 self.host_fns.insert(import_idx, func);
369 }
370
371 /// Resolve the callable registered at `import_idx`.
372 pub fn resolve(&self, import_idx: u32) -> Option<&Arc<dyn RelonFunction>> {
373 self.host_fns.get(&import_idx)
374 }
375
376 /// Number of registered host fns.
377 pub fn len(&self) -> usize {
378 self.host_fns.len()
379 }
380
381 /// `true` when no host fns are registered.
382 pub fn is_empty(&self) -> bool {
383 self.host_fns.is_empty()
384 }
385}
386
387/// Zero-surface [`NativeFnCaps`] for LLVM-dispatched host fns. Same
388/// envelope as the cranelift backend's `CraneliftNativeFnCaps`: no
389/// closure-callback / iterator surface yet, so a host fn that tries to
390/// call back into Relon logic gets a typed `Unsupported` error rather
391/// than a segfault. Cached as a single `Arc` so each dispatch is a
392/// refcount bump.
393struct LlvmNativeFnCaps;
394
395impl NativeFnCaps for LlvmNativeFnCaps {
396 fn call_relon(
397 &self,
398 _func: &Value,
399 _args: Vec<Value>,
400 _range: TokenRange,
401 ) -> Result<Value, RuntimeError> {
402 Err(RuntimeError::Unsupported {
403 reason: "llvm-aot host fn: call_relon callback unsupported".to_string(),
404 })
405 }
406}
407
408fn llvm_native_caps() -> Arc<dyn NativeFnCaps> {
409 static CAPS: std::sync::OnceLock<Arc<dyn NativeFnCaps>> = std::sync::OnceLock::new();
410 Arc::clone(CAPS.get_or_init(|| Arc::new(LlvmNativeFnCaps) as Arc<dyn NativeFnCaps>))
411}
412
413/// Stable symbol name the LLVM module declares the native-dispatch
414/// helper under. The evaluator maps it onto
415/// [`relon_llvm_call_native`]'s address via `engine.add_global_mapping`
416/// before resolving the entry pointer. Mirrors the cranelift backend's
417/// `RelonCallNative` vtable slot — same `(state, import_idx, args_ptr,
418/// arg_count) -> i64` shape, resolved by symbol here instead of through
419/// a data-vtable slot.
420pub const RELON_LLVM_CALL_NATIVE_SYMBOL: &str = "relon_llvm_call_native";
421
422/// Dynamic host-fn dispatch helper for a source-lowered
423/// `Op::CallNative`. The JIT-emitted call site passes the per-call
424/// `ArenaState` pointer, the IR `import_idx`, a pointer to `arg_count`
425/// contiguous i64 args (spilled into an `alloca` by the lowering), and
426/// the arg count. The helper:
427///
428/// 1. loads the `host_fns` registry pointer from the state;
429/// 2. resolves the `Arc<dyn RelonFunction>` registered at `import_idx`;
430/// 3. packs the i64 args as `Value::Int`s into `NativeArgs`;
431/// 4. invokes the callable and returns the i64-encoded scalar result.
432///
433/// Failures (no registry / no callable / host-fn error / non-scalar
434/// return) do **not** unwind across this `extern "C"` boundary (that
435/// would be UB on a `panic=unwind` build): the helper records a
436/// [`NativeTrap`] code in `state.trap_code` and returns `0`. The JIT
437/// call site loads `trap_code` right after the call and routes a
438/// non-zero value to an `llvm.trap`, so the host sees a typed
439/// `RuntimeError` the same way every other LLVM trap surfaces. Mirrors
440/// the cranelift backend's `SandboxState::call_native`.
441///
442/// Scope: scalar `Int` args in, `Int` / `Bool` / `Unit` result out.
443///
444/// # Safety
445///
446/// `state` must point at a live, aligned [`ArenaState`]; `args_ptr`
447/// must point at `arg_count` contiguous `i64`s. The JIT prologue passes
448/// the same `state` pointer it received and a stack slot it just
449/// populated, so both invariants hold for every production caller.
450pub unsafe extern "C" fn relon_llvm_call_native(
451 state: *const ArenaState,
452 import_idx: u32,
453 args_ptr: *const i64,
454 arg_count: u32,
455) -> i64 {
456 // SAFETY: caller guarantees a live, aligned ArenaState.
457 let state = unsafe { &*state };
458 // SAFETY: per-call ownership — the JIT thread is the only reader.
459 let registry_ptr = unsafe { *state.host_fns.get() } as *const HostFnRegistry;
460 let record_trap = |code: NativeTrap| {
461 // SAFETY: per-call ownership; the JIT call has not returned yet
462 // but no other thread can see this state.
463 unsafe {
464 *state.trap_code.get() = code as u64;
465 }
466 };
467 if registry_ptr.is_null() {
468 record_trap(NativeTrap::HostFnMissing);
469 return 0;
470 }
471 // SAFETY: the evaluator installs a registry that outlives the
472 // dispatch (it lives on the evaluator, behind an Arc).
473 let registry = unsafe { &*registry_ptr };
474 let Some(func) = registry.resolve(import_idx).cloned() else {
475 record_trap(NativeTrap::HostFnMissing);
476 return 0;
477 };
478 let args_slice = if arg_count == 0 {
479 &[][..]
480 } else {
481 // SAFETY: caller guarantees `arg_count` contiguous i64s.
482 unsafe { std::slice::from_raw_parts(args_ptr, arg_count as usize) }
483 };
484 let packed: Vec<Value> = args_slice.iter().map(|&x| Value::Int(x)).collect();
485 let native_args = NativeArgs::from_positional(packed, llvm_native_caps());
486 match func.call(native_args, TokenRange::default()) {
487 Ok(Value::Int(v)) => v,
488 Ok(Value::Bool(b)) => i64::from(b),
489 Ok(v) if v.is_option_none() => 0,
490 Ok(_) | Err(_) => {
491 record_trap(NativeTrap::HostFnError);
492 0
493 }
494 }
495}
496
497/// Address of [`relon_llvm_call_native`] as a `usize`, for
498/// `engine.add_global_mapping`. Two-step cast silences the
499/// `fn-as-usize` lint (mirrors `relon_llvm_str_contains_arena_addr`).
500#[inline]
501pub fn relon_llvm_call_native_addr() -> usize {
502 relon_llvm_call_native as *const () as usize
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn arena_state_offsets_match_repr_c_layout() {
511 let mut buf = [0u8; 16];
512 let state = ArenaState::new(&mut buf, 16);
513 let base = &state as *const _ as usize;
514 assert_eq!(
515 (state.arena_base.get() as usize) - base,
516 ARENA_STATE_OFFSET_BASE as usize
517 );
518 assert_eq!(
519 (state.arena_len.get() as usize) - base,
520 ARENA_STATE_OFFSET_LEN as usize
521 );
522 assert_eq!(
523 (state.tail_cursor.get() as usize) - base,
524 ARENA_STATE_OFFSET_TAIL_CURSOR as usize
525 );
526 assert_eq!(
527 (state.scratch_cursor.get() as usize) - base,
528 ARENA_STATE_OFFSET_SCRATCH_CURSOR as usize
529 );
530 assert_eq!(
531 (state.scratch_base.get() as usize) - base,
532 ARENA_STATE_OFFSET_SCRATCH_BASE as usize
533 );
534 assert_eq!(
535 (state.trap_code.get() as usize) - base,
536 ARENA_STATE_OFFSET_TRAP_CODE as usize
537 );
538 assert_eq!(
539 (state.host_fns.get() as usize) - base,
540 ARENA_STATE_OFFSET_HOST_FNS as usize
541 );
542 assert_eq!(
543 (state.step_budget.get() as usize) - base,
544 ARENA_STATE_OFFSET_STEP_BUDGET as usize
545 );
546 }
547
548 struct AddOne;
549 impl RelonFunction for AddOne {
550 fn call(&self, args: NativeArgs, _r: TokenRange) -> Result<Value, RuntimeError> {
551 match args.positional.first() {
552 Some(Value::Int(x)) => Ok(Value::Int(x + 1)),
553 _ => Err(RuntimeError::Unsupported {
554 reason: "AddOne expects Int".into(),
555 }),
556 }
557 }
558 }
559
560 #[test]
561 fn call_native_helper_dispatches_registered_fn() {
562 let mut reg = HostFnRegistry::new();
563 reg.register(0, Arc::new(AddOne));
564 let mut buf = [0u8; 16];
565 let state = ArenaState::new(&mut buf, 16);
566 // SAFETY: `reg` outlives the call below.
567 unsafe { state.install_host_fns(® as *const _) };
568 let args = [41i64];
569 let r = unsafe { relon_llvm_call_native(&state as *const _, 0, args.as_ptr(), 1) };
570 assert_eq!(r, 42);
571 assert_eq!(state.trap_code(), 0);
572 }
573
574 #[test]
575 fn call_native_helper_traps_when_unregistered() {
576 let reg = HostFnRegistry::new();
577 let mut buf = [0u8; 16];
578 let state = ArenaState::new(&mut buf, 16);
579 unsafe { state.install_host_fns(® as *const _) };
580 let r = unsafe { relon_llvm_call_native(&state as *const _, 7, std::ptr::null(), 0) };
581 assert_eq!(r, 0);
582 assert_eq!(state.trap_code(), NativeTrap::HostFnMissing as u64);
583 }
584
585 #[test]
586 fn call_native_helper_traps_when_no_registry() {
587 let mut buf = [0u8; 16];
588 let state = ArenaState::new(&mut buf, 16);
589 // No install_host_fns — registry pointer stays null.
590 let r = unsafe { relon_llvm_call_native(&state as *const _, 0, std::ptr::null(), 0) };
591 assert_eq!(r, 0);
592 assert_eq!(state.trap_code(), NativeTrap::HostFnMissing as u64);
593 }
594
595 #[test]
596 fn native_trap_bounds_code_lifts_to_index_out_of_bounds() {
597 assert!(matches!(
598 NativeTrap::runtime_error_from_code(NativeTrap::DivisionByZero as u64),
599 RuntimeError::DivisionByZero(_)
600 ));
601 assert!(matches!(
602 NativeTrap::runtime_error_from_code(NativeTrap::BoundsViolation as u64),
603 RuntimeError::IndexOutOfBounds { .. }
604 ));
605 assert!(matches!(
606 NativeTrap::runtime_error_from_code(NativeTrap::ResourceExhausted as u64),
607 RuntimeError::StepLimitExceeded { .. }
608 ));
609 }
610}