From 1bcd19e6b66cce40404a82153f432167dda1843a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:19:39 -0800 Subject: [PATCH 1/2] 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. --- crates/plugins/src/runtime.rs | 16 ++++++++++---- crates/plugins/tests/abi.rs | 39 +++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 31adb7a2b..e6c289b8f 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -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(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { 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)?; } } diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 5f2820a5e..f5b78d224 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -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 { + 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> { + 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)"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 10728b332..1dca69fa3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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. From 9ebf258b7cfb9559fa98389219c50dabd86397b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:24:25 -0800 Subject: [PATCH 2/2] Docs: list the bulk memory fuel line beside the exporter plug-ins entry --- docs/target-app-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1dca69fa3..31dc47a26 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1478,6 +1478,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Envelope names given as another type:** `object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` or `orientation` other than text or null, such as `5`, and an `orientation` other than `horizontal` or `vertical`; `object.envelope.options` refuses such a `preserveShape`. `object.envelope.options` checks the warp keys with no envelope or only mesh envelopes selected, too. Each leaves the document, the options new envelopes get and the undo history as they were. - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.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 parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged.