makepad/platform/script/tests/doc_eval_contract.rs

164 lines
7.9 KiB
Rust

//! The bounded, pure document contract (`bounded.rs`): a document's load
//! and each per-frame call run under instruction, time and heap limits
//! that bail loudly, and after the load the document is frozen, so a frame
//! call can neither keep state nor depend on the order frames run in.
use makepad_script::*;
use std::time::{Duration, Instant};
fn test_vm() -> ScriptVm<'static> {
let host = Box::leak(Box::new(ScriptVmHost::new(0i32, ())));
ScriptVm {
host: host.host_context(),
bx: Box::new(ScriptVmBase::new()),
}
}
fn script(name: &str, code: &str) -> ScriptMod {
ScriptMod::new("", "", &format!("doc_contract_{name}"), 0, 0, &code, &[])
}
fn call(vm: &mut ScriptVm, root: ScriptValue, name: LiveId, args: &[ScriptValue]) -> (ScriptValue, EvalReport, Vec<String>) {
let f = vm.bx.heap.value(root.as_object().unwrap(), name.into(), NoTrap);
let (v, report) = vm.eval_bounded(&EvalLimits::DOC_FRAME, |vm| vm.call(f, args));
(v, report, vm.take_errors())
}
#[test]
fn runaway_loads_hit_a_limit_and_report_it() {
for (name, code) in [
("spin", "loop {}\n;"),
("spin_caught", "try { loop {} } catch { 1 }\n;"),
("recursion", "fn f(x) { f(x + 1) + 1 }\nf(0)\n;"),
("allocation", "let a = []\nfor i in 0..100000000 { a.push({x: i}) }\n;"),
] {
let vm = &mut test_vm();
let limits = EvalLimits { heap_bytes: 8 << 20, ..EvalLimits::DOC_LOAD.with_instructions(5_000_000) };
let t0 = Instant::now();
let (_, report) = vm.eval_bounded(&limits, |vm| vm.eval(script(name, code)));
assert!(report.limit.is_some(), "{name}: no limit reported ({report:?}, errors {:?})", vm.take_errors());
assert!(t0.elapsed() < Duration::from_secs(5), "{name} took {:?}", t0.elapsed());
}
}
#[test]
fn a_bounded_evaluation_has_no_wall_clock_limit() {
// A busy machine never fails a correct document: no run budget is
// installed, and the host's own (none here) is left as it was.
let vm = &mut test_vm();
let (_, report) = vm.eval_bounded(&EvalLimits::DOC_FRAME, |vm| {
assert!(vm.bx.run_budget.is_none());
vm.eval(script("busy", "var n = 0\nfor i in 0..100000 { n = n + i }\nn"))
});
assert!(report.limit.is_none(), "{report:?}");
assert!(vm.bx.run_budget.is_none());
}
const DOC: &str = "
var count = 0
let state = {n: 0}
let list = [1, 2, 3]
let base = 10
{
pure: fn(t) { let o = {v: base + t} o.v * 2 }
counter: fn(t) { count = count + 1 count }
poke: fn(t) { state.n = t state.n }
grow: fn(t) { list.push(t) list.len() }
fresh: fn(t) { let a = [] a.push(t) a.push(base) a.len() + t }
}
";
#[test]
fn a_frozen_document_cannot_keep_state_between_calls() {
let vm = &mut test_vm();
let (root, report) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("doc", DOC)));
assert!(report.limit.is_none() && vm.take_errors().is_empty());
let (objects, _) = vm.freeze_document(&[root]);
assert!(objects > 3, "froze {objects} objects");
// Pure calls work and give the same result whatever the order.
let pure = |vm: &mut ScriptVm, t: f64| call(vm, root, id!(pure), &[t.into()]).0.as_number();
let forward: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| pure(vm, *t)).collect();
let backward: Vec<_> = [2.0, 1.0, 0.0].iter().map(|t| pure(vm, *t)).rev().collect();
assert_eq!(forward, vec![Some(20.0), Some(22.0), Some(24.0)]);
assert_eq!(forward, backward);
// Fresh containers made inside a call are the call's own.
assert_eq!(call(vm, root, id!(fresh), &[1.0f64.into()]).0.as_number(), Some(3.0));
// Writes into the document are errors, and repeat calls see no change.
for name in [id!(counter), id!(poke), id!(grow)] {
let (first, _, errors) = call(vm, root, name, &[5.0f64.into()]);
assert!(!errors.is_empty(), "{name}: the write into the frozen document was not refused (got {first:?})");
let (_, _, again) = call(vm, root, name, &[6.0f64.into()]);
assert!(!again.is_empty());
}
let state = vm.bx.heap.value(root.as_object().unwrap(), id!(poke).into(), NoTrap);
assert!(state.as_object().is_some());
}
#[test]
fn freezing_a_document_leaves_the_host_modules_alone() {
let vm = &mut test_vm();
let (root, _) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("doc2", "use mod.math.*\n{ f: fn(t) { sin(t) + abs(t) } }")));
vm.freeze_document(&[root]);
// A later document in the same VM still builds and mutates its own state.
let (v, report) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("doc3", "use mod.math.*\nvar a = [] a.push(abs(-2)) let o = {x: 1} o.x = 3 a[0] + o.x")));
assert!(report.limit.is_none(), "{report:?}");
assert!(vm.take_errors().is_empty());
assert_eq!(v.as_number(), Some(5.0));
assert_eq!(call(vm, root, id!(f), &[0.0f64.into()]).0.as_number(), Some(0.0));
}
#[test]
fn a_document_that_holds_a_module_leaves_it_open_for_later_registration() {
// `use mod.lateshaders` puts the module itself in the scope the
// document's closures capture. The host registers into it after the
// document loaded (a kit built on first use adds its shaders), so
// freezing the document must leave the module writable.
let vm = &mut test_vm();
vm.new_module(id!(lateshaders));
let (_, report) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("late_first", "mod.lateshaders.Base = {x: 1}\n;")));
assert!(report.limit.is_none() && vm.take_errors().is_empty());
let doc = "use mod.lateshaders\nlet state = {n: 0}\n{ frame: fn(t) { lateshaders.Base.x + t } poke: fn(t) { state.n = t state.n } }";
let (root, report) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("late_doc", doc)));
assert!(report.limit.is_none(), "{report:?}");
assert!(vm.take_errors().is_empty());
vm.freeze_document(&[root]);
let (v, _) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("late_second", "mod.lateshaders.Late = {y: 2}\nmod.lateshaders.Late.y")));
assert_eq!(vm.take_errors(), Vec::<String>::new(), "the registration after the document was refused");
assert_eq!(v.as_number(), Some(2.0));
// The document still reads the module, and its own state stays frozen.
assert_eq!(call(vm, root, id!(frame), &[1.0f64.into()]).0.as_number(), Some(2.0));
let (_, _, errors) = call(vm, root, id!(poke), &[5.0f64.into()]);
assert!(!errors.is_empty(), "the write into the document's own state was not refused");
}
#[test]
fn frame_limits_bound_each_call() {
let vm = &mut test_vm();
let (root, _) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("doc4", "{ spin: fn(t) { loop {} } fine: fn(t) { t + 1 } }")));
vm.freeze_document(&[root]);
let t0 = Instant::now();
let (_, report, _) = call(vm, root, id!(spin), &[0.0f64.into()]);
assert!(report.limit.is_some());
assert!(t0.elapsed() < Duration::from_secs(2));
// The VM is usable after a limit.
let (v, report, errors) = call(vm, root, id!(fine), &[1.0f64.into()]);
assert_eq!(v.as_number(), Some(2.0), "{report:?} {errors:?}");
}
#[test]
fn host_natives_that_build_strings_work_under_the_bounds() {
// A host native with no allocation preflight: an Octoscript budget
// refuses it; a document evaluation charges its real length instead.
let vm = &mut test_vm();
let module = vm.new_module(id!(doctest));
vm.add_method(module, id!(greet), script_args!(), |vm, _args| vm.new_string_with(|_, s| s.push_str("hello, document")));
let (v, report) = vm.eval_bounded(&EvalLimits::DOC_LOAD, |vm| vm.eval(script("strings", "use mod.doctest.*\ngreet()")));
assert!(report.limit.is_none(), "{report:?} {:?}", vm.take_errors());
assert_eq!(vm.string_with(v, |_, s| s.to_string()).as_deref(), Some("hello, document"));
let (_, octo) = vm.with_heap_allocation_limit(1 << 20, |vm| vm.eval(script("strings2", "use mod.doctest.*\ngreet()")));
assert!(octo.exceeded, "outside eval_bounded the strict budget still refuses it");
}