mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
3e0c1d6870
commit
79cb38f678
@@ -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`]).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user