diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index eeb397b62..3a72b70d7 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -1,8 +1,8 @@ //! The wasmi host: loading, instantiating and calling a plug-in under resource limits. use wasmi::{ - Config, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, TypedResumableCall, - WasmParams, WasmResults, + Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, + TypedResumableCall, WasmParams, WasmResults, }; use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; @@ -14,6 +14,14 @@ pub const ABI_VERSION: i32 = 1; /// Fuel handed out per slice; between slices the host checks the deadline. const FUEL_SLICE: u64 = 20_000_000; +/// wasmi's fuel costs, but compiling costs nothing. wasmi compiles a function when it is first +/// called, and charges the run that calls it by the function's size. When the slice a run was in +/// ran out meanwhile, wasmi ended the run with an error, not an out-of-fuel pause the host resumes: +/// a first run failed with `Limit("instruction budget")` and most of its budget left, as where the +/// slices fell decided. So a run pays for its own instructions only, as many on a first run as on +/// the next. Copying keeps wasmi's default cost, a unit for every 64 bytes. +const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64, fuel_per_bytes_translated: 0, fuel_per_bytes_validated: 0 }; + /// Instructions a heavy filter's run may use besides [`Limits::fuel_per_byte`] for each byte of /// input ([`crate::Budget::Heavy`]): 20 times a light one's. Its output's size can't add to it: /// the host learns that only once the run has ended. @@ -152,7 +160,11 @@ impl Plugin { return Err(Error::Module("not a WebAssembly binary (missing \\0asm header)".into())); } let mut config = Config::default(); - config.consume_fuel(true).enforced_limits(EnforcedLimits::strict()).set_max_recursion_depth(limits.max_recursion_depth.max(16)); + config + .consume_fuel(true) + .fuel_cost(FUEL_COSTS) + .enforced_limits(EnforcedLimits::strict()) + .set_max_recursion_depth(limits.max_recursion_depth.max(16)); let engine = Engine::new(&config); let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?; if let Some(imp) = module.imports().next() { diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 1ca9df87a..5fbb694d5 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -164,6 +164,9 @@ fn wall_clock_budget_stops_runaway_plugins() { assert!(t < Duration::from_secs(3), "{t:?}"); } +/// Cancelling stops a run where one slice of its budget ends: an endless loop given all but +/// unlimited instructions comes back `Cancelled` within seconds, where outside a cancelled scope the +/// same module runs until its budget is spent. #[test] fn cancelling_stops_a_run_between_two_slices_of_its_budget() { let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); @@ -179,6 +182,29 @@ fn cancelling_stops_a_run_between_two_slices_of_its_budget() { assert_eq!(e, Error::Limit("instruction budget".into())); } +/// Compiling a plug-in's code takes none of a run's instructions, so a first run takes as many as the +/// next, though wasmi compiles each function only when it is first called. When the compiling was the +/// run's to pay for, and the slice of the budget the run was in ran out while a function compiled, +/// wasmi ended the run with an error the host can't resume: it failed with most of its budget left. +/// Here `vc_run` calls a function about 100 KB long, a `br_table` of 100,000 labels. +#[test] +fn a_first_run_takes_no_more_instructions_than_the_next() { + let big = format!("(func $big (param $i i32) (block (br_table {}0 (local.get $i))))", "0 ".repeat(100_000)); + let calling = module(MANIFEST, &format!("(call $big (i32.const 0)) {}", tpl::ECHO), &big); + let with = |fuel_base| Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() }; + let warm = Plugin::load(&calling, Limits::default()).unwrap(); + assert_eq!(warm.run(INPUT.as_bytes(), b"{}"), Ok(INPUT.as_bytes().to_vec())); + // The fewest instructions a run takes once the functions are compiled. + let (mut lo, mut hi) = (0, 1 << 20); + assert!(warm.run_limited(INPUT.as_bytes(), b"{}", &with(hi)).is_ok()); + while hi - lo > 1 { + let mid = (lo + hi) / 2; + if warm.run_limited(INPUT.as_bytes(), b"{}", &with(mid)).is_ok() { hi = mid } else { lo = mid } + } + let first = Plugin::load(&calling, Limits::default()).unwrap(); + assert_eq!(first.run_limited(INPUT.as_bytes(), b"{}", &with(hi)), Ok(INPUT.as_bytes().to_vec()), "a first run within {hi} instructions"); +} + /// A filter that asks for a heavy budget gets 20 times the instructions and five times the time of /// a light one, so it finishes work a light one can't: here 100 million steps of a loop. The /// budget is its own: what it returns can't add to it, since the host learns that only at the end. diff --git a/docs/plugins.md b/docs/plugins.md index 40e53a0dd..298a44b85 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -109,6 +109,11 @@ wasmi is built with its *portable* (loop) dispatcher: the default tail-call disp sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts the process instead of returning an error. +wasmi compiles each function when it is first called, and the instruction budget doesn't pay for that, so a run +takes as many instructions the first time as the next. Charged to the run, as wasmi charges by default, compiling +could fail it: when the 20 M-instruction slice the run was in ran out while a function compiled, wasmi ended the run +with an error rather than a pause the host resumes, and the run stopped with most of its budget left. + Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters and input geometry: a plug-in runs again only when one of them changes.