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..31dc47a26 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1478,12 +1478,14 @@ 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. - **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.