render: the M3 OFX host — one oak-worker --ofx-host process for every plugin job

docs/zh/plans/render-pipeline-threads.md M3 (design 3.2): OpenFX crash
isolation moves from "every worker hosts plugins" to a single dedicated
host process, served over NDJSON + shared memory.

- oak-worker --ofx-host mode (src/ofx_host.rs): loads every plugin once,
  resolves jobs by the cross-process-stable OFX identifier, and renders
  through the same in-process executor the workers used to install.
- oak-render/ofxhost.rs: the single-host client. The render manager
  creates and installs it for the Pipeline backend (lazy spawn on the
  first plugin job); eval::process_plugin_job prefers it and falls back
  to the in-process executor otherwise, so the process backend keeps its
  current behavior until M4.
- Data plane: input/output FrameSlotPool pairs (the handshake's input_*
  fields are used for the first time). Named clips and the source frame
  are written to input slots after the explicit CPU readback; the plugin
  output returns through an output slot. Pool size/capacity grow by a
  host restart when a job needs more (safe: submissions are serialized
  and one job is in flight).
- Crash loop: reader EOF fails the in-flight submit, which respawns the
  host and re-posts the same job (frames are read back once); after three
  consecutive crashes the client is permanently dead and the evaluator
  falls back to a purple frame. The dead child is reaped immediately, and
  a submit mutex enforces the one-job-in-flight contract.
- Progress/cancel: the host flushes plugin_progress immediately (live
  progress), and reads stdin on its own thread so plugin_cancel takes
  effect mid-render at the plugin's next progressUpdate; the sticky flag
  resets at progressStart and request_plugin_cancel_all broadcasts to
  both the worker pool and the host.
- JobSpec::Plugin / PluginJobPayload carry the plugin type_id (stable
  across processes); `--ofx-crash-once` / `--ofx-crash-always` are the
  deterministic crash hooks, matching the worker's env hooks.
- Tests: wire round-trips; host unit tests (crash budget, cancel-flag
  reset through the factory, source mapping); oak-worker integration
  tests against the real host + bundled test plugin (render + progress,
  crash respawn and re-post, three-crash give-up, mid-render cancel on
  the new slow variant, concurrent submits); eval's purple fallback.
