164 lines
7.9 KiB
Rust
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");
|
|
}
|