Files
vectorcraft/crates/ui-egui/src/plugin_runs.rs
T
Louise FoxandClaude Opus 5.5 79cb38f678 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
2026-10-23 08:18:47 +07:00

208 lines
8.1 KiB
Rust

//! Heavy plug-in filters, run off the UI thread. A filter whose manifest asks for a heavy budget
//! (`Budget::Heavy`: minutes rather than a minute, for routing, packing or simulation) would freeze
//! the app while it computes, so the app runs it on a thread of its own ([`start`]) while editing
//! goes on: the status bar shows it, and after [`DELAY`] a progress window with Cancel (also Esc,
//! `plugin.cancelRuns`) and Continue in Background (`plugin.hideRuns`). A thread of its own, not
//! the background worker's queue ([`crate::background`]), so saves and exports don't wait behind it.
//!
//! When it is done its output is handed to `plugin.run` with the same parameters and objects
//! ([`FilterRun::hand_in`]), so the edit is one undo step like any run, unless the document or the
//! plug-in changed meanwhile: then nothing changes and the status bar says to run it again. On the
//! web (no threads) it runs at once ([`available`]).
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use serde_json::{Value, json};
use vectorcraft_doc::cancel::{self, CancelToken};
use vectorcraft_engine::EngineError;
use vectorcraft_engine::cmd::plugin::FilterRun;
use crate::VectorcraftApp;
use crate::export_progress::{self, Pressed};
pub use crate::export_progress::DELAY;
/// What a run's thread hands back: the run, and its output or why it has none.
type Done = (FilterRun, Result<Vec<u8>, String>);
/// A heavy filter running.
pub struct Running {
id: u64,
/// What the status bar and the progress window say: `Running “Route Paths”…`.
pub label: String,
/// The filter's name, quoted.
who: String,
/// `plugin.run`'s params that apply its output: the objects it was given as `ids`.
params: Value,
/// Its Cancel.
token: CancelToken,
/// Continue in Background: the progress window no longer shows it.
pub hidden: bool,
done: Receiver<Done>,
}
impl Running {
/// Does the progress window show it: neither hidden (Continue in Background) nor cancelled?
fn shown(&self) -> bool {
!self.hidden && !self.token.is_cancelled()
}
}
/// The heavy filters running.
#[derive(Default)]
pub struct PluginRuns {
pub running: Vec<Running>,
next_id: u64,
}
/// Do heavy filters run off the UI thread here? Not on wasm (no threads).
pub fn available() -> bool {
!cfg!(target_arch = "wasm32")
}
/// Runs filter `p` (`plugin.run`'s params) on a thread of its own → `{background: true, status}`;
/// where no thread can run it, at once (`plugin.run`'s result).
pub fn start(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
if !available() {
return app.run("plugin.run", p);
}
// Bad parameters and a missing document fail here, before anything runs.
let run = FilterRun::prepare(&app.session, &p).map_err(|e| e.to_string())?;
let who = format!("“{}”", run.plugin().manifest().name.trim_end_matches('…'));
let mut params = p;
params["ids"] = json!(run.inputs().iter().map(|i| i.0).collect::<Vec<_>>());
let (tx, done) = channel();
let token = CancelToken::new();
let stop = token.clone();
std::thread::Builder::new()
.name("vectorcraft-plugin".into())
.spawn(move || {
let output = vectorcraft_engine::guard::catch_panic(|| cancel::scope(&stop, None, || run.run()))
.map_err(|msg| format!("internal error: {msg} (please report this bug)"))
.and_then(|r| r.map_err(|e| e.to_string()));
// The app may have quit meanwhile: nothing to hand it to.
let _ = tx.send((run, output));
})
.map_err(|e| e.to_string())?;
let label = format!("Running {who}…");
let runs = &mut app.plugin_runs;
runs.next_id += 1;
runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, token, hidden: false, done });
app.status(label.clone());
Ok(json!({ "background": true, "status": label }))
}
/// Applies the runs that are done (each frame). Not while a dialog is open: its OK acts on the
/// active document, which a run's result changes; the results wait until it is closed.
pub fn poll(app: &mut VectorcraftApp) {
if app.ui.dialog.is_some() {
return;
}
let mut i = 0;
while let Some(r) = app.plugin_runs.running.get(i) {
let done = match r.done.try_recv() {
Ok(done) => Some(done),
Err(TryRecvError::Empty) => {
i += 1;
continue;
}
Err(TryRecvError::Disconnected) => None,
};
let r = app.plugin_runs.running.remove(i);
// The status bar no longer says it is running.
if app.ui.status == r.label {
app.ui.status.clear();
}
finish(app, r, done);
}
}
/// Applies run `r`'s result: its output, as `plugin.run`, while the document and the plug-in are as
/// they were; else why it has none, in the status bar.
fn finish(app: &mut VectorcraftApp, r: Running, done: Option<Done>) {
// Cancel changes nothing, even when the run ended just before it.
if r.token.is_cancelled() {
app.status(EngineError::Cancelled.to_string());
return;
}
let (run, output) = match done {
Some((run, Ok(output))) => (run, output),
Some((_, Err(e))) => {
app.status(format!("{} failed: {e}", r.who));
return;
}
None => {
app.status(format!("{} failed: {e}", r.who, e = "internal error: its thread stopped (please report this bug)"));
return;
}
};
if !run.current(&app.session) {
app.status(format!("{} finished, but the document or the plug-in changed meanwhile: run it again", r.who));
return;
}
run.hand_in(&mut app.session, output);
if let Err(e) = app.run("plugin.run", r.params) {
app.status(format!("{} failed: {e}", r.who));
}
}
/// The progress window's Cancel (also Esc): every heavy filter running stops at the next slice of
/// its budget and changes nothing → `{cancelled: n}`.
pub fn cancel_all(app: &mut VectorcraftApp) -> Value {
let mut n = 0;
for r in app.plugin_runs.running.iter().filter(|r| !r.token.is_cancelled()) {
r.token.cancel();
n += 1;
}
json!({ "cancelled": n })
}
/// The progress window's Continue in Background: the runs go on, shown in the status bar only →
/// `{hidden: n}`.
pub fn hide_all(app: &mut VectorcraftApp) -> Value {
let mut n = 0;
for r in app.plugin_runs.running.iter_mut().filter(|r| r.shown()) {
r.hidden = true;
n += 1;
}
json!({ "hidden": n })
}
/// Is a heavy filter running that hasn't been cancelled?
pub fn any_running(app: &VectorcraftApp) -> bool {
app.plugin_runs.running.iter().any(|r| !r.token.is_cancelled())
}
/// Is a heavy filter running that the progress window shows?
pub fn any_shown(app: &VectorcraftApp) -> bool {
app.plugin_runs.running.iter().any(Running::shown)
}
/// The progress window of the first run it shows, once that has run for [`DELAY`]. Not while a
/// dialog or the export progress window is open: never two modals at once.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(first) = app.plugin_runs.running.iter().find(|r| r.shown()) else { return };
let now = ctx.input(|i| i.time);
let since = ctx.data_mut(|m| {
let seen = m.get_temp_mut_or_insert_with(egui::Id::new("plugin-run-seen"), || (first.id, now));
if seen.0 != first.id {
*seen = (first.id, now);
}
seen.1
});
let exporting = app.background.jobs.iter().any(|j| j.progress.is_some() && !j.hidden && !j.cancelled());
if now - since < DELAY || app.ui.dialog.is_some() || exporting {
return;
}
let heading = crate::i18n::msg(&first.label);
let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| export_progress::busy(ui, tl!("The plug-in is computing…")));
let id = match pressed {
Some(Pressed::Cancel) => "plugin.cancelRuns",
Some(Pressed::Hide) => "plugin.hideRuns",
None => return,
};
if let Err(e) = app.run(id, json!({})) {
app.status(e);
}
}