This commit is contained in:
2026-09-12 23:10:43 +08:00
parent 4337559ed0
commit fec6e9dba7
14 changed files with 2054 additions and 27 deletions
+6
View File
@@ -32,6 +32,7 @@
mod framecache;
mod ipc;
mod ofx_host;
mod worker;
use std::process::exit;
@@ -64,6 +65,11 @@ fn parse_backend(args: &[String]) -> String {
fn main() {
let args: Vec<String> = std::env::args().collect();
// M3: `--ofx-host` runs the single OpenFX host loop instead of the
// ticket worker (same binary, no extra target to package).
if args.iter().any(|arg| arg == "--ofx-host") {
exit(ofx_host::ofx_host_main(&args));
}
let backend = parse_backend(&args);
exit(worker::worker_main(&backend));
}
+517
View File
@@ -0,0 +1,517 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! `oak-worker --ofx-host`: the single OpenFX host process (M3, design
//! §3.2).
//!
//! The host loads every OFX plugin once (the worker binary's normal plugin
//! runtime) and then serves `ofx_job` messages from the main process over
//! NDJSON, with frames moving through the input/output
//! [`FrameSlotPool`]s announced by the handshake. Each job is resolved to
//! a host-local instance by the plugin **identifier** (the cross-process
//! stable key) and rendered through the same in-process executor the
//! workers used to install ([`oak_plugin::node_factory::install_render_executor`]).
//!
//! Progress is flushed to stdout immediately (the main process's reader
//! forwards it to the plugin-progress dialog), and `plugin_cancel` sets
//! the same sticky flag protocol the worker used: the next `progressStart`
//! resets it and every `progressUpdate` after a cancel answers false, so
//! the plugin aborts at its next progress call.
//!
//! The host is deliberately single-threaded and synchronous, matching the
//! worker model; crash isolation comes from the parent respawning it.
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex, MutexGuard};
use oak_core::frame::VideoParamsPod;
use oak_core::texture::{Frame, Texture};
use oak_core::PixelFormat;
use oak_render::eval::{self, JobSpec, PluginJobRequest};
use oak_render::ipc::{
error_message, write_message, FrameSlotPool, HandshakeMsg, OfxJobMsg, OfxResultMsg,
PluginProgressMsg, SharedMemoryRegion, ShmMode, TYPE_HANDSHAKE, TYPE_OFX_JOB,
TYPE_PLUGIN_CANCEL, TYPE_SHUTDOWN,
};
/// Sticky plugin cancel (protocol parity with `worker.rs`): set by
/// `plugin_cancel`, reset by the next `progressStart`.
static OFX_CANCEL: AtomicBool = AtomicBool::new(false);
/// stdout is shared by progress events (emitted from inside the plugin
/// render) and control responses.
static OUT_LOCK: Mutex<()> = Mutex::new(());
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// Write one NDJSON line to stdout, flushed (progress must stream while
/// the job renders). No-op under unit tests (they exercise the reporter
/// return values, not the pipe).
fn emit(value: &serde_json::Value) {
if cfg!(test) {
return;
}
let _guard = lock(&OUT_LOCK);
let stdout = std::io::stdout();
let mut out = stdout.lock();
let _ = write_message(&mut out, value);
let _ = out.flush();
}
/// The host's progress reporter: emits `plugin_progress` immediately and
/// reports cancellation to the plugin.
struct HostProgressReporter {
label: String,
message: String,
}
impl oak_plugin::progress::UiProgressReporter for HostProgressReporter {
fn update(&mut self, progress: f64) -> bool {
emit(
&PluginProgressMsg {
label: self.label.clone(),
message: self.message.clone(),
fraction: progress.clamp(0.0, 1.0),
}
.to_json(),
);
!OFX_CANCEL.load(Ordering::Relaxed)
}
fn end(&mut self) {
emit(
&PluginProgressMsg {
label: self.label.clone(),
message: self.message.clone(),
fraction: 1.0,
}
.to_json(),
);
}
}
/// Build one progress reporter for `progressStart`: clears the sticky
/// cancel (the protocol's "a fresh render starts uncancelled") and emits
/// the fraction-0 start event. The reporter factory installed with the
/// progress suite calls this; tests call it directly.
fn host_progress_reporter(label: &str, message: &str) -> Box<dyn oak_plugin::progress::UiProgressReporter> {
OFX_CANCEL.store(false, Ordering::Relaxed);
emit(
&PluginProgressMsg {
label: label.to_string(),
message: message.to_string(),
fraction: 0.0,
}
.to_json(),
);
Box::new(HostProgressReporter {
label: label.to_string(),
message: message.to_string(),
})
}
/// Install the reporter factory (`progressStart` → [`host_progress_reporter`]).
fn install_progress_factory() {
oak_plugin::progress::set_reporter_factory(Some(Arc::new(host_progress_reporter)));
}
/// Read stdin on its own thread. `plugin_cancel` is handled inline (sets
/// the sticky flag) so a cancel is observed while a plugin render is in
/// flight; every other line goes to the main loop through the channel.
/// Dropping the sender on EOF closes the channel and ends the loop.
fn spawn_stdin_reader() -> mpsc::Receiver<String> {
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("oak-ofx-host-stdin".into())
.spawn(move || {
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin.lock());
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if line.trim().is_empty() {
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
if value.get("type").and_then(|t| t.as_str()) == Some(TYPE_PLUGIN_CANCEL) {
OFX_CANCEL.store(true, Ordering::Relaxed);
continue;
}
}
if tx.send(line.clone()).is_err() {
break;
}
}
})
.expect("spawn OFX host stdin reader");
rx
}
/// The attached pools. The `SharedMemoryRegion`s must outlive the pools
/// (the pool views point into the mappings).
struct HostPools {
_input_region: SharedMemoryRegion,
input: FrameSlotPool,
_output_region: SharedMemoryRegion,
output: FrameSlotPool,
}
/// Attach the pools announced by a `handshake` (the worker's
/// `handle_handshake`, minus the render session).
fn attach_pools(msg: &serde_json::Value) -> Result<HostPools, String> {
let hs: HandshakeMsg =
serde_json::from_value(msg.clone()).map_err(|e| format!("invalid handshake: {e}"))?;
if hs.shm_key.is_empty() || hs.output_slots <= 0 || hs.slot_data_bytes <= 0 {
return Err("handshake missing output shared-memory geometry".to_string());
}
let output_bytes =
FrameSlotPool::bytes_needed(hs.output_slots as u32, hs.slot_data_bytes as usize);
let mut output_region = SharedMemoryRegion::new();
if !output_region.open(&hs.shm_key, output_bytes, ShmMode::Attach) {
return Err(format!(
"failed to attach output shared memory: {}",
output_region.error()
));
}
// SAFETY: the mapping is live and sized for the pool.
let output = unsafe { FrameSlotPool::attach(output_region.data()) };
if !output.is_valid() {
return Err("output shared memory does not contain a frame slot pool".to_string());
}
if hs.input_shm_key.is_empty() || hs.input_slots <= 0 || hs.input_slot_data_bytes <= 0 {
return Err("handshake missing input shared-memory geometry".to_string());
}
let input_bytes =
FrameSlotPool::bytes_needed(hs.input_slots as u32, hs.input_slot_data_bytes as usize);
let mut input_region = SharedMemoryRegion::new();
if !input_region.open(&hs.input_shm_key, input_bytes, ShmMode::Attach) {
return Err(format!(
"failed to attach input shared memory: {}",
input_region.error()
));
}
// SAFETY: the mapping is live and sized for the pool.
let input = unsafe { FrameSlotPool::attach(input_region.data()) };
if !input.is_valid() {
return Err("input shared memory does not contain a frame slot pool".to_string());
}
Ok(HostPools {
_input_region: input_region,
input,
_output_region: output_region,
output,
})
}
/// Consume one input frame (the producer is the main process).
fn read_input_frame(pool: &FrameSlotPool, slot: u32) -> Result<Frame, String> {
let mut consumed = 0u32;
// SAFETY: consumer side of the SPSC protocol, single-threaded host.
if !unsafe { pool.consume(&mut consumed) } {
return Err("input slot missing".to_string());
}
if consumed != slot {
unsafe {
pool.release(consumed);
}
return Err(format!(
"input slot mismatch: expected {slot}, got {consumed}"
));
}
// SAFETY: `slot` was just consumed; the meta POD is initialized by the
// producer before publish.
let meta = unsafe { &*pool.meta_const(slot) };
if meta.width <= 0 || meta.height <= 0 || meta.data_size < 0 {
unsafe {
pool.release(slot);
}
return Err("input frame has invalid metadata".to_string());
}
let len = (meta.data_size as usize).min(pool.slot_data_bytes());
// SAFETY: `len` is within the slot block.
let data = unsafe { std::slice::from_raw_parts(pool.slot_data_const(slot), len).to_vec() };
unsafe {
pool.release(slot);
}
let mut frame = Frame::new();
let mut pod = VideoParamsPod::default();
pod.width = meta.width;
pod.height = meta.height;
pod.format = PixelFormat::F32 as i32;
frame.set_video_params(pod);
frame.data = data;
Ok(frame)
}
/// Publish one output frame (the consumer is the main process).
fn write_output_frame(pool: &FrameSlotPool, frame: &Frame) -> Result<i32, String> {
let mut slot = 0u32;
// SAFETY: producer side of the SPSC protocol, single-threaded host.
if !unsafe { pool.acquire(&mut slot) } {
return Err("output pool is full".to_string());
}
let bytes = frame.data.len();
if bytes > pool.slot_data_bytes() {
unsafe {
pool.release(slot);
}
return Err(format!(
"output frame is {bytes} bytes, larger than the output slot ({})",
pool.slot_data_bytes()
));
}
// SAFETY: `slot` was just acquired; fill then publish.
unsafe {
std::ptr::copy_nonoverlapping(frame.data.as_ptr(), pool.slot_data(slot), bytes);
let meta = &mut *pool.meta(slot);
*meta = Default::default();
meta.width = frame.width;
meta.height = frame.height;
meta.format = PixelFormat::F32 as i32;
meta.channel_count = 4;
meta.linesize = frame.linesize_bytes() as i32;
meta.data_size = bytes as i32;
meta.time_num = frame.timestamp.numerator();
meta.time_den = frame.timestamp.denominator().max(1);
if !pool.publish(slot) {
pool.release(slot);
return Err("output publish failed".to_string());
}
}
Ok(slot as i32)
}
/// Render one `ofx_job` and return its `ofx_result` response.
fn handle_job(msg: serde_json::Value, pools: &HostPools) -> serde_json::Value {
let job: OfxJobMsg = match serde_json::from_value(msg) {
Ok(job) => job,
Err(err) => return error_message(&format!("invalid ofx_job: {err}"), None),
};
let fail = |message: String| {
OfxResultMsg {
job: job.job,
slot: -1,
error: message,
}
.to_json()
};
let mut inputs = Vec::with_capacity(job.inputs.len());
for input in &job.inputs {
match read_input_frame(&pools.input, input.slot) {
Ok(frame) => inputs.push((input.name.clone(), Texture::wrap_frame(frame))),
Err(err) => return fail(err),
}
}
let src = match job.src_slot {
Some(slot) => match read_input_frame(&pools.input, slot) {
Ok(frame) => Texture::wrap_frame(frame),
Err(err) => return fail(err),
},
None => {
// No explicit source: mirror the evaluator's fallback (the
// declared effect input, else the first clip, else dummy).
let named = inputs
.iter()
.find(|(name, _)| name == &job.effect_input_id)
.or_else(|| inputs.first())
.map(|(_, texture)| texture.clone());
named.unwrap_or_else(Texture::dummy)
}
};
let Some(factory) = eval::plugin_instance_factory() else {
return fail("no plugin instance factory installed in the OFX host".to_string());
};
let Some(instance) = factory(&job.type_id) else {
return fail(format!("unknown or unavailable OFX plugin: {}", job.type_id));
};
let Some(executor) = eval::plugin_executor() else {
return fail("no plugin executor installed in the OFX host".to_string());
};
let spec = JobSpec::Plugin {
instance,
type_id: job.type_id.clone(),
time: job.time,
effect_input_id: if job.effect_input_id.is_empty() {
None
} else {
Some(job.effect_input_id.clone())
},
inputs,
values: job
.values
.iter()
.map(|param| (param.input.clone(), param.value.to_node_value()))
.collect(),
};
match executor(&PluginJobRequest { spec: &spec, src }) {
Ok(texture) => match texture.to_frame() {
Ok(frame) => match write_output_frame(&pools.output, &frame) {
Ok(slot) => OfxResultMsg {
job: job.job,
slot,
error: String::new(),
}
.to_json(),
Err(err) => fail(err),
},
Err(err) => fail(format!("plugin output readback failed: {err:?}")),
},
Err(err) => fail(format!("plugin render failed: {err:?}")),
}
}
/// Test-only crash hooks (deterministic crash/restart acceptance tests).
struct CrashHooks {
/// Crash on every job.
always: bool,
/// Crash on the first job of each process unless the marker file
/// exists (create it before crashing, so a respawned host renders).
once_marker: Option<PathBuf>,
}
impl CrashHooks {
fn from_args(args: &[String]) -> Self {
let mut hooks = CrashHooks {
always: false,
once_marker: None,
};
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--ofx-crash-always" => hooks.always = true,
"--ofx-crash-once" if i + 1 < args.len() => {
hooks.once_marker = Some(PathBuf::from(&args[i + 1]));
i += 1;
}
_ => {}
}
i += 1;
}
hooks
}
fn maybe_crash(&self) {
if self.always {
std::process::abort();
}
if let Some(path) = &self.once_marker {
if !path.exists() {
let _ = std::fs::write(path, b"ofx-host-crashed");
std::process::abort();
}
}
}
}
/// The `--ofx-host` main loop. Returns the process exit code.
pub fn ofx_host_main(args: &[String]) -> i32 {
// The same plugin runtime the render workers install: the executor
// (so `plugin_executor` is callable) and the identifier-keyed instance
// factory (so jobs resolve their own instances).
oak_plugin::node_factory::install_render_executor();
if let Err(err) = oak_plugin::host::Host::global().cache.scan() {
eprintln!("ofx-host: plugin scan failed: {err}");
}
install_progress_factory();
let crash = CrashHooks::from_args(args);
// stdin runs on its own thread (cancel must be observed mid-render);
// the main loop consumes the forwarded control messages.
let control = spawn_stdin_reader();
let mut pools: Option<HostPools> = None;
while let Ok(line) = control.recv() {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&line) else {
emit(&error_message("invalid JSON message", None));
continue;
};
match msg.get("type").and_then(|t| t.as_str()) {
Some(TYPE_HANDSHAKE) => match attach_pools(&msg) {
Ok(attached) => pools = Some(attached),
Err(err) => emit(&error_message(&err, None)),
},
Some(TYPE_OFX_JOB) => match &pools {
Some(pools) => {
crash.maybe_crash();
let response = handle_job(msg, pools);
emit(&response);
}
None => emit(&error_message("ofx_job before handshake", None)),
},
Some(TYPE_PLUGIN_CANCEL) => OFX_CANCEL.store(true, Ordering::Relaxed),
Some(TYPE_SHUTDOWN) => break,
other => emit(&error_message(
&format!(
"unknown message type: {}",
other.unwrap_or("<missing>")
),
None,
)),
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancel_flag_is_reset_by_progress_start() {
// Cancel semantics (protocol parity with the worker): a cancelled
// reporter tells the plugin to abort at its next progressUpdate;
// the next progressStart (the factory path below) clears the
// sticky flag.
OFX_CANCEL.store(true, Ordering::Relaxed);
let mut reporter = host_progress_reporter("render", "msg");
assert!(
reporter.update(0.5),
"the progressStart factory path reset the sticky cancel"
);
// A cancel after the start makes the next update answer false.
OFX_CANCEL.store(true, Ordering::Relaxed);
assert!(!reporter.update(0.6), "a cancelled reporter answers false");
OFX_CANCEL.store(false, Ordering::Relaxed);
}
#[test]
fn crash_hooks_parse_args() {
let hooks = CrashHooks::from_args(&[
"oak-worker".to_string(),
"--ofx-host".to_string(),
"--ofx-crash-once".to_string(),
"/tmp/marker".to_string(),
]);
assert!(!hooks.always);
assert_eq!(hooks.once_marker.as_deref(), Some(std::path::Path::new("/tmp/marker")));
let hooks = CrashHooks::from_args(&["oak-worker".to_string(), "--ofx-crash-always".to_string()]);
assert!(hooks.always);
assert!(hooks.once_marker.is_none());
}
}
+261
View File
@@ -0,0 +1,261 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! M3: the single OpenFX host process (`oak-worker --ofx-host`).
//!
//! These tests spawn the real worker binary in host mode against the
//! crate's bundled test plugin and assert the milestone's acceptance
//! points: a plugin job renders and its progress events reach the app
//! callback, a crashed host is respawned and the in-flight job is
//! re-posted, and three consecutive crashes exhaust the budget (the
//! evaluator's purple fallback is asserted by the eval unit test).
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use oak_core::texture::Texture;
use oak_core::{PixelFormat, Rational};
use oak_render::eval::{generate_frame, JobSpec};
use oak_render::ofxhost::{OfxHost, OfxHostConfig};
/// The plugin runtime and the progress callback are process-wide.
static LOCK: Mutex<()> = Mutex::new(());
/// Assemble the bundled test plugin into an OFX bundle and return the
/// directory that contains it (`OFX_PLUGIN_PATH`). `None` when the test
/// plugin was not built (release builds).
fn bundle_parent(tag: &str) -> Option<PathBuf> {
let lib = oak_plugin::bundled_test_plugin()?;
let root = std::env::temp_dir().join(format!(
"oak-ofx-host-{tag}-{}",
std::process::id()
));
let bundle = root.join("OakTest.ofx.bundle");
let platform = if cfg!(target_os = "macos") {
"MacOS"
} else if cfg!(target_os = "windows") {
"Win64"
} else if cfg!(target_arch = "aarch64") {
"Linux-aarch64"
} else {
"Linux-x86-64"
};
let dest = bundle.join("Contents").join(platform);
std::fs::create_dir_all(&dest).ok()?;
let name = lib.file_name()?;
std::fs::copy(&lib, dest.join(name)).ok()?;
Some(root)
}
fn host(plugin_dir: &Path, args: Vec<String>, max_failures: u32) -> Arc<OfxHost> {
OfxHost::new(OfxHostConfig {
host_bin: Some(PathBuf::from(env!("CARGO_BIN_EXE_oak-worker"))),
max_failures,
host_args: args,
env: vec![(
"OFX_PLUGIN_PATH".to_string(),
plugin_dir.to_string_lossy().into_owned(),
)],
..Default::default()
})
.expect("host client")
}
fn source_texture(w: i32, h: i32) -> Texture {
let mut frame = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32).unwrap();
for px in frame.data.chunks_exact_mut(16) {
for (c, v) in px.chunks_exact_mut(4).zip([0.1f32, 0.2, 0.3, 1.0]) {
c.copy_from_slice(&v.to_le_bytes());
}
}
Texture::wrap_frame(frame)
}
/// The bundled test plugin's filter (`org.oak.test-plugin`) paints a
/// constant 0.5 grey with alpha 1.
fn plugin_spec(src: &Texture) -> JobSpec {
JobSpec::Plugin {
instance: 0,
type_id: "org.oak.test-plugin".to_string(),
time: 0.0,
effect_input_id: Some("Source".to_string()),
inputs: vec![("Source".to_string(), src.clone())],
values: Vec::new(),
}
}
fn first_pixel(frame: &oak_core::texture::Frame) -> [f32; 4] {
let mut out = [0f32; 4];
for (i, value) in out.iter_mut().enumerate() {
*value = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap());
}
out
}
#[test]
fn host_renders_plugin_job_and_forwards_progress() {
let _lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = bundle_parent("render")
.expect("the bundled test plugin must exist (build.rs compiles it)");
let events: Arc<Mutex<Vec<(String, String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
oak_render::procpool::set_plugin_progress_cb(Some(Arc::new(move |label, message, fraction| {
sink.lock()
.unwrap_or_else(|e| e.into_inner())
.push((label, message, fraction));
})));
let client = host(&dir, Vec::new(), 3);
let src = source_texture(16, 16);
let out = client
.submit(&plugin_spec(&src), &src)
.expect("the host renders the plugin job");
assert_eq!((out.width, out.height), (16, 16));
assert_eq!(out.format, PixelFormat::F32);
let px = first_pixel(&out);
assert!(
(px[0] - 0.5).abs() < 1e-3 && (px[3] - 1.0).abs() < 1e-6,
"the test plugin's constant grey must come back: {px:?}"
);
let seen = events.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert!(
seen.iter()
.any(|(label, _, fraction)| label == "render" && (*fraction - 0.5).abs() < 1e-6),
"the plugin's progressUpdate must reach the app callback: {seen:?}"
);
assert!(
seen.iter().any(|(_, _, fraction)| (*fraction - 1.0).abs() < 1e-6),
"progressEnd closes the dialog: {seen:?}"
);
oak_render::procpool::set_plugin_progress_cb(None);
client.shutdown();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn host_respawns_and_reposts_after_a_crash() {
let _lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = bundle_parent("respawn")
.expect("the bundled test plugin must exist (build.rs compiles it)");
let marker = dir.join("crash-once.marker");
let client = host(
&dir,
vec![
"--ofx-crash-once".to_string(),
marker.to_string_lossy().into_owned(),
],
3,
);
let src = source_texture(16, 16);
let out = client
.submit(&plugin_spec(&src), &src)
.expect("the in-flight job is re-posted to the respawned host");
assert_eq!(out.width, 16);
assert!(client.restarts() >= 1, "the crashed host was respawned");
assert_eq!(client.failures(), 0, "a successful re-post clears the crash count");
assert_eq!(first_pixel(&out)[0], 0.5, "the re-posted job still renders");
client.shutdown();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn host_cancels_an_in_flight_slow_render() {
let _lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = bundle_parent("cancel").expect("the bundled test plugin must exist");
let client = host(&dir, Vec::new(), 3);
let src = source_texture(16, 16);
let spec = JobSpec::Plugin {
instance: 0,
type_id: "org.oak.test-plugin.slow".to_string(),
time: 0.0,
effect_input_id: Some("Source".to_string()),
inputs: vec![("Source".to_string(), src.clone())],
values: Vec::new(),
};
let submitter = client.clone();
let src_for_job = src.clone();
let job = std::thread::spawn(move || submitter.submit(&spec, &src_for_job));
// The slow plugin renders for ~2 s in 20 ms progress steps; cancel
// lands while it is in flight and the next progressUpdate aborts it.
std::thread::sleep(Duration::from_millis(200));
client.cancel();
let result = job.join().expect("the submit thread must not panic");
assert!(
result.is_err(),
"a cancelled slow render must fail instead of returning a frame"
);
assert_eq!(
client.failures(),
0,
"a cancel is not a crash; the budget stays untouched"
);
client.shutdown();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn host_serializes_concurrent_submits() {
let _lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = bundle_parent("concurrent").expect("the bundled test plugin must exist");
let client = host(&dir, Vec::new(), 3);
let src = source_texture(16, 16);
let a = client.clone();
let spec_a = plugin_spec(&src);
let src_a = src.clone();
let first = std::thread::spawn(move || a.submit(&spec_a, &src_a));
let b = client.clone();
let spec_b = plugin_spec(&src);
let src_b = src.clone();
let second = std::thread::spawn(move || b.submit(&spec_b, &src_b));
// The submit lock serializes the jobs (each writes the shared input
// pool); both must come back with the plugin's constant grey.
for handle in [first, second] {
let frame = handle.join().expect("submit thread").expect("job renders");
assert_eq!(first_pixel(&frame)[0], 0.5);
}
client.shutdown();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn host_gives_up_after_three_consecutive_crashes() {
let _lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = bundle_parent("giveup")
.expect("the bundled test plugin must exist (build.rs compiles it)");
let client = host(&dir, vec!["--ofx-crash-always".to_string()], 3);
let src = source_texture(8, 8);
let err = client
.submit(&plugin_spec(&src), &src)
.expect_err("three consecutive crashes must give up");
let _ = err;
assert_eq!(client.failures(), 3);
assert!(client.is_permanently_dead());
// Fast-fail afterwards: no more spawns.
let restarts = client.restarts();
assert!(client.submit(&plugin_spec(&src), &src).is_err());
assert_eq!(client.restarts(), restarts);
client.shutdown();
let _ = std::fs::remove_dir_all(&dir);
}