Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions examples/match-in-loop.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- match as the tail of an @ loop body: value yields into the loop, not the caller.

evens n:n>n;a=0;@i 0..n{r=mod i 2;?r{0:a=+a i;_:a}};+a 0

label n:n>n;?n{0:0;_:n}

-- engine-skip: jit
-- run: evens 0
-- out: 0
-- run: evens 5
-- out: 6
-- run: evens 10
-- out: 20
-- run: label 0
-- out: 0
-- run: label 7
-- out: 7
14 changes: 4 additions & 10 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1665,9 +1665,8 @@ fn serde_json_to_value(v: serde_json::Value) -> Value {

fn eval_body(env: &mut Env, stmts: &[Spanned<Stmt>]) -> Result<BodyResult> {
let mut last = Value::Nil;
for (i, spanned) in stmts.iter().enumerate() {
let is_last = i == stmts.len() - 1;
match eval_stmt(env, &spanned.node, is_last) {
for spanned in stmts.iter() {
match eval_stmt(env, &spanned.node) {
Ok(Some(BodyResult::Return(v))) => return Ok(BodyResult::Return(v)),
Ok(Some(BodyResult::Break(v))) => return Ok(BodyResult::Break(v)),
Ok(Some(BodyResult::Continue)) => return Ok(BodyResult::Continue),
Expand All @@ -1691,7 +1690,7 @@ fn eval_body(env: &mut Env, stmts: &[Spanned<Stmt>]) -> Result<BodyResult> {
Ok(BodyResult::Value(last))
}

fn eval_stmt(env: &mut Env, stmt: &Stmt, is_last: bool) -> Result<Option<BodyResult>> {
fn eval_stmt(env: &mut Env, stmt: &Stmt) -> Result<Option<BodyResult>> {
match stmt {
Stmt::Let { name, value } => {
let val = eval_expr(env, value)?;
Expand Down Expand Up @@ -1782,12 +1781,7 @@ fn eval_stmt(env: &mut Env, stmt: &Stmt, is_last: bool) -> Result<Option<BodyRes
BodyResult::Return(v) => return Ok(Some(BodyResult::Return(v))),
BodyResult::Break(v) => return Ok(Some(BodyResult::Break(v))),
BodyResult::Continue => return Ok(Some(BodyResult::Continue)),
BodyResult::Value(v) => {
if is_last {
return Ok(Some(BodyResult::Return(v)));
}
return Ok(Some(BodyResult::Value(v)));
}
BodyResult::Value(v) => return Ok(Some(BodyResult::Value(v))),
}
}
}
Expand Down
113 changes: 113 additions & 0 deletions tests/regression_match_in_loop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Regression tests for tree-walker miscompile where a `?match{}` expression
// at the tail of an `@` loop body silently early-returned from the enclosing
// function instead of yielding into the loop body.
//
// Before the fix, `Stmt::Match` in the tree interpreter converted a
// `BodyResult::Value(v)` into `BodyResult::Return(v)` whenever the match was
// the last statement of any body — including a loop body. That escaped the
// loop AND the function with the matched arm's value. VM and Cranelift were
// correct; only the tree-walker mis-handled this. These tests pin the
// cross-engine behaviour so the bug cannot silently come back.

use std::process::Command;

fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
}

fn run(engine: &str, src: &str, entry: &str) -> String {
let out = ilo()
.args([src, engine, entry])
.output()
.expect("failed to run ilo");
assert!(
out.status.success(),
"ilo {engine} failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}

#[cfg(feature = "cranelift")]
const ENGINES_ALL: &[&str] = &["--run-tree", "--run-vm", "--run-cranelift"];
#[cfg(not(feature = "cranelift"))]
const ENGINES_ALL: &[&str] = &["--run-tree", "--run-vm"];

// Match-as-tail of an `@` loop must not escape the function. The function's
// tail value (`5`) is what should be returned.
const LOOP_TAIL: &str = r#"f>n;cs=["1"];@c cs{rn=num c;?rn{^_:99;~v:42}};5"#;

#[test]
fn match_in_loop_tail_cross_engine() {
for engine in ENGINES_ALL {
let out = run(engine, LOOP_TAIL, "f");
assert_eq!(out.trim(), "5", "{engine}: {out:?}");
}
}

// Sanity: match as the tail of a function (no surrounding loop) still returns
// the matched arm's value. The fix must not break this case.
const FN_TAIL: &str = r#"f>n;rn=num "1";?rn{^_:99;~v:42}"#;

#[test]
fn match_as_fn_tail_cross_engine() {
for engine in ENGINES_ALL {
let out = run(engine, FN_TAIL, "f");
assert_eq!(out.trim(), "42", "{engine}: {out:?}");
}
}

// Match arm body with side effects inside an `@` loop must run for every
// iteration, mutate the accumulator, and reach the function's tail.
const LOOP_SIDE_EFFECTS: &str =
r#"f>n;n=0;cs=["1","x","2","y","3"];@c cs{rn=num c;?rn{^_:n;~v:n=+n 1}};n"#;

#[test]
fn match_side_effects_in_loop_cross_engine() {
for engine in ENGINES_ALL {
let out = run(engine, LOOP_SIDE_EFFECTS, "f");
assert_eq!(out.trim(), "3", "{engine}: {out:?}");
}
}

// `brk` inside a match inside an `@` loop must still propagate to the loop
// (not return from the function). Tail of the function returns the
// accumulator after the loop exits early. The non-numeric arm fires a braced
// guard with `brk`; the numeric arm increments.
const BRK_IN_MATCH: &str =
r#"f>n;n=0;cs=["1","2","x","3"];@c cs{rn=num c;?rn{^_:1{brk}{n=n};~v:n=+n 1}};n"#;

#[test]
fn brk_in_match_in_loop_cross_engine() {
for engine in ENGINES_ALL {
let out = run(engine, BRK_IN_MATCH, "f");
assert_eq!(out.trim(), "2", "{engine}: {out:?}");
}
}

// Same shape as LOOP_TAIL but with a `wh` (while) loop instead of `@`. The
// match at the tail of the while body must yield into the loop, not the
// caller; the function's own tail (`5`) is what should be returned.
const WHILE_LOOP_TAIL: &str = r#"f>n;i=0;wh <i 3{i=+i 1;rn=num "1";?rn{^_:99;~v:42}};5"#;

#[test]
fn match_in_while_loop_tail_cross_engine() {
for engine in ENGINES_ALL {
let out = run(engine, WHILE_LOOP_TAIL, "f");
assert_eq!(out.trim(), "5", "{engine}: {out:?}");
}
}

// `cnt` inside a match inside an `@` loop must propagate to the loop.
// All non-numeric entries hit the wildcard arm and fire a braced-guard `cnt`;
// numeric entries increment the accumulator.
const CNT_IN_MATCH: &str =
r#"f>n;n=0;cs=["1","x","2","y","3"];@c cs{rn=num c;?rn{^_:1{cnt}{n=n};~v:n=+n 1}};n"#;

#[test]
fn cnt_in_match_in_loop_cross_engine() {
for engine in ENGINES_ALL {
let out = run(engine, CNT_IN_MATCH, "f");
assert_eq!(out.trim(), "3", "{engine}: {out:?}");
}
}
Loading