Plug-ins: heavy filters, allowed at install and run in the background (G17)

A filter plug-in gets 50 M instructions and a minute for each run.
Filters that order a plotter's paths, pack shapes or run a simulation
need minutes, and the app shouldn't freeze while they compute.

A filter's manifest may now ask for "budget": "heavy" (Budget): each run
gets HEAVY_FUEL (1 G instructions) and HEAVY_WALL_TIME_MS (300 s). Only a
filter may ask. plugin.install refuses such a filter without allowHeavy:
true, and plugin.inspect returns the entry a module would have without
installing it. Install Plug-in… and File › Open ask the user and install
on OK; the plug-in store keeps the answer, and folder loads skip heavy
filters with a note, since they ask nobody.

The app runs a heavy filter on a thread of its own (plugin_runs), not in
Background Save's queue, with the status bar and a progress window
(Cancel, Continue in Background) that it shares with Background Export
(export_progress::window). FilterRun splits plugin.run: prepared on the
session, run on any thread, its output handed in, and the next
plugin.run with the same params applies that output instead of running
the plug-in again, so the edit stays one guarded, journaled command. A
result whose document or plug-in changed meanwhile is dropped, and the
status bar says to run it again. A cancelled plug-in run is now
EngineError::Cancelled.

What a filter keeps with the document (#3720) goes with the run: it is
given _context.documentData when the run is prepared, and the
documentData of its output is applied with its objects, in the same undo
step, when the run is handed in (FilterRun::finish).

Tests: plugins/tests/abi.rs (100 M loop steps out of a light budget,
within a heavy one; folders skip heavy filters), manifest.rs, engine
tests_plugins.rs (consent, inspect, the store, hand-in, a handed-in
run's document data, cancel) and
ui-egui tests_plugin_runs.rs (installing asks, no Preview, a background
run as one undo step, a stale result kept out, the progress window).
CRAFT_UI_HEAVY_FILTER_WASM=<folder> writes a test filter to try in the
app. Its strings and messages have rows in each complete catalog
(COMPLETE_MESSAGES).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
This commit is contained in:
Louise Fox
2026-10-23 08:18:47 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 3e0c1d6870
commit 79cb38f678
46 changed files with 1372 additions and 112 deletions
+4 -3
View File
@@ -6,7 +6,8 @@
//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that
//! imports anything is rejected;
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
//! also enforced (native builds), so infinite loops end with an error;
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
//! - a **fresh instance per run**, so no state leaks between calls.
//!
@@ -33,8 +34,8 @@ mod runtime;
pub mod wat;
pub use dialog::ParamUi;
pub use manifest::{Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, Limits, Plugin};
pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`]).
+58
View File
@@ -45,6 +45,27 @@ impl Kind {
}
}
/// How much one run of a plug-in may compute.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Budget {
/// The usual budget ([`crate::Limits::default`]).
#[default]
Light,
/// A filter's larger budget for heavy but legitimate work (routing, packing, simulation):
/// [`crate::HEAVY_FUEL`] instructions and [`crate::HEAVY_WALL_TIME_MS`]. The
/// user allows it once, when the plug-in is installed, and the app runs it in the background
/// with Cancel.
Heavy,
}
impl Budget {
/// The usual budget, which a manifest written back leaves out (`skip_serializing_if`).
fn is_light(&self) -> bool {
*self == Budget::Light
}
}
/// One declared parameter.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -191,6 +212,9 @@ pub struct Manifest {
/// files get. Only exporters declare them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
/// How much a run may compute: only a filter may be [`Budget::Heavy`].
#[serde(default, skip_serializing_if = "Budget::is_light")]
pub budget: Budget,
}
impl Manifest {
@@ -216,9 +240,12 @@ impl Manifest {
author: String::new(),
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
}
}
/// Checks what the module declared: its id, name and text lengths, its extensions, a budget its
/// kind may have (only a filter may be heavy), and each parameter and its dialog layout.
fn validate(&self) -> Result<()> {
if !valid_id(&self.id) {
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
@@ -231,6 +258,15 @@ impl Manifest {
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
}
self.validate_extensions()?;
match (self.budget, self.kind) {
(Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {}
(Budget::Heavy, Kind::Effect) => {
return Err(Error::Manifest("a live effect runs while the document is drawn, so it can't be `heavy`".into()));
}
(Budget::Heavy, Kind::Exporter) => {
return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into()));
}
}
for (i, (k, p)) in self.params.iter().enumerate() {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
@@ -501,6 +537,28 @@ mod tests {
}
}
/// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an
/// exporter has an export's budget, so they can't. Light, the default, isn't written back.
#[test]
fn only_a_filter_asks_for_a_heavy_budget() {
let heavy = Manifest::parse(br#"{"id":"org.example.route","name":"Route","kind":"filter","budget":"heavy"}"#).unwrap();
assert_eq!(heavy.budget, Budget::Heavy);
assert_eq!(serde_json::to_value(&heavy).unwrap()["budget"], json!("heavy"));
for light in [r#"{"id":"a","name":"x","kind":"filter"}"#, r#"{"id":"a","name":"x","kind":"effect","budget":"light"}"#] {
let m = Manifest::parse(light.as_bytes()).unwrap();
assert_eq!(m.budget, Budget::Light);
assert!(serde_json::to_value(&m).unwrap().get("budget").is_none(), "{light}");
}
for bad in [
r#"{"id":"a","name":"x","kind":"effect","budget":"heavy"}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"budget":"heavy"}"#,
r#"{"id":"a","name":"x","kind":"filter","budget":"huge"}"#,
r#"{"id":"a","name":"x","kind":"filter","budget":true}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
}
#[test]
fn strings_and_number_lists_resolve_like_the_other_types() {
let m = Manifest::parse(
+6 -1
View File
@@ -92,7 +92,8 @@ pub struct FolderReport {
}
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
/// reported and skipped; the others still load.
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
/// which installs only once the user agrees.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
load_folder_where(dir, |_| Ok(()))
@@ -114,6 +115,10 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path)
continue;
}
match load_file(&f, Limits::default()) {
// A heavy plug-in installs once the user agrees, which loading a folder doesn't ask.
Ok(p) if p.manifest().budget == crate::Budget::Heavy => {
report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into()));
}
Ok(p) => report.loaded.push(install(p).id().to_string()),
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
}
+20
View File
@@ -14,6 +14,20 @@ pub const ABI_VERSION: i32 = 1;
/// Fuel handed out per slice; between slices the host checks the deadline.
const FUEL_SLICE: u64 = 20_000_000;
/// Instructions a heavy filter's run may use besides [`Limits::fuel_per_byte`] for each byte of
/// input ([`crate::Budget::Heavy`]): 20 times a light one's. Its output's size can't add to it:
/// the host learns that only once the run has ended.
pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// A heavy run's budget as people read it: (minutes it may take, times the instructions a light
/// run gets).
pub fn heavy_terms() -> (u64, u64) {
(HEAVY_WALL_TIME_MS / 60_000, HEAVY_FUEL / Limits::default().fuel_base.max(1))
}
/// Resource limits for running plug-ins.
#[derive(Clone, Debug, PartialEq)]
pub struct Limits {
@@ -156,6 +170,12 @@ impl Plugin {
e => e,
})?;
p.manifest = Manifest::parse(&text)?;
// A heavy filter's runs get the larger budget, which the host lets the user allow or
// refuse before installing it.
if p.manifest.budget == crate::Budget::Heavy {
p.limits.fuel_base = p.limits.fuel_base.max(HEAVY_FUEL);
p.limits.wall_time_ms = p.limits.wall_time_ms.max(HEAVY_WALL_TIME_MS);
}
// The other exports must exist with the right types.
live.func::<i32, i32>("vc_alloc")?;
live.func::<RunArgs, i64>("vc_run")?;
+21 -1
View File
@@ -36,11 +36,31 @@ pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64
/// A plug-in that always returns `output` (whatever it is given).
pub fn returning(manifest: &str, output: &str) -> String {
let run = format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
then_returning(manifest, "", output)
}
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
/// returns `output`.
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
/// that runs until it is cancelled.
pub fn spinning(manifest: &str, steps: u64, output: &str) -> String {
let spin = format!(
"(local $n i64) (local.set $n (i64.const {steps}))
(block $done (loop $step
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
(br $step)))"
);
then_returning(manifest, &spin, output)
}
/// What [`when_given`] looks through.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Given {