Section 02: UB & Soundness
Context: These issues don’t crash today but represent latent bugs. M14 loads poison values from uninitialized memory. H2 marks functions nounwind that call potentially-panicking runtime functions. M9 silently overflows when creating 1..=INT_MAX ranges. M2 means integer overflow in AOT silently wraps instead of panicking. Any of these could cause mysterious failures under different optimization levels, LLVM versions, or target architectures.
Depends on: Nothing, but coordinate with Section 03 (exception handling) since H2 and H1/M10/M11 share nounwind analysis code.
02.1 Fix M14 — None Variant Uninitialized Payload
Journey: J12 | Severity: MEDIUM (but LLVM UB)
File(s): compiler/ori_llvm/src/codegen/ (variant construction)
When constructing None (or any unit variant of a sum type with payload variants), the codegen stores only the tag but then loads ALL fields from the alloca — including the payload which was never written.
; None construction:
%variant = alloca { i64, i64 }, align 8
store i64 1, ptr %variant.tag, align 4 ; tag = 1 (None) — stored
; payload at offset 1 is NEVER stored
%variant.f1 = load i64, ptr %variant.f1.ptr, align 4 ; loads uninitialized → poison!
Fix options:
- (a) Zero-initialize the alloca (recommended):
%variant = alloca { i64, i64 }, align 8followed bystore { i64, i64 } zeroinitializer, ptr %variant. Simple, correct, tiny cost. - (b) Skip loading uninitialized fields: Only load fields that were stored. More complex codegen logic but avoids the write.
- (c) Use
insertvalueinstead of alloca: Build the SSA value directly without memory. Also fixes M7. Best long-term but larger change.
Recommended: Option (a) as immediate fix, option (c) as part of Section 05 (M7). Both must use zeroinitializer for unset payload fields — this is the consistent invariant across the plan (no undef for unused variant payloads).
- Write test: construct unit variant (Empty), verified
zeroinitializerin IR dump - Zero-initialize allocas for unit variants of payload sum types (
construction.rs) - Verify with Valgrind on Journey 12 code — 0 errors, 0 leaks
02.2 Fix H2 — Audit nounwind for Runtime Calls
Journey: J10 | Severity: HIGH
File(s): compiler/ori_llvm/src/codegen/ (nounwind analysis)
_ori_check_iteration() is marked nounwind but calls ori_iter_from_list and ori_iter_next which have NO function attributes in their declarations. If either can panic (OOM, bounds violation), the nounwind guarantee is violated.
; Function marked nounwind...
define fastcc i64 @_ori_check_iteration() #0 { ; #0 = nounwind
; ...but calls functions without nounwind:
%list.iter = call ptr @ori_iter_from_list(ptr %list.data5, i64 %list.len, i64 8)
%iter_next.has = call i8 @ori_iter_next(ptr %list.iter, ptr %iter_next.scratch, i64 8)
Required:
- Audit ALL runtime function declarations — which can panic?
- Mark non-panicking runtime functions with
nounwindattribute - Ensure nounwind analysis considers transitive calls to runtime functions
- Functions calling potentially-panicking runtime functions must NOT be
nounwind
- Catalog all
declareruntime functions in generated IR across all 12 journeys - For each: determine if it can panic (check ori_rt source)
- Mark nounwind-safe runtime functions with the attribute
- Audit the nounwind propagation algorithm — does it check runtime function attributes?
- Fix: functions calling non-nounwind runtime functions must not be marked nounwind
- Verify:
_ori_check_iterationshould NOT be nounwind (calls allocating runtime functions)
02.3 Fix M9 — Range Overflow for ..=INT_MAX
Journey: J7 | Severity: MEDIUM
File(s): compiler/ori_llvm/src/codegen/ (range iteration codegen)
Inclusive range 1..=n computes end + step to convert to exclusive bound. For n = INT_MAX, this overflows.
%add = add i64 %proj.1, %proj.3 ; end + step = INT_MAX + 1 → overflow!
Fix options:
- (a) Saturating add:
%add = call i64 @llvm.sadd.sat.i64(i64 %end, i64 %step)— saturates at INT_MAX instead of wrapping. - (b) Sign-aware loop condition (recommended): Use a comparison that matches the step direction, avoiding the +1 conversion entirely. More natural for inclusive ranges.
- (c) Overflow check: Emit
llvm.sadd.with.overflowand panic on overflow.
Recommended: Option (b) — use a sign-aware loop condition instead of converting to exclusive bounds.
Critical: step sign is NOT always compile-time known. Ori allows variable step expressions (e.g., for x in 0..12 by step yield x where step is a runtime variable — see tests/spec/expressions/ranges.ori:304). The codegen must handle all three cases at runtime:
- step > 0 (ascending): continue while
current <= end(icmp sle) - step < 0 (descending): continue while
current >= end(icmp sge) - step == 0: panic (infinite loop prevention —
ranges.ori:153tests syntax only, NOT runtime panic;assert_panicstest needed)
Implementation: runtime branch on step sign.
; Runtime step-sign dispatch for inclusive ranges:
%step_positive = icmp sgt i64 %step, 0
%step_negative = icmp slt i64 %step, 0
%step_zero = icmp eq i64 %step, 0
br i1 %step_zero, label %panic_zero_step, label %check_direction
check_direction:
%cmp_le = icmp sle i64 %current, %end ; ascending check
%cmp_ge = icmp sge i64 %current, %end ; descending check
%continue = select i1 %step_positive, i1 %cmp_le, i1 %cmp_ge
br i1 %continue, label %body, label %exit
When the step IS a compile-time constant (the common case), LLVM’s constant folding will eliminate the dead branch, producing the same code as a hardcoded sle/sge. No performance cost for the common case.
- Write test:
for x in 0..=0 do ...(edge case — single element range) - Write test: ascending inclusive range near INT_MAX
- Write test:
for x in 10..=0 by -1 do ...(descending inclusive range) - Write test:
for x in 5..=1 by -1 do ...(descending inclusive with step -1) - Write test: variable step with inclusive range (runtime-dynamic sign)
- Write test: zero step panics —
assert_panics(f: () -> for x in 0..=10 by 0 yield x)(existingranges.ori:153only tests syntax, not runtime behavior) - Implement fix: emit runtime step-sign branch (step > 0 → sle, step < 0 → sge, step == 0 → panic)
- Verify: LLVM constant-folds the branch away for literal steps
- Verify: Journey 7 still returns 30
- Verify: descending inclusive ranges produce correct results matching eval
- Verify: variable-step inclusive ranges match eval behavior
02.4 Add H3 — noalias on Proven Non-Aliasing Parameters
Severity: HIGH (optimization enabler, not correctness)
File(s): compiler/ori_llvm/src/codegen/function_compiler/mod.rs, compiler/ori_llvm/src/codegen/arc_emitter/emitter_utils.rs
WARNING: Blanket noalias on all pointer params is UNSOUND. While Ori has value semantics at the language level, pointer parameters in LLVM IR can alias the same underlying RC-managed buffer. Consider:
@f (a: [int], b: [int]) -> int = a[0] + b[0]
// Called as:
let xs = [1, 2, 3];
f(a: xs, b: xs) // a and b share the same RC buffer!
At the Ori level, a and b are independent values. But at the LLVM IR level, before COW triggers a copy, both params point to the same heap allocation. LLVM noalias means the pointers do NOT alias — if LLVM reorders a store through a before a load through b (which noalias permits), the result is wrong.
The existing codebase already handles this correctly. emitter_utils.rs:33-50 applies noalias only in CowMode::StaticUnique contexts — when uniqueness analysis proves refcount == 1. This is the right pattern.
What to do — extend the existing pattern, not replace it:
- sret and
ori_rc_allocreturn: Alreadynoalias— correct (fresh allocations can’t alias). - COW
StaticUnique: Alreadynoalias— correct (proven unique, refcount == 1). - Function parameters from fresh allocations: If a parameter is provably freshly constructed at the call site (not passed through from another parameter), it cannot alias other params. This requires interprocedural analysis or annotation propagation.
- All other pointer params: NOT safe for
noalias— could share RC buffer.
Conservative approach (recommended): Extend noalias only to parameters that the ARC pipeline’s ownership analysis marks as Owned AND that are provably not aliased with other params. This requires integrating with borrow inference results, not blanket annotation.
Acceptance rule (define before implementing): Document the exact set of ownership/provenance states that permit noalias. Candidate rule: a pointer param gets noalias if and only if (a) it is an sret or fresh ori_rc_alloc return (already done), OR (b) it is CowMode::StaticUnique at a COW call site (already done), OR (c) callee-side analysis proves no other live param in the same function shares the same allocation origin. Any state not explicitly listed here must NOT get noalias. This rule must be written into a code comment at the annotation site to prevent interpretation drift during future changes.
Deferred analysis: A full escape analysis could prove more params non-aliasing, but this is a significant undertaking. Start with the conservative cases and measure the optimization impact.
- Audit all current
noaliasusage — verify soundness of existing annotations - Identify parameters provably non-aliasing (fresh allocations at call site,
Ownedwithout shared source) - Add
noaliasonly to proven non-aliasing pointer params (NOT blanket on all pointer params) - Write test:
f(a: xs, b: xs)— both params alias same buffer, verify correct behavior with and without noalias - Verify with
opt -passes=print-alias-summaryon proven-noalias cases - Verify: no test regressions
02.5 Fix M2 — Checked Arithmetic (Overflow Panics)
Journey: J1 (confirmed J1-J12) | Severity: MEDIUM
File(s): compiler/ori_llvm/src/codegen/ (arithmetic instruction emission)
All integer arithmetic in AOT uses wrapping semantics (add, sub, mul without nsw). Ori’s spec says integer overflow should panic. The plan’s goal is zero LLVM UB — therefore nsw flags are not an option (nsw makes overflow UB, directly contradicting the zero-UB goal).
Decision: Checked arithmetic (non-negotiable).
Emit llvm.sadd.with.overflow / ssub.with.overflow / smul.with.overflow intrinsics and branch to a panic function on overflow. This matches Rust’s debug mode behavior and Ori’s spec. It is the only approach compatible with this plan’s “zero UB” mission.
Implementation sketch:
; Before (wrapping — silent UB on overflow):
%r = add i64 %a, %b
; After (checked — panics on overflow):
%result = call { i64, i1 } @llvm.sadd.with.overflow.i64(i64 %a, i64 %b)
%r = extractvalue { i64, i1 } %result, 0
%overflow = extractvalue { i64, i1 } %result, 1
br i1 %overflow, label %panic, label %continue
Note: A future --release flag could switch to nsw for performance-critical builds, but the default must match the spec (panic on overflow).
- Emit
llvm.sadd.with.overflow/ssub.with.overflow/smul.with.overflowfor all signed integer arithmetic - Generate overflow panic via
ori_panic_cstrwith per-operation messages (inline in each overflow branch) - Write test: arithmetic that would overflow, verify AOT panics (matching eval behavior)
- Verify: non-overflowing arithmetic generates identical results to eval
02.6 Completion Checklist
- No
loadof uninitialized memory in any generated IR (M14 fixed) - All runtime function declarations have correct
nounwindattributes - No function marked
nounwindtransitively calls a panicking runtime function - Proven non-aliasing pointer parameters have
noaliasattribute; no blanket annotation (H3) -
1..=0range works correctly (empty inclusive range edge case) - All signed integer arithmetic uses checked overflow intrinsics (panic on overflow)
-
./scripts/valgrind-aot.sh— 0 errors -
./test-all.shgreen
Exit Criteria: opt -passes=verify on generated IR for all 12 journeys reports 0 errors. Valgrind reports 0 invalid reads/writes. nounwind analysis is conservative-correct. Proven non-aliasing parameters have noalias; no blanket annotation on shared-buffer-capable params.