Plug-ins: a run keeps the fuel left in a slice at a bulk memory instruction

wasmi pauses a run before an instruction the store's fuel can't pay for
and charges nothing for it. A memory.fill or memory.copy is charged for
its whole length, so a pause can leave up to that cost in the store.
Plugin::call replaced the store's fuel with the next slice and compared
the instruction's cost with the rest of the budget alone, so a run whose
budget covered its work could end with Limit("instruction budget").

Each pause now tops the store up to the slice size, keeping what it
held, and ends the run only when the store's fuel and the rest of the
budget together can't pay for the next instruction.
This commit is contained in:
dotCooCoo
2026-11-08 13:23:34 +07:00
committed by bflatastic
parent c85b286994
commit 1bcd19e6b6
3 changed files with 52 additions and 4 deletions
+12 -4
View File
@@ -239,6 +239,12 @@ impl Plugin {
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline and the
/// caller's cancellation ([`vectorcraft_doc::cancel`]) between them.
///
/// wasmi pauses before an instruction that the store's fuel can't pay for, and charges nothing
/// for it. It charges a `memory.fill` or `memory.copy` for its whole length before writing, so
/// up to that cost can be left in the store at a pause. Each slice tops the store up to the
/// slice size, keeping that fuel, and a run ends with the instruction budget only when the
/// store's fuel and the rest of the budget together can't pay for the next instruction.
fn call<P: WasmParams, R: WasmResults>(&self, live: &mut Live, f: &TypedFunc<P, R>, params: P, fuel: u64, deadline: &Deadline) -> Result<R> {
let mut left = fuel;
let give = left.min(FUEL_SLICE);
@@ -251,14 +257,16 @@ impl Plugin {
TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())),
TypedResumableCall::OutOfFuel(inv) => {
let need = inv.required_fuel();
if left == 0 || need > left {
let held = live.store.get_fuel().map_err(wasm_err)?;
let total = held.saturating_add(left);
if need > total {
return Err(Error::Limit("instruction budget".into()));
}
deadline.check()?;
vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?;
let give = left.min(FUEL_SLICE.max(need));
left -= give;
live.store.set_fuel(give).map_err(wasm_err)?;
let fill = total.min(FUEL_SLICE.max(need)).max(held);
left -= fill - held;
live.store.set_fuel(fill).map_err(wasm_err)?;
call = inv.resume(&mut live.store).map_err(wasm_err)?;
}
}
+39
View File
@@ -155,6 +155,45 @@ fn budgets_stop_runaway_plugins() {
assert_eq!(e, Error::Limit("instruction budget".into()));
}
/// A filter that runs `op`, an 8 MiB bulk memory instruction costing about 131,000 instructions,
/// 160 times (about 21 million instructions in all), or forever when `endless`.
fn bulk_module(op: &str, endless: bool) -> Vec<u8> {
let bulk = match op {
"fill" => "(memory.fill (i32.const 1024) (i32.const 7) (i32.const 8388608))",
"copy" => "(memory.copy (i32.const 1024) (i32.const 2048) (i32.const 8387584))",
_ => panic!("no bulk operation `{op}`"),
};
let run = if endless {
format!("(loop $work {bulk} (br $work)) {}", tpl::ECHO)
} else {
format!(
"(local $n i32) (local.set $n (i32.const 160)) \
(loop $work {bulk} (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}",
tpl::ECHO
)
};
build(&tpl::module(MANIFEST, &run, "", 130))
}
fn bulk_run(op: &str, endless: bool, fuel_base: u64) -> vectorcraft_plugins::Result<Vec<u8>> {
let p = Plugin::load(&bulk_module(op, endless), Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() })?;
p.run(INPUT.as_bytes(), b"{}")
}
/// A bulk memory instruction that the fuel left in a slice of the budget can't pay for pauses the
/// run without using that fuel, and the run keeps it: 160 fills or copies that cross the first
/// slice finish within 21 million instructions, though the pause left about 75,000 of them in the
/// slice. With fewer instructions than the work takes, or with a loop that never ends, the run
/// still ends with the instruction budget.
#[test]
fn a_bulk_memory_instruction_at_the_end_of_a_slice_keeps_the_run_s_fuel() {
for op in ["fill", "copy"] {
assert_eq!(bulk_run(op, false, 21_000_000), Ok(INPUT.as_bytes().to_vec()), "{op} within its budget");
assert_eq!(bulk_run(op, false, 15_000_000), Err(Error::Limit("instruction budget".into())), "{op} over its budget");
assert_eq!(bulk_run(op, true, 21_000_000), Err(Error::Limit("instruction budget".into())), "endless {op}");
}
}
#[test]
fn wall_clock_budget_stops_runaway_plugins() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
+1
View File
@@ -1484,6 +1484,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.