mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -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
@@ -441,3 +441,142 @@ fn installed_plugins_are_kept_in_the_store() {
|
||||
third.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
}
|
||||
|
||||
/// A heavy test filter `id` that counts `steps` down, then adds a triangle named "Plug-in".
|
||||
fn heavy(id: &str, steps: u64) -> Vec<u8> {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||||
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
|
||||
wat::parse_str(tpl::spinning(&manifest, steps, &output.to_string())).unwrap()
|
||||
}
|
||||
|
||||
/// A filter that asks for a heavy budget installs only once the user agrees (`allowHeavy`);
|
||||
/// `plugin.inspect` tells whoever installs it so beforehand, and installs nothing.
|
||||
#[test]
|
||||
fn a_heavy_filter_installs_once_the_user_agrees() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.heavy";
|
||||
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
|
||||
// With the file's name, as the app passes it.
|
||||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data, "name": "route.wasm"}));
|
||||
assert_eq!((inspected["id"].as_str(), inspected["budget"].as_str()), (Some(id), Some("heavy")));
|
||||
assert_eq!(inspected["source"], "route.wasm");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("heavy budget") && e.contains("5 minutes") && e.contains("allowHeavy"), "{e}");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
let info = run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||||
assert_eq!(info["budget"], "heavy");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
|
||||
let listed = run(&mut s, "plugin.list", json!({}));
|
||||
assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["id"] == id && p["budget"] == "heavy"));
|
||||
// A light plug-in's entry says nothing of budgets.
|
||||
assert!(install(&mut s, DESATURATE).get("budget").is_none());
|
||||
}
|
||||
|
||||
/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next
|
||||
/// start without asking, with its budget.
|
||||
#[test]
|
||||
fn a_stored_heavy_filter_keeps_its_budget() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-heavy");
|
||||
let id = "test.engine.storedheavy";
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
let plugin = vectorcraft_plugins::registry::get(id).expect("installed again from the store");
|
||||
assert_eq!(plugin.limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
|
||||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// The app runs a heavy filter away from the session ([`cmd::plugin::FilterRun`]) and hands its
|
||||
/// output in: `plugin.run` with the same params applies it, as one undo step, without running the
|
||||
/// plug-in again. Only once, and only for the same run: objects that changed since, or the plug-in
|
||||
/// installed again, run the plug-in instead.
|
||||
#[test]
|
||||
fn a_filter_run_made_elsewhere_is_applied_by_plugin_run() {
|
||||
use cmd::plugin::FilterRun;
|
||||
let mut s = session();
|
||||
let id = "test.engine.handin";
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||||
let p = json!({"id": id, "ids": []});
|
||||
let named = |s: &Session, ids: &Value| s.doc().unwrap().doc.node(NodeId(ids[0].as_u64().unwrap())).unwrap().name.clone();
|
||||
// What it computed, as the app hands it in; here marked, so the test sees which output applied.
|
||||
let r = FilterRun::prepare(&s, &p).unwrap();
|
||||
assert!(r.current(&s));
|
||||
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Plug-in", "Handed in");
|
||||
r.hand_in(&mut s, output.clone().into_bytes());
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
|
||||
assert_eq!(named(&s, &made).as_deref(), Some("Handed in"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Once only: the next run runs the plug-in.
|
||||
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
|
||||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"));
|
||||
// Made for objects that changed since, it isn't theirs.
|
||||
let obj = rect(&mut s, 10.0, "#ff0000");
|
||||
let on = json!({"id": id, "ids": [obj.0]});
|
||||
let stale = FilterRun::prepare(&s, &on).unwrap();
|
||||
run(&mut s, "paint.setFill", json!({"ids": [obj.0], "color": "#00ff00"}));
|
||||
assert!(!stale.current(&s), "the document changed");
|
||||
stale.hand_in(&mut s, output.clone().into_bytes());
|
||||
let made = run(&mut s, "plugin.run", on)["ids"].clone();
|
||||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made for objects that changed since");
|
||||
// A plug-in installed again is another one.
|
||||
let again = FilterRun::prepare(&s, &p).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||||
assert!(!again.current(&s));
|
||||
again.hand_in(&mut s, output.into_bytes());
|
||||
let made = run(&mut s, "plugin.run", p)["ids"].clone();
|
||||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made by the plug-in installed before");
|
||||
}
|
||||
|
||||
/// A heavy filter run away from the session reads what it keeps with the document
|
||||
/// (`_context.documentData`) and hands its `documentData` back with its output: `plugin.run`
|
||||
/// applies both as one undo step.
|
||||
#[test]
|
||||
fn a_filter_run_made_elsewhere_keeps_its_document_data() {
|
||||
use cmd::plugin::FilterRun;
|
||||
let mut s = session();
|
||||
let id = "test.engine.handindata";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||||
let install = |s: &mut Session, src: String| {
|
||||
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
|
||||
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||||
};
|
||||
// It first keeps the pens it found with the document.
|
||||
install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string()));
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
// Given those pens, it adds one, in the background; the output handed in is marked, so the
|
||||
// test sees it is the one that applied.
|
||||
let next = json!({"objects": [{"path": triangle(), "name": "Pen 3"}], "documentData": {"pens": 3}}).to_string();
|
||||
install(&mut s, tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":{"pens":2}"#, &next));
|
||||
let p = json!({"id": id, "ids": []});
|
||||
let r = FilterRun::prepare(&s, &p).unwrap();
|
||||
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Pen 3", "Handed in");
|
||||
r.hand_in(&mut s, output.into_bytes());
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let made = run(&mut s, "plugin.run", p)["ids"].clone();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.node(NodeId(made[0].as_u64().unwrap())).unwrap().name.as_deref(), Some("Handed in"));
|
||||
assert_eq!(st.doc.plugin_data(id), Some(&json!({"pens": 3})));
|
||||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
|
||||
}
|
||||
|
||||
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
|
||||
/// two slices of its budget and changes nothing.
|
||||
#[test]
|
||||
fn a_cancelled_filter_run_changes_nothing() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.cancelled";
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1 << 40)), "allowHeavy": true}));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
token.cancel();
|
||||
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("plugin.run", &json!({"id": id})));
|
||||
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user