mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user