// 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 . //! 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 { 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, max_failures: u32) -> Arc { 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>> = 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); }