Files
effectcraft/crates/helper-host/tests/it/main.rs
T
611a282bea Plug-ins: external effect helpers (safe, out-of-process native effects) + plug-in API 2 (#889)
* Plug-ins: API 2 (PluginHost, padding, layer_span)

Split out of the OpenFX work so it can be reviewed on its own. Additive; the WebAssembly ABI
stays at 1.

- point3 / layer / text / hidden parameters, integer sliders, parameter groups.
- EffectPlugin::padding (transparent pixels around the buffer, at most 4096 per side).
- EffectPlugin::render_v2 with a PluginHost: the input at other times, other layers, parameters
  at other times, frame rate, per-instance key, the layer's source range.
- EffectEnv::layer_span from the renderer; MAX_PLUGINS 1024.
- plugin_error: a plug-in effect's last render error, for the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS

* Plug-ins: external effect helpers (crates/helper-host)

Native plug-in formats run in separate helper programs, never in EffectCraft's process
(docs/adr/0001-external-effect-helpers.md). New safe-Rust crate effectcraft-helper-host (L3,
native only, unsafe forbidden): it finds helper programs, starts each as a child process and
registers its effects through plug-in API 2, so the UI and command surface need nothing new.

- Protocol v1 (docs/external-helpers.md): JSON lines over stdin / stdout (helper replies are
  prefixed so plug-in chatter is ignored); pixels go through a frame exchange file (/dev/shm on
  Linux), never through the pipe. Render-time callbacks for the input at other times, other layers
  and parameters at other times, with nested renders through the same helper.
- A helper that exits, hangs past its timeout or breaks the protocol is stopped. The render fails
  with a message, the frame is left as it was, and the helper restarts for the next render. An
  effect that stops its helper 3 times is turned off (helper_blocklist.txt).
- Opt-in: nothing starts helpers unless the user asks (Effect > Scan for Effect Helpers), or opts
  in to starting them at launch with EFFECTCRAFT_HELPERS=1, an environment variable, so writing
  to the settings folder alone can't run code. effect.helpers.scan / load are refused for
  confined agent sessions (confine.rs), like file.executeFile.
- Discovery: helpers/ next to the app (Contents/Helpers on macOS), helpers/ in the settings
  folder, EFFECTCRAFT_HELPER_PATH.
- Commands effect.helpers.list / scan / load; Effect menu entries (translated); the last render
  error of an effect shows as a warning sign after its name in Effect Controls.
- serve(): the helper side for Rust helpers. tests/it/main.rs runs the whole protocol (render,
  padding, callbacks, nesting, threads, crash, restart, block list, hang) against a fake helper,
  with nothing to install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS

* Effect helpers: address review (start failures, frame file mode, nextest listing)

- A helper that can't start (not installed any more, restarting too often, no frame exchange
  file) is reported but no longer charged to the effect being rendered: it is Failure::Refused,
  not Failure::Broken, so it never counts toward turning that effect off. New check in
  tests/it: once the helper is out of restarts, renders of an effect fail without blocking it
  (fails without the fix: the effect was turned off after 3 such renders).
- The frame exchange file is created owner-only (0o600) on Unix: it holds the user's frames and
  /dev/shm is shared. Asserted in the frames unit test.
- tests/it (harness = false) speaks the libtest listing protocol cargo-nextest uses:
  `--list --format terse` prints `helper_protocol: test` (nothing with `--ignored`), and
  `helper_protocol --exact` runs the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS

* Effect helpers: bound writes to a helper, drain stderr, keep callbacks in step

- Writes to a helper go through a writer thread with the request timeout: a
  helper that stops reading its stdin (hung with a large callback answer or
  render request pending) no longer blocks the render thread forever while
  holding the helper.
- stderr is drained as bytes (lossy, in 64 KiB pieces) to the end: output that
  isn't UTF-8 stopped the reader, closing the pipe under the helper (EPIPE /
  SIGPIPE on its next write); lines were unbounded.
- Callback images that can't be written are answered with `null` instead of
  failing the render while the helper waits (which left the protocol out of
  step); a frame that can't be written before the request is sent is not
  charged to the effect.
