Lines
98.82 %
Functions
88.24 %
Branches
100 %
//! Tier 3 architectural spike (ADR-0026): confirm the wasm
//! exception-handling proposal behaves as the `(handler-case)` /
//! `(unwind-protect)` design assumes, *before* any codegen lands.
//!
//! Load-bearing properties verified:
//! 1. **GC liveness across a catch edge — guest-allocated.** A
//! `(ref $cond)` struct from `struct.new`, held in a local across a
//! `try_table`, is readable after the catch.
//! 2. **GC liveness across a catch edge — host-allocated.** A ref
//! produced by the runtime's `alloc_string_ref` host helper (the
//! same path `(handler-case)` will use to build condition objects),
//! held in a wasm local across the catch edge, survives the unwind
//! and downcasts cleanly afterward. Finding from the 3.1 review:
//! guest-local survival alone doesn't prove the host-ref path.
//! 3. **`OutOfFuel` bypasses both `catch_all` and a tagged `catch`.**
//! Engine traps are not wasm exceptions, so neither catch shape
//! swallows them — the deadline budget stays non-catchable.
//! 4. **`EpochInterrupt` bypasses `try_table`** — the second engine
//! deadline mechanism is non-catchable too, not just fuel.
use scripting::runtime::{
EngineError, EngineOpts, alloc_string_ref, build_engine, classify_runtime_error,
};
use wasmtime::{Caller, FuncType, HeapType, Linker, Module, RefType, Store, Val, ValType};
/// Guest-allocated `$cond` struct (field 42) stored in a local before a
/// `try_table`, read after the `$err` catch. Proves guest-local GC
/// rooting across the unwind.
const GUEST_LIVENESS_WAT: &str = r#"
(module
(type $cond (struct (field i32)))
(tag $err)
(func (export "probe") (result i32)
(local $c (ref null $cond))
(local.set $c (struct.new $cond (i32.const 42)))
(block $handler
(try_table (catch $err $handler)
(throw $err))
)
(struct.get $cond 0 (local.get $c)))
"#;
/// Host-allocated arrayref (from `make_ref`) stored in an `anyref`
/// local before a `try_table`, downcast + measured after the catch.
/// Proves the host-produced ref path — the one `(handler-case)` builds
/// condition objects on — survives the catch edge.
const HOST_LIVENESS_WAT: &str = r#"
(import "spike" "make_ref" (func $make_ref (result anyref)))
(local $r anyref)
(local.set $r (call $make_ref))
(array.len (ref.cast (ref array) (local.get $r))))
/// Infinite loop wrapped in `try_table (catch_all ...)`. The catch
/// label targets a no-result block (the proposal requires `catch_all`
/// labels to take no values); reaching it would mean the trap was
/// wrongly swallowed, so the handler returns sentinel `1`.
const BUSY_CATCH_ALL_WAT: &str = r#"
(func (export "burn") (result i32)
(try_table (catch_all $handler)
(loop $l (br $l)))
(return (i32.const 0)))
(i32.const 1)))
/// Infinite loop wrapped in a *tagged* `try_table (catch $err ...)`.
/// A tagged catch must not swallow engine traps any more than
/// `catch_all` does.
const BUSY_TAGGED_CATCH_WAT: &str = r#"
/// Condition-object lifetime (3.2/3.3 load-bearing). A payload-carrying
/// tag `(tag $err (param (ref $cond)))` throws a struct; the catch
/// delivers it as a `$handler` block param; we store it to a local, run
/// MORE `struct.new` allocations and a nested inner `try_table`/throw,
/// THEN read both fields of the original condition. If the caught ref
/// were dropped or moved by the unwind or the later allocations, the
/// final reads would fault — so a return of `code*1000 + msg` proves the
/// condition object survives exactly the way `(handler-case)` needs.
const CONDITION_LIFETIME_WAT: &str = r#"
(type $cond (struct (field $code i32) (field $msg i32)))
(tag $err (param (ref $cond)))
(tag $inner)
(local $caught (ref null $cond))
(block $outer (result (ref $cond))
(try_table (result (ref $cond)) (catch $err $outer)
(throw $err (struct.new $cond (i32.const 7) (i32.const 9))))
;; normal exit unreachable — body always throws
(unreachable))
;; $outer delivers the caught (ref $cond) on the stack
(local.set $caught)
;; churn the GC: more allocations after the catch
(drop (struct.new $cond (i32.const 1) (i32.const 2)))
(drop (struct.new $cond (i32.const 3) (i32.const 4)))
;; a fully-nested throw/catch between catch and the final read
(block $inner_h
(try_table (catch $inner $inner_h)
(throw $inner)))
;; original condition must still be intact
(i32.add
(i32.mul (struct.get $cond $code (local.get $caught)) (i32.const 1000))
(struct.get $cond $msg (local.get $caught))))
/// The exact 3-frame boundary-wrapper shape the compiler will emit for a
/// VALUED body (i32 result, like `should_apply`): `block $exit (result
/// i32)` → `block $handler (result (ref $cond))` → `try_table (result
/// i32) (catch $err $handler)`. A `br` out of a nested inner block inside
/// the body mimics RETURN-FROM / GO: it must resolve through the +3
/// wrapper depth to the right target. On the throw path the handler reads
/// the condition and returns a sentinel; the normal path returns the
/// body's i32. Returns 11 (body via inner br), proving inner labels
/// resolve under the wrapper.
const BOUNDARY_VALUED_WAT: &str = r#"
(block $exit (result i32)
(block $handler (result (ref $cond))
(try_table (result i32) (catch $err $handler)
;; body: an inner block whose `br` jumps to $exit through the
;; try_table + handler frames (mimics RETURN-FROM target math)
(block $inner (result i32)
(br $exit (i32.const 11))))
;; normal try_table completion carries the body's i32 out and
;; skips the handler tail — the boundary wrapper MUST emit this
;; `br $exit` after the try_table closes.
(br $exit))
;; $handler: condition ref on stack → sentinel
(drop)
(i32.const 99))))
/// The void variant of the boundary wrapper (no result arity, like
/// `process`). `block $exit` and `try_table` carry no result type; the
/// body falls through; the catch path drops the condition. Proves the
/// void shape validates and the catch→fallthrough typing is well-formed.
/// Exported as `() -> ()`; reaching the end without trap is success.
const BOUNDARY_VOID_WAT: &str = r#"
(func (export "probe")
(block $exit
;; body: no value, just falls through to br $exit
;; normal completion skips the handler tail
;; $handler: drop the condition, fall through
(drop))))
/// The exact `(handler-case)` lowering shape (Tier 3.3): `Catch::OneRef`
/// delivers BOTH the condition ref AND an `exnref` to a TWO-result handler
/// block (`(result (ref $cond) exnref)`), so an unmatched clause can
/// `throw_ref` the ORIGINAL exception. Dispatch is FLAT — each clause is an
/// independent `if` that `br`s to `$outer` on match, so every clause body
/// sits at the same depth (no nested-if depth drift). The SECOND clause's
/// body performs an outer `br $outer` (mimicking RETURN-FROM from a
/// non-first clause), proving relative-br depth is correct through the flat
/// dispatch. Body throws code 2; clause 1 matches code 1 (skipped), clause 2
/// matches code 2 → returns 22 via `br $outer`. Proves OneRef + functype
/// handler block + flat dispatch + outer-br all validate and run.
const HANDLER_CASE_MATCH_WAT: &str = r#"
(local $e exnref)
(block $outer (result i32)
(block $handler (result (ref $cond) exnref)
(try_table (result i32) (catch_ref $err $handler)
(throw $err (struct.new $cond (i32.const 2) (i32.const 0))))
;; normal completion carries the body i32 out, skips the handler
(br $outer))
;; catch edge: stack = [(ref $cond), exnref]
(local.set $e)
(local.set $c)
;; clause 1: code == 1 ?
(if (i32.eq (struct.get $cond $code (local.get $c)) (i32.const 1))
(then (br $outer (i32.const 11))))
;; clause 2: code == 2 ? (non-first clause doing an outer br)
(if (i32.eq (struct.get $cond $code (local.get $c)) (i32.const 2))
(then (br $outer (i32.const 22))))
;; no match, no `t`: re-raise the ORIGINAL exception
(throw_ref (local.get $e)))))
/// Same shape, but the body throws code 9 which matches NO clause and there
/// is no `t` catch-all → the handler `throw_ref`s the original exnref, which
/// escapes the function uncaught. Proves the re-raise path: the call must
/// return `Err` (an uncaught exception), not a value.
const HANDLER_CASE_RERAISE_WAT: &str = r#"
(throw $err (struct.new $cond (i32.const 9) (i32.const 0))))
/// Simpler `(handler-case)` lowering: re-raise on no-match by re-`throw`ing a
/// FRESH `$nomi_error` carrying the SAME condition (nomiscript has no
/// exception identity — conditions are value-like code+message), so we avoid
/// `Catch::OneRef` / exnref / a 2-value functype handler block entirely. The
/// catch is the proven `Catch::One` single-value shape (same as the 3.2
/// boundary wrapper). Flat dispatch; second clause matches code 2 via outer
/// `br $outer` → returns 22.
const HANDLER_CASE_ONE_RETHROW_WAT: &str = r#"
;; catch edge: stack = [(ref $cond)]
;; no match: re-throw a fresh $err carrying the SAME condition.
;; `$c` is nullable; the tag param is non-null → ref.as_non_null.
(throw $err (ref.as_non_null (local.get $c))))))
/// Re-throw variant where code 9 matches nothing and there's no `t` → the
/// handler re-`throw`s the same condition, which escapes uncaught.
const HANDLER_CASE_ONE_RETHROW_ESCAPE_WAT: &str = r#"
#[test]
fn handler_case_catch_one_rethrow_matches_non_first_clause() {
assert_eq!(
run_i32_probe(HANDLER_CASE_ONE_RETHROW_WAT, "handler-case-one-rethrow"),
22,
"Catch::One + flat dispatch must run the matching (non-first) clause; \
re-raise via a fresh throw of the same condition needs no exnref"
);
}
fn handler_case_catch_one_rethrow_escapes_on_no_match() {
let engine = build_engine(EngineOpts::baseline()).expect("build engine");
let module = Module::new(&engine, HANDLER_CASE_ONE_RETHROW_ESCAPE_WAT)
.expect("compile handler-case-one-rethrow-escape");
let mut store: Store<()> = Store::new(&engine, ());
store.set_epoch_deadline(1_000_000);
let instance = Linker::<()>::new(&engine)
.instantiate(&mut store, &module)
.expect("instantiate");
let probe = instance
.get_func(&mut store, "probe")
.expect("probe export");
let mut results = [Val::I32(0)];
probe
.call(&mut store, &[], &mut results)
.expect_err("unmatched handler-case with no catch-all must re-raise");
fn handler_case_oneref_dispatch_matches_non_first_clause() {
run_i32_probe(HANDLER_CASE_MATCH_WAT, "handler-case-match"),
"OneRef two-value handler + flat dispatch must run the matching \
(non-first) clause and carry its value out via br $outer"
fn handler_case_no_match_reraises_original_exception() {
let module =
Module::new(&engine, HANDLER_CASE_RERAISE_WAT).expect("compile handler-case-reraise");
let outcome = probe.call(&mut store, &[], &mut results);
outcome.expect_err("an unmatched handler-case with no catch-all must re-raise (throw_ref)");
fn gc_struct_local_survives_catch_edge() {
let module = Module::new(&engine, GUEST_LIVENESS_WAT).expect("compile guest-liveness module");
.expect("probe call");
results[0].i32().expect("i32 result"),
42,
"guest GC struct local must survive the try_table catch edge"
fn host_allocated_ref_survives_catch_edge() {
let module = Module::new(&engine, HOST_LIVENESS_WAT).expect("compile host-liveness module");
let mut linker: Linker<()> = Linker::new(&engine);
let make_ref_ty = FuncType::new(
&engine,
[],
[ValType::Ref(RefType::new(true, HeapType::Any))],
linker
.func_new(
"spike",
"make_ref",
make_ref_ty,
|mut caller: Caller<'_, ()>, _args, results| {
let array = alloc_string_ref(&mut caller, b"hello")?;
results[0] = Val::AnyRef(Some(array.to_anyref()));
Ok(())
},
.expect("register make_ref");
let instance = linker
5,
"host-allocated arrayref must survive the catch edge and downcast (len of \"hello\")"
fn out_of_fuel_bypasses_catch_all() {
assert_trap_bypasses_catch(BUSY_CATCH_ALL_WAT, TrapKind::Fuel);
fn out_of_fuel_bypasses_tagged_catch() {
assert_trap_bypasses_catch(BUSY_TAGGED_CATCH_WAT, TrapKind::Fuel);
fn epoch_interrupt_bypasses_catch_all() {
assert_trap_bypasses_catch(BUSY_CATCH_ALL_WAT, TrapKind::Epoch);
/// Runs a no-import `() -> i32` `probe` export and returns its result.
/// Shared by the condition-lifetime and valued-boundary spikes.
fn run_i32_probe(wat: &str, what: &str) -> i32 {
let module = Module::new(&engine, wat).unwrap_or_else(|e| panic!("compile {what}: {e}"));
.unwrap_or_else(|e| panic!("{what} call: {e}"));
results[0].i32().expect("i32 result")
fn caught_condition_object_survives_later_allocations() {
run_i32_probe(CONDITION_LIFETIME_WAT, "condition-lifetime"),
7009,
"caught (ref $cond) must survive store-to-local + later allocations + nested catch \
(code 7 * 1000 + msg 9)"
fn valued_boundary_wrapper_inner_branch_resolves() {
run_i32_probe(BOUNDARY_VALUED_WAT, "boundary-valued"),
11,
"inner br must resolve to $exit through the 3-frame valued boundary wrapper"
fn void_boundary_wrapper_validates_and_runs() {
let module = Module::new(&engine, BOUNDARY_VOID_WAT).expect("compile boundary-void");
.call(&mut store, &[], &mut [])
.expect("void boundary probe must validate and run to completion");
#[derive(Clone, Copy)]
enum TrapKind {
Fuel,
Epoch,
/// Runs `wat`'s `burn` export under the given deadline mechanism and
/// asserts the resulting trap escapes the `try_table` rather than being
/// caught. Both fuel and epoch budgets must stay non-catchable.
fn assert_trap_bypasses_catch(wat: &str, kind: TrapKind) {
let engine = build_engine(EngineOpts::baseline().with_fuel()).expect("build engine");
let module = Module::new(&engine, wat).expect("compile busy module");
match kind {
TrapKind::Fuel => {
store.set_fuel(10_000).expect("set fuel");
TrapKind::Epoch => {
store.set_fuel(1_000_000_000).expect("set fuel");
store.set_epoch_deadline(1);
engine.increment_epoch();
let burn = instance.get_func(&mut store, "burn").expect("burn export");
let outcome = burn.call(&mut store, &[], &mut results);
let err = outcome.expect_err("burn must trap, not be caught");
assert!(
matches!(
classify_runtime_error(&err),
EngineError::OutOfFuel | EngineError::EpochInterrupt
),
"engine trap must bypass the catch, got {:?}",
classify_runtime_error(&err)