Lines
99.35 %
Functions
91.67 %
Branches
100 %
// Skipped under Miri: these tests compile+run wasm via wasmtime, whose
// Cranelift backend refuses to run under Miri.
#![cfg(not(miri))]
//! Runtime evaluation of UNWIND-PROTECT through the eval-mode (nomi-eval)
//! boundary wrapper: cleanup runs on normal completion AND on a raise, the
//! raise re-propagates after cleanup, and the form yields the body's value on
//! the normal path. Codegen-level validation lives in
//! `nomiscript/tests/codegen/unwind_protect.rs`.
use nms::interpreter::Interpreter;
use scripting::nomiscript::{Fraction, Value};
fn eval_one(src: &str) -> Value {
let mut interp = Interpreter::new(false).unwrap();
interp
.eval(src)
.unwrap_or_else(|e| panic!("eval {src:?}: {e}"))
.into_iter()
.next_back()
.unwrap_or_else(|| panic!("eval {src:?} produced no value"))
}
fn eval_err(src: &str) -> String {
match interp.eval(src) {
Ok(v) => panic!("eval {src:?} unexpectedly succeeded: {v:?}"),
Err(e) => e.to_string(),
fn n(v: i64) -> Value {
Value::Number(Fraction::from_integer(v))
#[test]
fn normal_completion_returns_body_value() {
assert_eq!(eval_one("(unwind-protect 42 (debug \"cleanup\"))"), n(42));
fn runtime_boolean_body_result_serializes_as_bool() {
// The body is a runtime comparison (`WasmType::Bool`). The unwind-protect
// eval-time type mirror must report `Bool` (not I32), so a bound result is
// sized correctly and serializes as Nil/Bool — not Number. tag-count is 0:
// (= 0 0) is true → Bool(true); (< 0 0) is false → Nil.
assert_eq!(
eval_one("(unwind-protect (= (transaction-tag-count 0) 0) (debug \"c\"))"),
Value::Bool(true)
);
eval_one("(let* ((x (unwind-protect (< (transaction-tag-count 0) 0) (debug \"c\")))) x)"),
Value::Nil
fn cleanup_runs_on_normal_path() {
// The cleanup mutates a binding visible after the form; proves cleanup
// executed on the normal path. n: 0 → body leaves it, cleanup sets 9.
eval_one("(let* ((n 0)) (unwind-protect 1 (setf n 9)) n)"),
n(9),
fn cleanup_runs_then_raise_repropagates() {
// The body raises; cleanup must run, then the raise re-propagates and
// escapes (no catch-all here), so eval surfaces an error.
let err = eval_err(r#"(unwind-protect (error 'boom "x") (debug "cleanup"))"#);
assert!(
!err.is_empty(),
"a raise in unwind-protect body must re-propagate after cleanup"
fn cleanup_side_effect_visible_to_outer_handler_after_raise() {
// The body raises; cleanup sets n=9; an enclosing handler-case catches the
// re-raised condition and returns n. Proves cleanup ran on the exceptional
// path BEFORE the catch saw the re-raise.
eval_one(
r#"(let* ((n 0))
(handler-case
(unwind-protect (error 'boom "x") (setf n 9))
(boom (e) n)))"#
),
fn outer_handler_catches_repropagated_condition() {
// The re-raised condition keeps its code, so the matching outer clause
// fires (returns 7), not a catch-all fallback.
r#"(handler-case
(unwind-protect (error 'boom "x") (debug "c"))
(boom (e) 7))"#
n(7),
fn cleanup_runs_once_on_normal_path() {
// The two cleanup splice copies (normal + exceptional) must not BOTH run
// on a normal exit. n increments once per cleanup execution → 1, not 2.
eval_one("(let* ((n 0)) (unwind-protect 1 (setf n (+ n 1))) n)"),
n(1),
fn multiple_cleanup_forms_all_run() {
eval_one("(let* ((a 0) (b 0)) (unwind-protect 1 (setf a 1) (setf b 2)) (+ a b))"),
n(3),
fn string_body_value_survives_cleanup() {
eval_one(r#"(unwind-protect "ok" (debug "c"))"#),
Value::String("ok".to_string()),
fn nested_unwind_protect_both_cleanups_run_on_raise() {
// Inner body raises; inner cleanup sets a=1, the re-raise crosses the
// outer unwind-protect whose cleanup sets b=2, then the handler-case
// catches it and returns a+b = 3. Both cleanups ran in order.
r#"(let* ((a 0) (b 0))
(unwind-protect
(unwind-protect (error 'boom "x") (setf a 1))
(setf b 2))
(boom (e) (+ a b))))"#
fn cleanup_runs_in_effect_position() {
// unwind-protect at effect position (non-tail of a BEGIN) still runs
// cleanup; the trailing 5 is the program value.
eval_one("(let* ((n 0)) (begin (unwind-protect 1 (setf n 7)) n))"),
// The `RT` prefix forces `n` to a RUNTIME local: a tagbody go-loop promotes
// it (the loop var can't be const-folded), so a cleanup `(setf n …)` is a
// real wasm store the runtime must execute — a const-foldable `n` would make
// these pass even if cleanup were skipped. Required because the non-local-exit
// cleanup bug originally hid behind compile-time const-folding.
const RT_PROMOTE: &str = "(tagbody lp (setf n (+ n 1)) (when (< n 1) (go lp)))";
fn cleanup_runs_on_nonlocal_return_from() {
// A `(return-from out)` inside the body targets a block OUTSIDE the
// unwind-protect — a non-local exit. CL semantics: cleanup MUST run as
// control unwinds past it. With `n` promoted to a runtime local, cleanup's
// `(setf n 9)` is a real store; n=9 proves it ran on the br-exit path.
eval_one(&format!(
"(let* ((n 0)) {RT_PROMOTE} \
(block out (unwind-protect (return-from out 7) (setf n 9))) n)"
)),
fn nonlocal_return_from_still_returns_body_value() {
// The block's value is the return-from value (7), even though cleanup
// runs as control unwinds past the unwind-protect. The `99` form after
// the unwind-protect *inside* the block is dead (return-from exited).
eval_one(r#"(block out (unwind-protect (return-from out 7) (debug "c")) 99)"#),
fn cleanup_runs_on_nonlocal_go() {
// A `(go done)` inside the body jumps to a tag in an OUTER tagbody,
// crossing the unwind-protect. Cleanup `(setf n 9)` must run before the
// jump. `n` is runtime-promoted by the same outer loop.
"(let* ((n 0)) \
(tagbody \
lp (setf n (+ n 1)) (when (< n 1) (go lp)) \
(unwind-protect (go done) (setf n 9)) \
(setf n 100) \
done) \
n)"
fn nested_unwind_protect_nonlocal_exit_runs_both_cleanups() {
// A non-local `(return-from out)` from inside two nested unwind-protects
// must run BOTH cleanups, innermost-first. a=1 (inner) + b=2 (outer) = 3.
"(let* ((n 0) (a 0) (b 0)) {RT_PROMOTE} \
(block out \
(unwind-protect \
(unwind-protect (return-from out 7) (setf a 1)) \
(setf b 2))) \
(+ a b))"
fn nested_nonlocal_exit_runs_each_cleanup_exactly_once() {
// Strongest "exactly once" assertion: both nested cleanups INCREMENT one
// isolated runtime counter `c`, so the result counts total cleanup runs.
// `c` is promoted to a runtime local by `(setf c g)` after the loop (guard
// is the SEPARATE var `g`, never the counter) — so the increment can't be
// const-folded and the readout isn't contaminated. Two cleanups, once each
// → 2 (a double-run would give 3+). Locks in the inline crossing-cleanup
// mechanism against the const-fold/shared-counter measurement traps that
// masked correctness during development.
"(let* ((g 0) (c 0)) \
(tagbody lp (setf g (+ g 0)) (when (< g 0) (go lp))) \
(setf c g) \
(unwind-protect (return-from out 7) (setf c (+ c 1))) \
(setf c (+ c 1)))) \
c)"
n(2),
// --- Adversarial-review regression cases (opencode, Tier 3.4) ---
fn crossing_cleanup_does_not_contaminate_sibling_branch() {
// A `(setf x v)` in cleanup of a `(return-from)` in ONE arm of a runtime
// `if` must NOT mutate the compile-time value of a const `x` the OTHER arm
// reads. The then-arm (compiled first) replays the crossing cleanup; if it
// ran against the live symbol table it would set `x`'s const value to 7,
// and the else-arm — taken at runtime since the host test is false — would
// return 7 instead of the correct pre-cleanup 0. Cleanups at a crossing
// site compile against a CLONE, so the sibling stays clean.
"(let* ((x 0)) \
(block out (unwind-protect \
(if (= (transaction-tag-count 0) 1) (return-from out 99) x) \
(setf x 7))))"
n(0),
fn return_from_inside_cleanup_does_not_recurse() {
// A `(return-from)` INSIDE a cleanup must not re-schedule that same cleanup
// (which recursed to a compile-time stack overflow before the frame was
// masked during its own emission). The cleanup's own `(return-from out 2)`
// takes over the unwind and wins — CL: a non-local exit from cleanup
// abandons the original exit.
eval_one("(block out (unwind-protect (return-from out 1) (return-from out 2)))"),
fn diverging_cleanup_makes_form_diverge_dead_tail() {
// A cleanup that always diverges (`(return-from b "x")`) makes the whole
// unwind-protect diverge — the body's value never falls through. The tail
// `3` after the form is dead, so BLOCK must type from the reachable string
// exit, not conflict it against the dead ratio tail.
eval_one(r#"(block b (unwind-protect 1 (return-from b "x")) 3)"#),
Value::String("x".to_string()),