- Mid-render calls use the render's own process: no respawn or heartbeat ping
  while the helper is waiting for an answer.
- Waiting for a busy helper is bounded (load timeout) instead of forever;
  status() no longer blocks on a running render.
- Reap the child when start-up fails after spawning; cap the block list read.
- Scripts start helpers only with Allow Scripts to Write Files and Access
  Network on (they run native code), as for File.execute().
- helper-host declares the workspace's no-panic crate lints; the desktop
  main() keeps its doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Effect helpers: rustfmt the fake provider, build the test session in its initializer (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Plug-in Library: offer WebAssembly API 1 releases only; a helper already stopped mid-render isn't charged again

- WasmPlugin loads only modules at the WebAssembly ABI version, which stays 1 while
  PLUGIN_API_VERSION is 2: share it as effects::plugin::WASM_API_VERSION and have the
  library's compatibility check use it, so it never installs an API 2 release that
  can't load.
- with_running: a process that is already gone was stopped by what broke it (a nested
  render, charged there), so this render fails without charging its own effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* frames: plug-in effects count as reading other frames (plug-in API 2)

With main's narrowed frame invalidation (#2795), an edit inside a precomp under an
effect that reads its layer at other times must reach the layer's whole span.
Plug-in API 2 gives every plug-in effect the input at other times, so any effect
outside ec. counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 11:11:57 +01:00

328 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The helper protocol end to end against a fake helper, written in safe Rust with
//! [`effectcraft_helper_host::serve`]. This binary is both sides: started with
//! `--effectcraft-helper` it is the helper, otherwise it runs the checks, starting itself as
//! the helper. (`harness = false`: libtest would reject the helper argument.)
use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginHost, PluginImage, PluginManifest, PluginParam, PluginParamKind, PluginParams, plugin};
use effectcraft_helper_host::protocol::{HELPER_ARG, Reply, reply_line};
use effectcraft_helper_host::serve::{Provider, serve};
fn manifest(id: &str, params: Vec<PluginParam>) -> PluginManifest {
PluginManifest {
api: 2,
id: id.into(),
name: id.rsplit('.').next().unwrap_or(id).into(),
category: "Helper Test".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params,
}
}
fn amount() -> PluginParam {
PluginParam {
id: "amount".into(),
name: "Amount".into(),
group: String::new(),
kind: PluginParamKind::Slider { default: 1.0, min: 0.0, max: 1.0, slider_min: None, slider_max: None, decimals: 2, integer: false },
}
}
/// What each fake effect does.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
/// RGB → amount·(1 − RGB).
Invert,
/// Asks for 3 pixels of padding; fills the frame with its padding as red.
Pad,
/// Replaces the frame with the input one frame earlier, and puts `amount` from
/// `params_at(0)` in alpha of pixel 0.
Echo,
/// Exits the process mid-render.
Crash,
/// Asks for the parameters before 0 (an answer far larger than a pipe holds), then never
/// reads it or answers.
Hang,
/// Writes a long line of bytes that aren't UTF-8 to stderr, then more, then inverts.
Noisy,
}
struct Fake {
m: PluginManifest,
kind: Kind,
}
impl EffectPlugin for Fake {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _: &mut PluginFrame, _: &PluginParams, _: f64) -> Result<(), String> {
Err("needs render_v2".into())
}
fn padding(&self, _: &PluginParams, _: f64, _: f64, _: [f64; 2], _: u64) -> u32 {
if self.kind == Kind::Pad { 3 } else { 0 }
}
fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64, host: &dyn PluginHost) -> Result<(), String> {
match self.kind {
Kind::Invert => {
let a = params.f("amount") as f32;
for p in frame.pixels.iter_mut() {
for c in &mut p[..3] {
*c = a * (1.0 - *c);
}
}
}
Kind::Pad => frame.pixels.iter_mut().for_each(|p| *p = [3.0, 0.0, 0.0, 1.0]),
Kind::Echo => {
let prev = host.input_at(host.time() - 1.0 / host.frame_rate()).ok_or("no input at the previous frame")?;
if prev.pixels.len() != frame.pixels.len() {
return Err("previous frame has another size".into());
}
frame.pixels.copy_from_slice(&prev.pixels);
let (flat, _) = host.params_at(0.0).ok_or("no params at 0")?;
if let (Some(p), Some(a)) = (frame.pixels.first_mut(), flat.first()) {
p[3] = *a as f32;
}
}
Kind::Crash => std::process::exit(3),
Kind::Hang => {
let mut out = std::io::stdout();
out.write_all(reply_line(&Reply::ParamsAt { time: -1.0 })?.as_bytes()).and_then(|_| out.flush()).map_err(|e| e.to_string())?;
std::thread::sleep(Duration::from_secs(3600));
}
Kind::Noisy => {
let mut err = std::io::stderr();
let mut junk = vec![0xffu8; 1 << 20];
junk.push(b'\n');
err.write_all(&junk).map_err(|e| format!("stderr: {e}"))?;
// EffectCraft must still be reading.
std::thread::sleep(Duration::from_millis(300));
err.write_all(b"still talking\n").map_err(|e| format!("stderr: {e}"))?;
frame.pixels.iter_mut().for_each(|p| *p = [1.0, 0.0, 0.0, 1.0]);
}
}
Ok(())
}
}
struct FakeProvider;
impl Provider for FakeProvider {
fn name(&self) -> String {
"Fake".into()
}
fn effects(&mut self) -> (Vec<Arc<dyn EffectPlugin>>, Vec<String>) {
let v: Vec<Arc<dyn EffectPlugin>> =
[("invert", Kind::Invert), ("pad", Kind::Pad), ("echo", Kind::Echo), ("crash", Kind::Crash), ("hang", Kind::Hang), ("noisy", Kind::Noisy)]
.into_iter()
.map(|(n, kind)| Arc::new(Fake { m: manifest(&format!("org.test.helper.{n}"), vec![amount()]), kind }) as Arc<dyn EffectPlugin>)
.collect();
(v, vec!["fake helper: a warning".into()])
}
}
/// The host side of a render in these checks: the input at any time is a flat grey of that
/// time; with `nest`, asking for the input renders `invert` through the same helper first.
struct TestHost {
w: u32,
h: u32,
nest: bool,
}
impl PluginHost for TestHost {
fn input_at(&self, t: f64) -> Option<PluginImage> {
let mut pixels = vec![[t as f32, t as f32, t as f32, 1.0]; (self.w * self.h) as usize];
if self.nest {
let inv = plugin("org.test.helper.invert")?;
let m = inv.manifest().clone();
let mut f =
PluginFrame { width: self.w, height: self.h, pixels: &mut pixels, scale: 1.0, origin: [0.0; 2], layer_size: [self.w as f64, self.h as f64] };
inv.render_v2(&mut f, &PluginParams { manifest: &m, flat: &[1.0], texts: &[] }, t, &TestHost { nest: false, ..*self }).ok()?;
}
Some(PluginImage { width: self.w, height: self.h, pixels, origin: [0.0; 2], scale: 1.0 })
}
fn layer_input(&self, _: &str, _: f64) -> Option<PluginImage> {
None
}
fn params_at(&self, t: f64) -> Option<(Vec<f64>, Vec<String>)> {
// Before 0: megabytes, far more than a pipe holds (see `Kind::Hang`).
if t < 0.0 {
return Some((vec![0.5; 1 << 20], vec![]));
}
Some((vec![0.25], vec![]))
}
fn frame_rate(&self) -> f64 {
10.0
}
fn time(&self) -> f64 {
1.0
}
fn instance_key(&self) -> u64 {
7
}
}
fn render(id: &str, pixels: &mut Vec<[f32; 4]>, w: u32, h: u32, amount: f64, nest: bool) -> Result<(), String> {
let p = plugin(id).ok_or_else(|| format!("{id} is not registered"))?;
let m = p.manifest().clone();
let params = PluginParams { manifest: &m, flat: &[amount], texts: &[] };
let host = TestHost { w, h, nest };
let pad = p.padding(&params, 1.0, 1.0, [w as f64, h as f64], 7);
let (w, h) = (w + 2 * pad, h + 2 * pad);
pixels.resize((w * h) as usize, [0.0; 4]);
let mut f = PluginFrame { width: w, height: h, pixels, scale: 1.0, origin: [pad as f64; 2], layer_size: [w as f64, h as f64] };
p.render_v2(&mut f, &params, 1.0, &host)
}
fn check(name: &str, ok: bool, detail: impl std::fmt::Debug) -> bool {
println!("{} {name}{}", if ok { "ok " } else { "FAIL" }, if ok { String::new() } else { format!(": {detail:?}") });
ok
}
fn run_checks() -> bool {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return check("current exe", false, e),
};
let state = std::env::temp_dir().join(format!("ec-fake-helper-state-{}", std::process::id()));
effectcraft_helper_host::blocklist::set_state_dir(state.clone());
let mut all = true;
// Discovery + describe.
let r = effectcraft_helper_host::scan_paths(std::slice::from_ref(&exe), false);
let loaded = r["loaded"].as_array().map_or(0, Vec::len);
all &= check("scan registers the helper's effects", loaded == 6, &r);
all &= check("helper warnings are reported", r["errors"].to_string().contains("a warning"), &r);
all &= check(
"a second scan doesn't start it twice",
effectcraft_helper_host::scan_paths(std::slice::from_ref(&exe), false)["helpers"].as_array().is_some_and(Vec::is_empty),
"",
);
let list = effectcraft_helper_host::list();
all &= check("list names the helper", list["helpers"][0]["name"] == "Fake" && list["helpers"][0]["running"] == true, &list);
// Plain render through the exchange file.
let mut px = vec![[0.25, 0.5, 1.0, 1.0]; 6];
let r = render("org.test.helper.invert", &mut px, 3, 2, 1.0, false);
all &= check("invert renders", r.is_ok() && px.iter().all(|p| (p[0] - 0.75).abs() < 1e-6 && p[2].abs() < 1e-6 && p[3] == 1.0), (&r, &px));
// Output on stderr that isn't text, in long lines, is drained, so the helper never blocks on it
// or loses its stderr.
let mut px = vec![[0.0; 4]; 4];
let r = render("org.test.helper.noisy", &mut px, 2, 2, 1.0, false);
all &= check("a helper's stderr is drained whatever it holds", r.is_ok() && px[0] == [1.0, 0.0, 0.0, 1.0], &r);
// Padding.
let mut px = vec![[0.0; 4]; 4];
let r = render("org.test.helper.pad", &mut px, 2, 2, 1.0, false);
all &= check("padding grows the frame", r.is_ok() && px.len() == 64 && px[0][0] == 3.0, (&r, px.len()));
// Callbacks: the input at another time and parameters at another time.
let mut px = vec![[0.0; 4]; 4];
let r = render("org.test.helper.echo", &mut px, 2, 2, 1.0, false);
all &= check("input_at / params_at round-trip", r.is_ok() && (px[1][0] - 0.9).abs() < 1e-6 && px[0][3] == 0.25, (&r, &px));
// Nested: answering input_at renders another effect of the same helper.
let mut px = vec![[0.0; 4]; 4];
let r = render("org.test.helper.echo", &mut px, 2, 2, 1.0, true);
all &= check("nested renders through one helper", r.is_ok() && (px[1][0] - 0.1).abs() < 1e-5, (&r, &px));
// Several threads at once share the helper.
let threads: Vec<_> = (0..4)
.map(|i| {
std::thread::spawn(move || {
let mut px = vec![[0.0, 0.0, 0.0, 1.0]; 16];
render("org.test.helper.invert", &mut px, 4, 4, 0.5, false).map(|_| (i, px[0][0]))
})
})
.collect();
let results: Vec<_> = threads.into_iter().map(|t| t.join().map_err(|_| "thread panicked".to_string()).and_then(|r| r)).collect();
all &= check("concurrent renders", results.iter().all(|r| matches!(r, Ok((_, v)) if (*v - 0.5).abs() < 1e-6)), &results);
// A crash mid-render is an error, the frame is untouched, and the helper comes back.
let mut px = vec![[0.5; 4]; 4];
let r = render("org.test.helper.crash", &mut px, 2, 2, 1.0, false);
all &= check("a crashing helper is an error", r.as_ref().is_err_and(|e| e.contains("helper")) && px.iter().all(|p| *p == [0.5; 4]), &r);
let mut px = vec![[0.0, 0.0, 0.0, 1.0]; 4];
let r = render("org.test.helper.invert", &mut px, 2, 2, 1.0, false);
all &= check("the helper restarts for the next render", r.is_ok() && px[0][0] == 1.0, &r);
// A render that starts the stopped helper itself (no padding request first) succeeds.
effectcraft_helper_host::stop_all();
let r = plugin("org.test.helper.invert").ok_or("invert is not registered").and_then(|p| {
let m = p.manifest().clone();
let mut px = vec![[0.0, 0.0, 0.0, 1.0]; 4];
let mut f = PluginFrame { width: 2, height: 2, pixels: &mut px, scale: 1.0, origin: [0.0; 2], layer_size: [2.0; 2] };
let host = TestHost { w: 2, h: 2, nest: false };
p.render_v2(&mut f, &PluginParams { manifest: &m, flat: &[1.0], texts: &[] }, 1.0, &host).map_err(|_| "render failed")?;
Ok(px[0][0])
});
all &= check("a stopped helper restarts for a render without padding", r == Ok(1.0), r);
// Effects that keep crashing it are turned off (and the list survives in the state dir).
for _ in 0..3 {
let _ = render("org.test.helper.crash", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false);
}
let r = render("org.test.helper.crash", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false);
all &= check("repeat offenders are blocked", r.as_ref().is_err_and(|e| e.contains("turned off")), &r);
all &= check("the block list is saved", std::fs::read_to_string(state.join("helper_blocklist.txt")).is_ok_and(|t| t.contains("org.test.helper.crash")), "");
let r = effectcraft_helper_host::scan_paths(&[], true);
all &= check("retry clears the block list", !effectcraft_helper_host::blocklist::is_blocked("org.test.helper.crash"), &r);
// A hang is cut off by the timeout, also while EffectCraft is writing to a helper that
// stopped reading.
let t0 = std::time::Instant::now();
let r = render("org.test.helper.hang", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false);
all &= check(
"a hanging helper times out",
r.as_ref().is_err_and(|e| e.contains("did not answer")) && t0.elapsed() < Duration::from_secs(30),
(&r, t0.elapsed()),
);
// The restarts above use up the helper's restarts for this minute, so it can't start again.
// That is not the fault of the effect being rendered: it fails, but is not charged and never
// turned off (it would be after CRASH_LIMIT charged failures).
let starts: Vec<_> = (0..4).map(|_| render("org.test.helper.invert", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false)).collect();
all &= check(
"a helper that can't start doesn't block the effect",
starts.iter().all(|r| r.as_ref().is_err_and(|e| e.contains("keeps stopping")))
&& !effectcraft_helper_host::blocklist::is_blocked("org.test.helper.invert"),
&starts,
);
effectcraft_helper_host::stop_all();
let _ = std::fs::remove_dir_all(&state);
all
}
/// The one test this binary reports to test runners.
const TEST_NAME: &str = "helper_protocol";
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == HELPER_ARG) {
if let Err(e) = serve(&mut FakeProvider) {
eprintln!("fake helper: {e}");
std::process::exit(1);
}
return;
}
// The libtest listing protocol cargo-nextest uses (`--list --format terse`, with and without
// `--ignored`): one `name: test` line, then nextest runs `<binary> helper_protocol --exact`.
if args.iter().any(|a| a == "--list") {
if !args.iter().any(|a| a == "--ignored") {
println!("{TEST_NAME}: test");
}
return;
}
// Keep the hang check short.
effectcraft_helper_host::client::set_render_timeout(Duration::from_secs(3));
if !run_checks() {
std::process::exit(1);
}
}