1
// Skipped under Miri: these tests compile+run wasm via wasmtime, whose
2
// Cranelift backend refuses to run under Miri.
3
#![cfg(not(miri))]
4

            
5
//! Runtime evaluation of UNWIND-PROTECT through the eval-mode (nomi-eval)
6
//! boundary wrapper: cleanup runs on normal completion AND on a raise, the
7
//! raise re-propagates after cleanup, and the form yields the body's value on
8
//! the normal path. Codegen-level validation lives in
9
//! `nomiscript/tests/codegen/unwind_protect.rs`.
10

            
11
use nms::interpreter::Interpreter;
12
use scripting::nomiscript::{Fraction, Value};
13

            
14
19
fn eval_one(src: &str) -> Value {
15
19
    let mut interp = Interpreter::new(false).unwrap();
16
19
    interp
17
19
        .eval(src)
18
19
        .unwrap_or_else(|e| panic!("eval {src:?}: {e}"))
19
19
        .into_iter()
20
19
        .next_back()
21
19
        .unwrap_or_else(|| panic!("eval {src:?} produced no value"))
22
19
}
23

            
24
1
fn eval_err(src: &str) -> String {
25
1
    let mut interp = Interpreter::new(false).unwrap();
26
1
    match interp.eval(src) {
27
        Ok(v) => panic!("eval {src:?} unexpectedly succeeded: {v:?}"),
28
1
        Err(e) => e.to_string(),
29
    }
30
1
}
31

            
32
15
fn n(v: i64) -> Value {
33
15
    Value::Number(Fraction::from_integer(v))
34
15
}
35

            
36
#[test]
37
1
fn normal_completion_returns_body_value() {
38
1
    assert_eq!(eval_one("(unwind-protect 42 (debug \"cleanup\"))"), n(42));
39
1
}
40

            
41
#[test]
42
1
fn runtime_boolean_body_result_serializes_as_bool() {
43
    // The body is a runtime comparison (`WasmType::Bool`). The unwind-protect
44
    // eval-time type mirror must report `Bool` (not I32), so a bound result is
45
    // sized correctly and serializes as Nil/Bool — not Number. tag-count is 0:
46
    // (= 0 0) is true → Bool(true); (< 0 0) is false → Nil.
47
1
    assert_eq!(
48
1
        eval_one("(unwind-protect (= (transaction-tag-count 0) 0) (debug \"c\"))"),
49
        Value::Bool(true)
50
    );
51
1
    assert_eq!(
52
1
        eval_one("(let* ((x (unwind-protect (< (transaction-tag-count 0) 0) (debug \"c\")))) x)"),
53
        Value::Nil
54
    );
55
1
}
56

            
57
#[test]
58
1
fn cleanup_runs_on_normal_path() {
59
    // The cleanup mutates a binding visible after the form; proves cleanup
60
    // executed on the normal path. n: 0 → body leaves it, cleanup sets 9.
61
1
    assert_eq!(
62
1
        eval_one("(let* ((n 0)) (unwind-protect 1 (setf n 9)) n)"),
63
1
        n(9),
64
    );
65
1
}
66

            
67
#[test]
68
1
fn cleanup_runs_then_raise_repropagates() {
69
    // The body raises; cleanup must run, then the raise re-propagates and
70
    // escapes (no catch-all here), so eval surfaces an error.
71
1
    let err = eval_err(r#"(unwind-protect (error 'boom "x") (debug "cleanup"))"#);
72
1
    assert!(
73
1
        !err.is_empty(),
74
        "a raise in unwind-protect body must re-propagate after cleanup"
75
    );
76
1
}
77

            
78
#[test]
79
1
fn cleanup_side_effect_visible_to_outer_handler_after_raise() {
80
    // The body raises; cleanup sets n=9; an enclosing handler-case catches the
81
    // re-raised condition and returns n. Proves cleanup ran on the exceptional
82
    // path BEFORE the catch saw the re-raise.
83
1
    assert_eq!(
84
1
        eval_one(
85
1
            r#"(let* ((n 0))
86
1
                 (handler-case
87
1
                   (unwind-protect (error 'boom "x") (setf n 9))
88
1
                   (boom (e) n)))"#
89
        ),
90
1
        n(9),
91
    );
92
1
}
93

            
94
#[test]
95
1
fn outer_handler_catches_repropagated_condition() {
96
    // The re-raised condition keeps its code, so the matching outer clause
97
    // fires (returns 7), not a catch-all fallback.
98
1
    assert_eq!(
99
1
        eval_one(
100
1
            r#"(handler-case
101
1
                 (unwind-protect (error 'boom "x") (debug "c"))
102
1
                 (boom (e) 7))"#
103
        ),
104
1
        n(7),
105
    );
106
1
}
107

            
108
#[test]
109
1
fn cleanup_runs_once_on_normal_path() {
110
    // The two cleanup splice copies (normal + exceptional) must not BOTH run
111
    // on a normal exit. n increments once per cleanup execution → 1, not 2.
112
1
    assert_eq!(
113
1
        eval_one("(let* ((n 0)) (unwind-protect 1 (setf n (+ n 1))) n)"),
114
1
        n(1),
115
    );
116
1
}
117

            
118
#[test]
119
1
fn multiple_cleanup_forms_all_run() {
120
1
    assert_eq!(
121
1
        eval_one("(let* ((a 0) (b 0)) (unwind-protect 1 (setf a 1) (setf b 2)) (+ a b))"),
122
1
        n(3),
123
    );
124
1
}
125

            
126
#[test]
127
1
fn string_body_value_survives_cleanup() {
128
1
    assert_eq!(
129
1
        eval_one(r#"(unwind-protect "ok" (debug "c"))"#),
130
1
        Value::String("ok".to_string()),
131
    );
132
1
}
133

            
134
#[test]
135
1
fn nested_unwind_protect_both_cleanups_run_on_raise() {
136
    // Inner body raises; inner cleanup sets a=1, the re-raise crosses the
137
    // outer unwind-protect whose cleanup sets b=2, then the handler-case
138
    // catches it and returns a+b = 3. Both cleanups ran in order.
139
1
    assert_eq!(
140
1
        eval_one(
141
1
            r#"(let* ((a 0) (b 0))
142
1
                 (handler-case
143
1
                   (unwind-protect
144
1
                     (unwind-protect (error 'boom "x") (setf a 1))
145
1
                     (setf b 2))
146
1
                   (boom (e) (+ a b))))"#
147
        ),
148
1
        n(3),
149
    );
150
1
}
151

            
152
#[test]
153
1
fn cleanup_runs_in_effect_position() {
154
    // unwind-protect at effect position (non-tail of a BEGIN) still runs
155
    // cleanup; the trailing 5 is the program value.
156
1
    assert_eq!(
157
1
        eval_one("(let* ((n 0)) (begin (unwind-protect 1 (setf n 7)) n))"),
158
1
        n(7),
159
    );
160
1
}
161

            
162
// The `RT` prefix forces `n` to a RUNTIME local: a tagbody go-loop promotes
163
// it (the loop var can't be const-folded), so a cleanup `(setf n …)` is a
164
// real wasm store the runtime must execute — a const-foldable `n` would make
165
// these pass even if cleanup were skipped. Required because the non-local-exit
166
// cleanup bug originally hid behind compile-time const-folding.
167
const RT_PROMOTE: &str = "(tagbody lp (setf n (+ n 1)) (when (< n 1) (go lp)))";
168

            
169
#[test]
170
1
fn cleanup_runs_on_nonlocal_return_from() {
171
    // A `(return-from out)` inside the body targets a block OUTSIDE the
172
    // unwind-protect — a non-local exit. CL semantics: cleanup MUST run as
173
    // control unwinds past it. With `n` promoted to a runtime local, cleanup's
174
    // `(setf n 9)` is a real store; n=9 proves it ran on the br-exit path.
175
1
    assert_eq!(
176
1
        eval_one(&format!(
177
1
            "(let* ((n 0)) {RT_PROMOTE} \
178
1
             (block out (unwind-protect (return-from out 7) (setf n 9))) n)"
179
1
        )),
180
1
        n(9),
181
    );
182
1
}
183

            
184
#[test]
185
1
fn nonlocal_return_from_still_returns_body_value() {
186
    // The block's value is the return-from value (7), even though cleanup
187
    // runs as control unwinds past the unwind-protect. The `99` form after
188
    // the unwind-protect *inside* the block is dead (return-from exited).
189
1
    assert_eq!(
190
1
        eval_one(r#"(block out (unwind-protect (return-from out 7) (debug "c")) 99)"#),
191
1
        n(7),
192
    );
193
1
}
194

            
195
#[test]
196
1
fn cleanup_runs_on_nonlocal_go() {
197
    // A `(go done)` inside the body jumps to a tag in an OUTER tagbody,
198
    // crossing the unwind-protect. Cleanup `(setf n 9)` must run before the
199
    // jump. `n` is runtime-promoted by the same outer loop.
200
1
    assert_eq!(
201
1
        eval_one(
202
1
            "(let* ((n 0)) \
203
1
               (tagbody \
204
1
                 lp (setf n (+ n 1)) (when (< n 1) (go lp)) \
205
1
                 (unwind-protect (go done) (setf n 9)) \
206
1
                 (setf n 100) \
207
1
                 done) \
208
1
               n)"
209
        ),
210
1
        n(9),
211
    );
212
1
}
213

            
214
#[test]
215
1
fn nested_unwind_protect_nonlocal_exit_runs_both_cleanups() {
216
    // A non-local `(return-from out)` from inside two nested unwind-protects
217
    // must run BOTH cleanups, innermost-first. a=1 (inner) + b=2 (outer) = 3.
218
1
    assert_eq!(
219
1
        eval_one(&format!(
220
1
            "(let* ((n 0) (a 0) (b 0)) {RT_PROMOTE} \
221
1
             (block out \
222
1
               (unwind-protect \
223
1
                 (unwind-protect (return-from out 7) (setf a 1)) \
224
1
                 (setf b 2))) \
225
1
             (+ a b))"
226
1
        )),
227
1
        n(3),
228
    );
229
1
}
230

            
231
#[test]
232
1
fn nested_nonlocal_exit_runs_each_cleanup_exactly_once() {
233
    // Strongest "exactly once" assertion: both nested cleanups INCREMENT one
234
    // isolated runtime counter `c`, so the result counts total cleanup runs.
235
    // `c` is promoted to a runtime local by `(setf c g)` after the loop (guard
236
    // is the SEPARATE var `g`, never the counter) — so the increment can't be
237
    // const-folded and the readout isn't contaminated. Two cleanups, once each
238
    // → 2 (a double-run would give 3+). Locks in the inline crossing-cleanup
239
    // mechanism against the const-fold/shared-counter measurement traps that
240
    // masked correctness during development.
241
1
    assert_eq!(
242
1
        eval_one(
243
1
            "(let* ((g 0) (c 0)) \
244
1
               (tagbody lp (setf g (+ g 0)) (when (< g 0) (go lp))) \
245
1
               (setf c g) \
246
1
               (block out \
247
1
                 (unwind-protect \
248
1
                   (unwind-protect (return-from out 7) (setf c (+ c 1))) \
249
1
                   (setf c (+ c 1)))) \
250
1
               c)"
251
        ),
252
1
        n(2),
253
    );
254
1
}
255

            
256
// --- Adversarial-review regression cases (opencode, Tier 3.4) ---
257

            
258
#[test]
259
1
fn crossing_cleanup_does_not_contaminate_sibling_branch() {
260
    // A `(setf x v)` in cleanup of a `(return-from)` in ONE arm of a runtime
261
    // `if` must NOT mutate the compile-time value of a const `x` the OTHER arm
262
    // reads. The then-arm (compiled first) replays the crossing cleanup; if it
263
    // ran against the live symbol table it would set `x`'s const value to 7,
264
    // and the else-arm — taken at runtime since the host test is false — would
265
    // return 7 instead of the correct pre-cleanup 0. Cleanups at a crossing
266
    // site compile against a CLONE, so the sibling stays clean.
267
1
    assert_eq!(
268
1
        eval_one(
269
1
            "(let* ((x 0)) \
270
1
               (block out (unwind-protect \
271
1
                 (if (= (transaction-tag-count 0) 1) (return-from out 99) x) \
272
1
                 (setf x 7))))"
273
        ),
274
1
        n(0),
275
    );
276
1
}
277

            
278
#[test]
279
1
fn return_from_inside_cleanup_does_not_recurse() {
280
    // A `(return-from)` INSIDE a cleanup must not re-schedule that same cleanup
281
    // (which recursed to a compile-time stack overflow before the frame was
282
    // masked during its own emission). The cleanup's own `(return-from out 2)`
283
    // takes over the unwind and wins — CL: a non-local exit from cleanup
284
    // abandons the original exit.
285
1
    assert_eq!(
286
1
        eval_one("(block out (unwind-protect (return-from out 1) (return-from out 2)))"),
287
1
        n(2),
288
    );
289
1
}
290

            
291
#[test]
292
1
fn diverging_cleanup_makes_form_diverge_dead_tail() {
293
    // A cleanup that always diverges (`(return-from b "x")`) makes the whole
294
    // unwind-protect diverge — the body's value never falls through. The tail
295
    // `3` after the form is dead, so BLOCK must type from the reachable string
296
    // exit, not conflict it against the dead ratio tail.
297
1
    assert_eq!(
298
1
        eval_one(r#"(block b (unwind-protect 1 (return-from b "x")) 3)"#),
299
1
        Value::String("x".to_string()),
300
    );
301
1
}