Merge pull request #4190 from KaboomFox/plugin-eager-compile

Plug-ins: compiling a plug-in's code takes none of a run's instructions
This commit is contained in:
BFlat
2026-10-26 08:40:55 +07:00
committed by GitHub
3 changed files with 46 additions and 3 deletions
+15 -3
View File
@@ -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() {
+26
View File
@@ -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.
+5
View File
@@ -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.