makepad/platform/script/tests/try_catch.rs
Admin 01a1c70b76 script: the Octoscript VM hardening -- execution caps, fuel and deadlines, heap/string/array/object limits, parser diagnostics and re-entrant dispatch, keeping a Makepad host's semantics and speed
The platform part of work 76627b6f1..6941c0e86, as one step that builds (makepad-platform checks for macOS and wasm32):

- script: re-entrant dispatch, thread index validation, Any-based handle downcasts, UTF-8 previews; regex: never_loop fix
- script: port Octoscript WS1 — worklist equality with fuel/deadline/work bail, uncaught-error bail, allow_debug_output
- script: port Octoscript parser/tokenizer/control-flow VM patches (ws2)
- script: port Octoscript heap/string/array/object hardening onto upstream's allocation budget
- script: port orphaned Octoscript VM hardening (execution caps, clear_type_methods, parser diagnostics)
- script: keep silenced streaming evals running past uncaught errors
- script: the Octoscript hardening keeps a Makepad host's semantics and its speed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:08:33 +02:00

434 lines
12 KiB
Rust

//! Regression tests for canonical `try protected catch fallback`, the legacy
//! catch-less `try protected fallback [ok success]` form, cross-call
//! unwinding, loop back-edge cleanup of iteration-local try frames, and the
//! logical/comparison precedence and `!` contracts (grammar v0.2).
use makepad_script::*;
fn test_vm() -> ScriptVm<'static> {
let host = Box::leak(Box::new(ScriptVmHost::new((), ())));
let mut vm = ScriptVm {
host,
bx: Box::new(ScriptVmBase::new()),
};
vm.bx.captured_errors = Some(Vec::new());
vm
}
fn eval(vm: &mut ScriptVm, name: &str, code: &str) -> ScriptValue {
vm.with_instruction_limit(500_000, |vm| {
vm.eval(ScriptMod {
file: format!("try_catch_{name}.octoscript"),
code: format!("{code}\n;"),
..Default::default()
})
})
}
fn number(vm: &mut ScriptVm, name: &str, code: &str) -> f64 {
let value = eval(vm, name, code);
let errors = vm.take_errors();
assert!(errors.is_empty(), "{name}: {errors:?}");
value
.as_number()
.unwrap_or_else(|| panic!("{name}: expected a number, got {value:?}"))
}
fn boolean(vm: &mut ScriptVm, name: &str, code: &str) -> bool {
let value = eval(vm, name, code);
let errors = vm.take_errors();
assert!(errors.is_empty(), "{name}: {errors:?}");
value
.as_bool()
.unwrap_or_else(|| panic!("{name}: expected a bool, got {value:?}"))
}
fn assertion_failed(vm: &mut ScriptVm, name: &str, code: &str) -> Vec<String> {
let _ = eval(vm, name, code);
let errors = vm.take_errors();
assert!(
errors.iter().any(|e| e.contains("assertion failed")),
"{name}: expected an uncaught assertion, got {errors:?}"
);
errors
}
#[test]
fn legacy_catch_less_try_still_recovers_and_keeps_its_value() {
let vm = &mut test_vm();
assert_eq!(
number(vm, "legacy_block", "use mod.std.assert\ntry {\n assert(false)\n} {\n 42\n}"),
42.0
);
// TRY_ERR's success path used to skip one extra opcode: with no `ok`
// branch that was the enclosing LET, so `value` never bound.
assert_eq!(
number(
vm,
"legacy_let",
"use mod.std.assert\nlet value = try { 7 } { assert(false) }\nvalue"
),
7.0
);
assert_eq!(
number(
vm,
"legacy_ok_after_success",
"let marker = 0\ntry { 7 } { marker = 1 } ok { marker = 2 }\nmarker"
),
2.0
);
assert_eq!(
number(
vm,
"legacy_ok_after_error",
"use mod.std.assert\nlet marker = 0\ntry { assert(false) } { marker = 1 } ok { marker = 2 }\nmarker"
),
1.0
);
}
#[test]
fn canonical_try_catch_returns_the_protected_or_fallback_value() {
let vm = &mut test_vm();
assert_eq!(
number(
vm,
"block_fallback",
"use mod.std.assert\ntry {\n assert(false)\n} catch {\n 42\n}"
),
42.0
);
// Block branches keep their final expression as the try value even when
// canonical source terminates that expression with a newline.
assert_eq!(
number(
vm,
"block_protected",
"use mod.std.assert\nlet value = try {\n 7\n} catch {\n assert(false)\n}\nvalue"
),
7.0
);
assert_eq!(
number(
vm,
"block_fallback_let",
"use mod.std.assert\nlet value = try {\n assert(false)\n} catch {\n 42\n}\nvalue"
),
42.0
);
assert_eq!(
number(vm, "expr", "use mod.std.assert\ntry assert(false) catch 9"),
9.0
);
assert_eq!(
number(
vm,
"record",
"let value = try ({answer: 42}) catch ({answer: 0})\nvalue.answer"
),
42.0
);
// Both values participate safely in a larger expression.
assert_eq!(
number(vm, "in_expression", "use mod.std.assert\n1 + (try assert(false) catch 2) * 3"),
7.0
);
assert_eq!(
number(vm, "in_expression_ok", "use mod.std.assert\n1 + (try 5 catch assert(false)) * 3"),
16.0
);
}
#[test]
fn canonical_try_value_survives_end_of_source_auto_close() {
let vm = &mut test_vm();
// No trailing terminator: the parser's auto-close path must not discard
// the fallback block's tail value either.
let value = vm.eval(ScriptMod {
file: "try_catch_auto_close.octoscript".to_owned(),
code: "use mod.std.assert\ntry {\n assert(false)\n} catch {\n 42\n}".to_owned(),
..Default::default()
});
let errors = vm.take_errors();
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(value.as_number(), Some(42.0));
}
#[test]
fn canonical_try_catch_does_not_swallow_a_fallback_error() {
let vm = &mut test_vm();
assertion_failed(
vm,
"fallback_error",
"use mod.std.assert\ntry {\n assert(false)\n} catch {\n assert(false)\n}",
);
}
#[test]
fn canonical_try_catch_unwinds_across_script_function_calls() {
let vm = &mut test_vm();
assert_eq!(
number(
vm,
"unwind_outer",
"use mod.std.assert\n\
fn fail() {\n\
assert(false)\n\
}\n\
fn middle() {\n\
return fail()\n\
}\n\
let recovered = try {\n\
middle()\n\
0\n\
} catch {\n\
42\n\
}\n\
recovered"
),
42.0
);
assert_eq!(
number(
vm,
"unwind_inner",
"use mod.std.assert\n\
fn fail() {\n\
assert(false)\n\
}\n\
fn recover() {\n\
return try {\n\
fail()\n\
} catch {\n\
7\n\
}\n\
}\n\
recover()"
),
7.0
);
// The unwound frames are gone: a later call still works and the
// try-owning frame's body is the one that continues.
assert_eq!(
number(
vm,
"unwind_then_call",
"use mod.std.assert\n\
fn fail() {\n\
assert(false)\n\
}\n\
fn twice(x) {\n\
return x * 2\n\
}\n\
let recovered = try {\n\
fail()\n\
} catch {\n\
twice(21)\n\
}\n\
recovered + twice(0)"
),
42.0
);
}
#[test]
fn canonical_nested_try_catch_can_recover_from_an_inner_fallback_error() {
let vm = &mut test_vm();
assert_eq!(
number(
vm,
"nested",
"use mod.std.assert\n\
try {\n\
try {\n\
assert(false)\n\
} catch {\n\
assert(false)\n\
}\n\
} catch {\n\
42\n\
}"
),
42.0
);
}
#[test]
fn canonical_catch_marker_is_a_one_shot_contextual_separator() {
let vm = &mut test_vm();
// The first `catch` is the separator, the second is the fallback identifier.
assert_eq!(
number(
vm,
"catch_ident",
"use mod.std.assert\nlet catch = 2\ntry {\n assert(false)\n} catch catch + 1"
),
3.0
);
// A protected identifier named `catch` must be parenthesized.
assert_eq!(
number(vm, "catch_protected", "let catch = 2\ntry (catch) catch catch + 1"),
2.0
);
// Outside `try`, `catch` is an ordinary identifier.
assert_eq!(
number(vm, "catch_plain", "let catch = 20\nfn f(catch) { return catch + 1 }\nf(catch) * 2"),
42.0
);
}
#[test]
fn loop_back_edges_discard_iteration_local_try_frames() {
for (name, source) in [
(
"plain_loop_continue",
"use mod.std.assert\n\
let first = true\n\
loop {\n\
if first {\n\
first = false\n\
try {\n\
continue\n\
nil\n\
} catch {\n\
state.caught = state.caught + 1\n\
}\n\
}\n\
assert(false)\n\
break\n\
}\n\
state.caught",
),
(
"while_continue",
"use mod.std.assert\n\
let index = 0\n\
while index < 2 {\n\
index += 1\n\
if index == 1 {\n\
try {\n\
continue\n\
nil\n\
} catch {\n\
state.caught = state.caught + 1\n\
}\n\
}\n\
assert(false)\n\
break\n\
}\n\
state.caught",
),
(
"for_continue",
"use mod.std.assert\n\
for i in 2 {\n\
if i == 0 {\n\
try {\n\
continue\n\
nil\n\
} catch {\n\
state.caught = state.caught + 1\n\
}\n\
}\n\
assert(false)\n\
break\n\
}\n\
state.caught",
),
(
"loop_break",
"use mod.std.assert\n\
loop {\n\
try {\n\
break\n\
nil\n\
} catch {\n\
state.caught = state.caught + 1\n\
}\n\
}\n\
assert(false)\n\
state.caught",
),
] {
let vm = &mut test_vm();
let state = vm.heap_mut().new_object();
vm.heap_mut()
.set_value_def(state, id!(caught).into(), ScriptValue::from_f64(0.0));
vm.set_injected_global(id!(state), state.into());
assertion_failed(vm, name, source);
// The abandoned handler must never have re-entered its fallback.
let caught = vm.heap().value(state, id!(caught).into(), NoTrap);
assert_eq!(caught.as_number(), Some(0.0), "{name}: stale try frame caught a later error");
}
// The enclosing try frame outside the loop stays intact.
let vm = &mut test_vm();
assert_eq!(
number(
vm,
"enclosing_try",
"use mod.std.assert\n\
try {\n\
let i = 0\n\
while i < 3 {\n\
i += 1\n\
if i < 3 { continue }\n\
assert(false)\n\
}\n\
0\n\
} catch {\n\
42\n\
}"
),
42.0
);
}
#[test]
fn logical_precedence_matches_boolean_algebra_and_skips_effects() {
let vm = &mut test_vm();
for a in [false, true] {
for b in [false, true] {
for c in [false, true] {
assert_eq!(
boolean(vm, "or_and", &format!("{a} || {b} && {c}")),
a || b && c,
"{a} || {b} && {c}"
);
assert_eq!(
boolean(vm, "and_or", &format!("{a} && {b} || {c}")),
a && b || c,
"{a} && {b} || {c}"
);
}
}
}
// `||` short-circuits its entire right-hand `&&` expression.
assert_eq!(number(vm, "effect", "let n = 0\ntrue || false && (n = 1)\nn"), 0.0);
assert_eq!(number(vm, "effect_and", "let n = 0\nfalse && (n = 1) || true\nn"), 0.0);
// Comparisons bind tighter than equality.
assert!(boolean(vm, "cmp_eq_1", "true == 2 > 1"));
assert!(boolean(vm, "cmp_eq_2", "false != 2 > 1"));
assert!(boolean(vm, "cmp_eq_3", "1 < 2 == true"));
assert!(boolean(vm, "cmp_eq_4", "(1 < 2 == true) && (2 >= 3 != true)"));
}
#[test]
fn logical_not_always_negates_truth_conversion() {
let vm = &mut test_vm();
for (source, expected) in [
("!1", false),
("!(1 + 0)", false),
("!0", true),
("!(0 + 0)", true),
("!(-1)", false),
("!1.5", false),
("!true", false),
("!false", true),
("!nil", true),
("!!7", true),
] {
assert_eq!(boolean(vm, "not", source), expected, "{source}");
}
}