fix(plugin): meter every host call and copied byte (#221)

Every plugin import now counts as a host call (reply_len, reply_read and set_output were free), and bytes copied out of or into the plugin's memory spend fuel, reserved before the copy or allocation, including when call overhead is set to zero. Fixes #192.

The log byte budget test from #216 runs with the default fuel, since copying a megabyte of log bytes is itself charged now.
This commit is contained in:
Sir Clangington III.
2026-10-14 07:42:06 +07:00
committed by GitHub
parent db55720c93
commit 79a0e58a3c
4 changed files with 71 additions and 14 deletions
+3 -1
View File
@@ -114,7 +114,7 @@ fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
let (layers, undo) = (layer_count(&s), undo_len(&s));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
assert_eq!(out["calls"], json!(4));
assert_eq!(out["calls"], json!(6));
assert_eq!(node(&s, a).name, "Tidy");
assert_eq!(layer_count(&s), layers + 1);
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
@@ -197,9 +197,11 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
let a = rect(&mut s, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
let bulk_output = "(call $set_output (i32.const 0) (i32.const 65536))".repeat(32);
let cases = [
("org.test.trap", "unreachable", "crashed"),
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
("org.test.bulk", bulk_output.as_str(), "instruction budget"),
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
];
for (id, tail, want) in cases {
+23 -10
View File
@@ -63,7 +63,8 @@ const IMPORTS: &[(&str, usize, usize)] = &[
/// What a run may use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more.
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more,
/// plus one fuel per byte copied out of or into guest memory.
pub fuel: u64,
pub call_fuel: u64,
/// Linear memory cap in bytes.
@@ -281,10 +282,10 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, Plu
}
}
let export = |name: &str| module.exports().find(|e| e.name() == name).map(|e| e.ty().clone());
if !export("memory").is_some_and(|t| t.memory().is_some()) {
if export("memory").is_none_or(|t| t.memory().is_none()) {
return Err(PluginError::InvalidModule("it doesn't export its `memory`".into()));
}
if !export(ENTRY).is_some_and(|t| t.func() == Some(&func_type(1, 1))) {
if export(ENTRY).is_none_or(|t| t.func() != Some(&func_type(1, 1))) {
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
}
Ok(module)
@@ -399,11 +400,7 @@ fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
/// Charge a host call: fuel and the call count.
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
let cost = caller.data().limits.call_fuel;
let left = caller.get_fuel()?;
if left < cost {
return Err(abort(caller, PluginError::OutOfFuel));
}
caller.set_fuel(left - cost)?;
spend_fuel(caller, cost)?;
let st = caller.data_mut();
st.out.calls = st.out.calls.saturating_add(1);
if st.out.calls > st.limits.max_calls {
@@ -413,12 +410,21 @@ fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
Ok(())
}
/// Reserve work before copying or allocating, including when call overhead is disabled.
fn spend_fuel(caller: &mut Ctx<'_>, cost: u64) -> Result<(), wasmi::Error> {
let Some(left) = caller.get_fuel()?.checked_sub(cost) else {
return Err(abort(caller, PluginError::OutOfFuel));
};
caller.set_fuel(left)
}
/// The bytes at `(ptr, len)` in the plugin's memory.
fn read_bytes(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
if len > caller.data().limits.max_message {
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
}
spend_fuel(caller, len as u64)?;
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
};
@@ -472,14 +478,20 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
Err(ask(&mut c, Request::Selection))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_len", |mut c: Ctx<'_>| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
Ok(i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
let n = want.min(c.data().reply.len());
spend_fuel(&mut c, n as u64)?;
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
};
let (data, st) = mem.data_and_store_mut(&mut c);
let n = want.min(st.reply.len());
let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?;
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
dst.copy_from_slice(src);
@@ -500,6 +512,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
charge(&mut c)?;
let out = read_str(&mut c, ptr, len)?;
c.data_mut().out.output = out;
Ok(())
+39 -2
View File
@@ -81,7 +81,7 @@ fn echoes_input_calls_commands_and_reads_replies() {
assert_eq!(host.logs, out.logs);
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
assert_eq!(host.notes, vec!["made it".to_string()]);
assert_eq!(out.calls, 3);
assert_eq!(out.calls, 7);
assert!(out.fuel > 0);
}
@@ -142,7 +142,9 @@ fn logs_over_the_default_byte_budget_fail_before_forwarding() {
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $logs (i32.lt_u (local.get $i) (i32.const 129)))) (i32.const 0)",
);
let (result, host) = go(&m, "{}");
// The default fuel: copying a megabyte of log bytes out of the plugin is itself charged.
let mut host = Mock::default();
let result = run(&m, "{}", &Limits::default(), &mut host);
assert!(matches!(result, Err(PluginError::Limit(_))), "logs over 1 MiB must fail the run");
assert_eq!(host.logs.len(), 128);
assert_eq!(host.logs.iter().map(String::len).sum::<usize>(), 1024 * 1024);
@@ -254,6 +256,41 @@ fn logs_still_observe_the_general_message_limit() {
assert!(host.logs.is_empty());
}
#[test]
fn reply_and_output_imports_obey_host_call_limit() {
for call in
["(drop (call $reply_len))", "(drop (call $reply_read (i32.const 0) (i32.const 2)))", "(call $set_output (i32.const 0) (i32.const 2))"]
{
let m = module(r#"(data (i32.const 0) "ok")"#, &format!("{call} {call} (i32.const 0)"));
let limits = Limits { max_calls: 1, ..small() };
assert!(matches!(run(&m, "{}", &limits, &mut Mock::default()), Err(PluginError::Limit(_))), "{call}");
let limits = Limits { max_calls: 2, ..small() };
assert_eq!(run(&m, "{}", &limits, &mut Mock::default()).unwrap().calls, 2, "{call}");
}
}
#[test]
fn repeated_bulk_imports_spend_fuel_for_bytes_even_without_call_overhead() {
for call in ["(drop (call $reply_read (i32.const 0) (i32.const 4096)))", "(call $set_output (i32.const 0) (i32.const 4096))"] {
let m = module("", &format!("{} (i32.const 0)", call.repeat(16)));
let limits = Limits { fuel: 40_000, call_fuel: 0, ..small() };
assert_eq!(run(&m, &"x".repeat(4096), &limits, &mut Mock::default()), Err(PluginError::OutOfFuel), "{call}");
}
}
#[test]
fn reply_copy_charges_actual_bytes_and_each_callback_gets_fresh_limits() {
let m = module("", "(call $set_output (i32.const 0) (call $reply_read (i32.const 0) (i32.const 2147483647))) (i32.const 0)");
let limits = Limits { max_calls: 2, fuel: 1000, call_fuel: 0, ..small() };
let mut instance = crate::Instance::new(&m, &limits).unwrap();
assert_eq!(instance.call(&"x".repeat(2048), &mut Mock::default()), Err(PluginError::OutOfFuel));
for input in ["hello", "again"] {
let out = instance.call(input, &mut Mock::default()).unwrap();
assert_eq!(out.output, input);
assert_eq!(out.calls, 2);
}
}
#[test]
fn memory_is_capped() {
// Declared larger than the cap: refused at instantiation.
+6 -1
View File
@@ -251,12 +251,17 @@ it can handle):
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to
(each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
its images 1 MB each, 2 MB together and 4096 pixels on a side.
Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the
allocation or copy. This also applies when the configured per-call overhead is zero. A reply
read is charged for the bytes actually returned, not the requested buffer size. `calls` counts
every ABI import, including calls which only query the reply length.
## ABI (version 3)
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2