Plug-ins: compiling a plug-in's code takes none of a run's instructions

wasmi compiles each function of a plug-in when it is first called and,
by default, charges the run that calls it 7 instructions for each byte
of the function. The host hands a run its budget in 20 M-instruction
slices and resumes it when a slice runs out. But when a slice runs out
while a function compiles, wasmi returns an error from the call, not an
out-of-fuel pause, and the host reports it as Limit("instruction
budget"). So a plug-in's first run could fail with most of its budget
left, depending on where the slices fell, and the next run, with its
functions compiled, succeeded.

StitchCraft's PES exporter hit it on a design of 98 × 148 mm. In
VectorCraft 0.17.0's host its first export failed with 10 G and with
275 G instructions alike, and its second succeeded, taking under 1 G.
Run on wasmi directly, with the host's slices, the first export failed
in its 36th slice ("required_fuel=174139"), and with compiling free it
finished in 49.

Compiling now costs nothing: wasmi's fuel costs with no cost for
translating or validating, and its default cost for copying. A run pays
for its own instructions only, as many on its first run as on the next.
Compiling every function when the plug-in loads (CompilationMode::Eager)
also fixes it, but loads a 1.4 MB module in 42 ms instead of 10.

The new test fails without the change: a run that takes 1,040
instructions once its functions are compiled failed within them on a
plug-in just loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
This commit is contained in:
Louise Fox
2026-10-26 08:18:24 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent bb45038617
commit 0bd011bd18
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.