From 0bd011bd1806a9771243e4fc9b92b860a4dead9e Mon Sep 17 00:00:00 2001 From: Louise Fox <2503492+KaboomFox@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:27:52 +0000 Subject: [PATCH] Plug-ins: compiling a plug-in's code takes none of a run's instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un --- crates/plugins/src/runtime.rs | 18 +++++++++++++++--- crates/plugins/tests/abi.rs | 26 ++++++++++++++++++++++++++ docs/plugins.md | 5 +++++ 3 files changed, 46 insertions(+), 3 deletions(-) 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.