From 666ac9b4d4646505d7af05c19fa7e2fafa7c2c0b Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 22 Sep 2026 20:54:04 +0800 Subject: [PATCH] test(oak-render, oak-worker): eval, procpool and worker coverage Render evaluation fallbacks, the process pool (dispatch, cancel, restart, teardown), half-float display packing, and the worker's shared-memory job paths; includes the M5 footage import acceptance tests and the software-decode byte-exactness guard. --- crates/oak-render/src/cache.rs | 310 +++ crates/oak-render/src/eval.rs | 2437 ++++++++++++++++- crates/oak-render/src/pipeline.rs | 30 +- crates/oak-render/src/procpool.rs | 2121 +++++++++++++- crates/oak-render/tests/common/mod.rs | 39 + .../oak-render/tests/footage_import_test.rs | 394 +++ .../oak-render/tests/render_threads_test.rs | 30 + crates/oak-worker/src/ofx_host.rs | 699 +++++ crates/oak-worker/src/worker.rs | 1179 +++++++- 9 files changed, 7204 insertions(+), 35 deletions(-) create mode 100644 crates/oak-render/tests/footage_import_test.rs diff --git a/crates/oak-render/src/cache.rs b/crates/oak-render/src/cache.rs index 0c27fb402..c633d7654 100644 --- a/crates/oak-render/src/cache.rs +++ b/crates/oak-render/src/cache.rs @@ -880,4 +880,314 @@ mod tests { assert!(!dir.join(&c.uuid).join("state").exists()); std::fs::remove_dir_all(&dir).ok(); } + + // ---- remaining boundary surface -------------------------------------- + + fn work_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("oakrender-test-{tag}-{}", next_owner_identity())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn accessors_and_null_timebase_defaults() { + let mut c = PlaybackCache::new(CacheKind::AudioPlayback, 42); + assert_eq!(c.uuid().len(), 38); + assert_eq!(c.timebase(), Rational::NULL); + assert!(c.saving_enabled()); + assert!(!c.disk_dir().is_empty()); + assert!(c.requested_ranges().is_empty()); + assert!(c.passthroughs().is_empty()); + + c.set_timebase(Rational::new(1, 25)); + assert_eq!(c.timebase(), Rational::new(1, 25)); + c.set_saving_enabled(false); + assert!(!c.saving_enabled()); + c.set_disk_dir("/tmp/oak-cache-test"); + assert_eq!(c.disk_dir(), "/tmp/oak-cache-test"); + c.set_uuid("{00000000-0000-4000-8000-000000000000}"); + assert_eq!(c.uuid(), "{00000000-0000-4000-8000-000000000000}"); + } + + #[test] + fn request_and_clear_ranges() { + let mut c = PlaybackCache::new(CacheKind::VideoFrame, 1); + c.set_saving_enabled(false); + let range = TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)); + c.request(range); + assert_eq!(c.requested_ranges().ranges(), &[range]); + c.clear_request_range(range); + assert!(c.requested_ranges().is_empty()); + } + + #[test] + fn byte_reader_reads_past_the_end_as_zero() { + let data = [0x12u8, 0x34, 0x56, 0x78]; + let mut r = ByteReader::new(&data); + assert_eq!(r.read_u32(), 0x1234_5678); + // Past the end: zeroed values, no panic. + assert_eq!(r.read_u32(), 0); + assert_eq!(r.read_i32(), 0); + assert_eq!(r.read_uuid(), [0u8; 16]); + let mut out = [0xFFu8; 4]; + assert_eq!(r.take(&mut out), 0); + assert_eq!(out, [0xFFu8; 4]); + + // Partial reads copy only the bytes that exist. + let mut r = ByteReader::new(&data); + assert_eq!(r.read_be(2), 0x1234); + // A partial read is left-aligned and zero-padded on the right. + assert_eq!(r.read_be(4), 0x5678_0000); + assert_eq!(r.read_be(8), 0); + let mut short = [0u8; 2]; + let mut r = ByteReader::new(&data); + assert_eq!(r.take(&mut short), 2); + assert_eq!(short, [0x12, 0x34]); + } + + #[test] + fn uuid_text_conversion_ignores_trailing_nibbles() { + let canonical = "{00112233-4455-6677-8899-aabbccddeeff}"; + let bytes = uuid_text_to_bytes(canonical); + assert_eq!( + bytes, + [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + 0xdd, 0xee, 0xff + ] + ); + assert_eq!(bytes_to_uuid_text(&bytes), canonical); + // Anything past the 32nd nibble is ignored (QDataStream parity). + assert_eq!(uuid_text_to_bytes(&format!("{canonical}ffff")), bytes); + } + + #[test] + fn modification_time_is_zero_for_missing_paths() { + assert_eq!( + modification_time_msecs(std::path::Path::new("/definitely/not/here")), + 0 + ); + } + + #[test] + fn set_uuid_reloads_the_disk_state() { + let dir = work_dir("uuid-reload"); + let mut source = tb_cache(); + source.set_saving_enabled(true); + source.set_disk_dir(&dir.to_string_lossy()); + let uuid = source.uuid.clone(); + source.validate(TimeRange::new(Rational::new(3, 1), Rational::new(9, 1))); + source.save_state(&dir).unwrap(); + + let mut target = PlaybackCache::new(CacheKind::VideoFrame, 2); + target.set_saving_enabled(false); + target.set_disk_dir(&dir.to_string_lossy()); + target.set_uuid(&uuid); + assert_eq!( + target.validated_ranges().ranges(), + &[TimeRange::new(Rational::new(3, 1), Rational::new(9, 1))] + ); + + // A uuid without a state file clears the ranges (missing state). + target.set_uuid("{00000000-0000-4000-8000-000000000000}"); + assert!(!target.has_validated_ranges()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn load_state_loads_a_clean_cache_and_skips_unchanged_files() { + let dir = work_dir("load-skip"); + + // One validated range persisted as `//state` by a + // separate producer instance. + let mut source = tb_cache(); + source.set_saving_enabled(true); + source.set_disk_dir(&dir.to_string_lossy()); + let loaded = TimeRange::new(Rational::new(3, 1), Rational::new(9, 1)); + source.validate(loaded); + source.save_state(&dir).unwrap(); + + // The consumer starts with genuinely empty in-memory ranges (and a + // zero `last_loaded_state`): the state has to come from the file. + // `validate` is deliberately not called on this instance — that is + // what made the old construction isomorphic. A no-op load or an + // inverted mtime guard now leaves the assertions below failing. + let mut target = PlaybackCache::new(CacheKind::VideoFrame, 2); + target.set_saving_enabled(false); + target.set_disk_dir(&dir.to_string_lossy()); + target.uuid = source.uuid.clone(); + assert!( + target.validated_ranges().is_empty(), + "the consumer starts with no ranges" + ); + + target.load_state(&dir).unwrap(); + assert_eq!( + target.validated_ranges().ranges(), + &[loaded], + "the state file loads into empty memory" + ); + assert_ne!( + target.last_loaded_state, 0, + "the load records the state file's mtime" + ); + + // Drop the memory only, then load the unchanged file again: the + // mtime guard must skip it, so the range stays gone. A guard that + // was removed would resurrect the range here (and an inverted one + // would already have failed the load above). + target.validated = TimeRangeList::new(); + target.load_state(&dir).unwrap(); + assert!( + target.validated_ranges().is_empty(), + "an unchanged state file is not reloaded" + ); + + // Force the reload path (a rewrite within the same millisecond + // would make the mtime comparison flaky): the now-larger file is + // re-read in full. + source.validate(TimeRange::new(Rational::new(10, 1), Rational::new(11, 1))); + source.save_state(&dir).unwrap(); + target.last_loaded_state = 0; + target.load_state(&dir).unwrap(); + assert_eq!( + target.validated_ranges().ranges(), + source.validated_ranges().ranges(), + "a forced load re-reads the whole state" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn save_state_reports_directory_creation_errors() { + let dir = work_dir("save-error"); + let blocker = dir.join("not-a-dir"); + std::fs::write(&blocker, b"file").unwrap(); + let mut c = tb_cache(); + c.set_saving_enabled(false); + c.set_disk_dir(&blocker.to_string_lossy()); + c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1))); + let err = c.save_state(&blocker).unwrap_err(); + assert!(format!("{err}").contains("create cache dir"), "{err}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn passthrough_snapshot_links_ranges_without_aliasing() { + let mut source = PlaybackCache::new(CacheKind::VideoFrame, 3); + source.set_saving_enabled(false); + source.set_timebase(Rational::new(1, 24)); + source.validate(TimeRange::new(Rational::new(2, 1), Rational::new(3, 1))); + let snapshot = PassthroughSnapshot { + validated: source.validated.clone(), + passthroughs: vec![( + TimeRange::new(Rational::new(8, 1), Rational::new(9, 1)), + source.uuid.clone(), + )], + timebase: source.timebase, + uuid: source.uuid.clone(), + }; + + let mut target = PlaybackCache::new(CacheKind::VideoFrame, 4); + target.set_saving_enabled(false); + target.set_passthrough_snapshot(snapshot.clone()); + assert_eq!(target.timebase(), Rational::new(1, 24)); + assert_eq!( + target.passthroughs().len(), + 2, + "validated source range plus the explicit entry" + ); + // Passthroughs cover exactly the linked ranges; the gap between them + // is still reported as invalidated. + let inv = target.invalidated_ranges(TimeRange::new(Rational::new(2, 1), Rational::new(9, 1))); + assert_eq!( + inv.ranges(), + &[TimeRange::new(Rational::new(3, 1), Rational::new(8, 1))] + ); + + // Audio caches keep their own timebase (frame-hash-only adoption). + let mut audio = PlaybackCache::new(CacheKind::AudioPlayback, 5); + audio.set_saving_enabled(false); + audio.set_passthrough_snapshot(snapshot); + assert_eq!(audio.timebase(), Rational::NULL); + } + + #[test] + fn passthrough_with_saving_enabled_persists_the_state() { + let dir = work_dir("passthrough-save"); + let mut source = PlaybackCache::new(CacheKind::VideoFrame, 6); + source.set_saving_enabled(false); + source.validate(TimeRange::new(Rational::new(0, 1), Rational::new(2, 1))); + + let mut target = PlaybackCache::new(CacheKind::VideoFrame, 7); + target.set_disk_dir(&dir.to_string_lossy()); + target.set_passthrough(&source); + let state = dir.join(&target.uuid).join("state"); + assert!(state.exists(), "set_passthrough persists when saving is on"); + + // Snapshot variant takes the same saving path. + let snapshot = PassthroughSnapshot { + validated: source.validated.clone(), + passthroughs: Vec::new(), + timebase: None, + uuid: source.uuid.clone(), + }; + target.set_passthrough_snapshot(snapshot); + assert!(state.exists()); + assert_eq!(target.passthroughs().len(), 2); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn frame_paths_use_the_timebase_or_whole_seconds() { + // Instance path with a timebase: 15s at 1/30 → frame 450. + let mut with_tb = tb_cache(); + with_tb.validate(TimeRange::new(Rational::new(0, 1), Rational::new(16, 1))); + let name = with_tb.frame_filename(Rational::new(15, 1)).expect("cached"); + assert!(name.ends_with("/450"), "{name}"); + + let path = PlaybackCache::frame_cache_path( + "/cache", + "id", + Rational::new(15, 1), + Rational::new(1, 30), + ); + assert_eq!( + path, + std::path::Path::new("/cache") + .join("id") + .join("450") + .to_string_lossy() + ); + assert_eq!( + PlaybackCache::frame_cache_path( + "/cache", + "id", + Rational::new(1, 10), + Rational::new(1, 1) + ), + std::path::Path::new("/cache") + .join("id") + .join("0") + .to_string_lossy() + ); + + // No timebase: whole seconds (round-half-away-from-zero). + let mut c = PlaybackCache::new(CacheKind::VideoFrame, 8); + c.set_saving_enabled(false); + c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(4, 1))); + let name = c.frame_filename(Rational::new(1, 2)).expect("cached"); + assert!(name.ends_with("/1"), "{name}"); + assert!(name.contains(c.uuid()), "{name}"); + } + + #[test] + fn next_owner_identity_is_monotonic() { + let a = next_owner_identity(); + let b = next_owner_identity(); + assert!(b > a); + } } diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index bf2d35a42..c1ad2fa69 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -3239,6 +3239,18 @@ fn apply_montage_effect( } } +/// The crate-level test lock serializing every test that reads or writes +/// the process-global pipeline color settings +/// ([`oak_core::color::set_pipeline_color_settings`]). The `pipeline` decode +/// tests pin the legacy working space for the whole decoded-pattern section +/// while the tests below temporarily switch to ACEScg; both modules run in +/// the same test binary, so one shared lock is required. +#[cfg(test)] +pub(crate) fn working_space_test_lock() -> &'static Mutex<()> { + static LOCK: Mutex<()> = Mutex::new(()); + &LOCK +} + #[cfg(test)] mod tests { use super::*; @@ -3307,6 +3319,14 @@ mod tests { .expect("value is still a job box (unresolved)") } + /// The helper's guard: a table without a texture channel panics + /// instead of silently returning a placeholder. + #[test] + #[should_panic(expected = "no texture in the table")] + fn resolved_texture_rejects_a_textureless_table() { + resolved_texture(&NodeValueTable::default()); + } + /// A frame-cache job box around `payload`. fn cache_job_box(payload: CacheJobPayload) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(Job::CacheJob(payload))) @@ -4729,6 +4749,2421 @@ mod tests { let _ = std::fs::remove_file(&path); } + // ---- Coverage edges: the eval seam's error and fallback paths -------- + + use oak_core::handle::CHandle; + use oak_node::project::Project; + + /// A GPU-like context whose readback returns a fixed frame: the + /// non-wgpu fallback and the foreign-context readback paths need a + /// `Texture::Gpu` without a real device. + struct FrameEchoCtx { + frame: Frame, + } + + impl oak_core::backend::GpuContextLike for FrameEchoCtx { + fn kind(&self) -> oak_core::backend::BackendKind { + oak_core::backend::BackendKind::Cpu + } + fn destroy_texture(&self, _token: u64) {} + fn upload(&self, _token: u64, _frame: &Frame) -> Result<()> { + Ok(()) + } + fn download(&self, _token: u64) -> Result { + Ok(self.frame.clone()) + } + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&oak_core::color::ColorProcessor>, + ) -> Result<()> { + Err(Error::Failed("FrameEchoCtx cannot blit".into())) + } + } + + /// A `Texture::Gpu` on [`FrameEchoCtx`] reporting `size`. + fn echo_gpu_texture(size: (i32, i32), rgba: [f32; 4], token: u64) -> Texture { + let frame = filled_frame(size, rgba) + .to_frame() + .unwrap_or_else(|_| Frame::dummy()); + Texture::gpu( + Arc::new(FrameEchoCtx { frame }), + token, + size.0, + size.1, + PixelFormat::F32, + ) + } + + /// An imported planar YUV texture on the non-wgpu stand-in context + /// (the real decode path never produces one in these tests). + fn planar_texture(size: (i32, i32)) -> Texture { + Texture::wrap_planar(oak_core::texture::PlanarTexture::new( + Arc::new(UnusedCtx), + oak_core::texture::PlanarFormat::Nv12, + size, + (1, 2), + oak_core::backend::YuvTransform::bt709_limited(), + (2, 2), + )) + } + + /// A valid OCIO processor (a 1D LUT doubling red) or `None` (with a + /// printed reason) when the bundled config is unavailable. + fn red_doubling_processor(tag: &str) -> Option { + lut_processor(tag, 2.0) + } + + /// A valid OCIO processor for the 1D LUT `0 -> 0`, `1 -> scale`. + fn lut_processor(tag: &str, scale: f32) -> Option { + if oak_core::color::set_up_default_config().is_err() { + eprintln!("{tag}: bundled OCIO missing; skipping"); + return None; + } + let path = std::env::temp_dir().join(format!( + "oakrender_eval_{tag}_{}.cube", + std::process::id() + )); + std::fs::write( + &path, + format!("LUT_1D_SIZE 2\n0.0 0.0 0.0\n{scale} 1.0 1.0\n"), + ) + .ok()?; + let processor = oak_core::color::ColorProcessor::create_lut( + path.to_str()?, + oak_core::color::Direction::Normal, + ) + .filter(|p| p.is_valid()); + let _ = std::fs::remove_file(&path); + if processor.is_none() { + eprintln!("{tag}: LUT processor unavailable; skipping"); + } + processor + } + + #[test] + fn hooks_default_disables_cache_and_reports_it() { + use oak_node::traverser::RenderHooks; + let hooks = RenderEvalHooks::default(); + assert!(!hooks.use_cache()); + let mut on = RenderEvalHooks::new(); + on.use_cache = true; + assert!(on.use_cache()); + assert!(on.ticket.is_none() && on.frame_size.is_none()); + } + + #[test] + fn color_transform_job_rejects_non_texture_and_null_inputs() { + let processor = Arc::new(oak_core::color::ColorProcessor::pass_through()); + let mut hooks = RenderEvalHooks::new(); + + let payload = ColorTransformJobPayload { + color_processor: processor.clone(), + input: NodeValue::Float(1.0), + time: Rational::new(0, 1), + }; + assert!(matches!( + hooks.process_color_transform_job(&payload), + Err(Error::Invalid) + )); + + let payload = ColorTransformJobPayload { + color_processor: processor, + input: NodeValue::Texture(CHandle::null()), + time: Rational::new(0, 1), + }; + assert!(matches!( + hooks.process_color_transform_job(&payload), + Err(Error::Invalid) + )); + } + + #[test] + fn color_transform_job_passes_planar_textures_through() { + let Some(processor) = red_doubling_processor("planar") else { + return; + }; + let payload = ColorTransformJobPayload { + color_processor: Arc::new(processor), + input: NodeValue::Texture(oak_node::handle::make_owned(planar_texture((2, 2)))), + time: Rational::new(0, 1), + }; + let out = RenderEvalHooks::new() + .process_color_transform_job(&payload) + .expect("planar pass-through"); + assert!(out.is_planar()); + } + + #[test] + fn color_transform_job_without_a_wgpu_context_converts_on_cpu() { + let Some(processor) = red_doubling_processor("ctxfallback") else { + return; + }; + let input = echo_gpu_texture((2, 2), [0.25, 0.25, 0.25, 1.0], 4242); + let payload = ColorTransformJobPayload { + color_processor: Arc::new(processor), + input: NodeValue::Texture(oak_node::handle::make_owned(input)), + time: Rational::new(0, 1), + }; + let out = RenderEvalHooks::new() + .process_color_transform_job(&payload) + .expect("cpu fallback"); + assert!(matches!(out, Texture::Cpu(_)), "the fallback wraps a CPU frame"); + let px = first_pixel(&out); + assert!((px[0] - 0.5).abs() < 1e-3, "red doubled on the CPU: {px:?}"); + } + + #[test] + fn plugin_job_rejects_a_non_plugin_spec() { + let mut hooks = RenderEvalHooks::new(); + let src = Texture::wrap_frame( + generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(), + ); + assert!(matches!( + hooks.process_plugin_job(src, &JobSpec::Generate), + Err(Error::Invalid) + )); + } + + #[test] + fn resolve_value_guards_depth_null_and_in_flight() { + let mut hooks = RenderEvalHooks::new(); + let mut in_flight = HashSet::new(); + + // Depth ceiling: even a job box is left untouched. + let mut deep = NodeValue::Texture(oak_node::handle::make_owned(Job::FootageJob( + FootageJobPayload::default(), + ))); + hooks.resolve_value(&mut deep, 64, &mut in_flight); + let NodeValue::Texture(deep_handle) = &deep else { + unreachable!() + }; + assert!(unsafe { oak_node::jobs::job_ref(deep_handle) }.is_some()); + assert!(in_flight.is_empty()); + + // A non-texture value passes through untouched. + let mut scalar = NodeValue::Float(0.5); + hooks.resolve_value(&mut scalar, 0, &mut in_flight); + assert!(matches!(scalar, NodeValue::Float(v) if v == 0.5)); + + // Empty handle: nothing to resolve. + let mut empty = NodeValue::Texture(CHandle::null()); + hooks.resolve_value(&mut empty, 0, &mut in_flight); + assert!(matches!(empty, NodeValue::Texture(_))); + + // A handle already in flight is skipped (the cycle guard). + let mut boxed = NodeValue::Texture(oak_node::handle::make_owned(Job::FootageJob( + FootageJobPayload::default(), + ))); + let key = match &boxed { + NodeValue::Texture(h) => h.ctx as usize, + _ => unreachable!(), + }; + in_flight.insert(key); + hooks.resolve_value(&mut boxed, 0, &mut in_flight); + let NodeValue::Texture(still) = &boxed else { + unreachable!() + }; + assert!(unsafe { oak_node::jobs::job_ref(still) }.is_some()); + assert!(in_flight.contains(&key)); + } + + #[test] + fn footage_job_with_missing_media_keeps_its_box() { + let payload = FootageJobPayload { + filename: std::env::temp_dir() + .join(format!("oakrender_missing_{}.mp4", std::process::id())) + .to_string_lossy() + .into_owned(), + stream_index: 0, + time: Rational::new(0, 1), + }; + assert!(RenderEvalHooks::new() + .process_footage_job_value(&payload) + .is_none()); + } + + #[test] + fn plugin_job_value_splits_clip_inputs_from_scalar_values() { + use oak_node::nodes::plugin::{PluginInstanceHandle, PluginJobPayload}; + + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + crate::ofxhost::install_client(None); + set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| { + let JobSpec::Plugin { + effect_input_id, + inputs, + values, + .. + } = req.spec + else { + return Err(Error::Invalid); + }; + assert!(effect_input_id.is_none(), "empty id maps to None"); + assert_eq!(inputs.len(), 1, "only the genuine texture box is a clip"); + assert_eq!(inputs[0].0, "Source"); + assert!(values.iter().any(|(k, _)| k == "gain")); + assert!(values.iter().all(|(k, _)| k != "Nothing")); + let mut frame = + generate_frame(Rational::new(0, 1), req.src.size(), PixelFormat::F32)?; + for pixel in frame.data.as_chunks_mut::<16>().0 { + for (c, v) in pixel + .as_chunks_mut::<4>() + .0 + .iter_mut() + .zip([0.5f32, 0.25, 0.125, 1.0]) + { + c.copy_from_slice(&v.to_le_bytes()); + } + } + Ok(Texture::wrap_frame(frame)) + }))); + + let mut values = NodeValueRow::new(); + values.insert( + "Source".into(), + texture_value(filled_frame((2, 1), [0.1, 0.2, 0.3, 1.0])), + ); + values.insert("gain".into(), NodeValue::Float(0.25)); + values.insert("Nothing".into(), NodeValue::None); + // A null texture box is skipped by the type guard. + values.insert("Null".into(), NodeValue::Texture(CHandle::null())); + // A non-texture box in the texture channel logs and is skipped. + values.insert( + "Boxed".into(), + NodeValue::Texture(oak_node::handle::make_owned(Job::FootageJob( + FootageJobPayload::default(), + ))), + ); + let payload = PluginJobPayload { + instance: PluginInstanceHandle(7), + type_id: "org.oak.test-plugin".into(), + time: Rational::new(1, 2), + effect_input_id: String::new(), + values, + }; + + let out = RenderEvalHooks::new() + .process_plugin_job_value(&payload, 0, &mut HashSet::new()) + .expect("plugin value resolves"); + let NodeValue::Texture(handle) = &out else { + panic!("expected a texture value, got {out:?}"); + }; + let texture = unsafe { oak_node::handle::get_checked::(handle) } + .cloned() + .expect("resolved texture"); + assert_eq!(first_pixel(&texture), [0.5, 0.25, 0.125, 1.0]); + set_plugin_executor(None); + } + + #[test] + fn color_transform_job_value_falls_back_to_its_input() { + let payload = ColorTransformJobPayload { + color_processor: Arc::new(oak_core::color::ColorProcessor::pass_through()), + input: NodeValue::None, + time: Rational::new(0, 1), + }; + let out = RenderEvalHooks::new().process_color_transform_job_value( + &payload, + 0, + &mut HashSet::new(), + ); + assert!(matches!(out, NodeValue::None)); + } + + #[test] + fn composite_tracks_rejects_nonpositive_sizes() { + assert!(composite_tracks(Vec::new(), (0, 4)).is_dummy()); + assert!(composite_tracks(Vec::new(), (4, -1)).is_dummy()); + } + + #[test] + fn evaluate_block_frame_handles_missing_and_textureless_roots() { + let mut graph = oak_node::graph::Graph::new(); + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + let seq = graph.add_node(score, sbehavior); + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + + // A stale root surfaces as `Failed` (the NotFound mapping). + let missing = evaluate_block_frame( + &graph, + &mut traverser, + &mut hooks, + oak_node::id::NodeId::INVALID, + Rational::new(0, 1), + ); + assert!(matches!(missing, Err(Error::Failed(_)))); + + // A root with no texture channel is `Ok(None)`. + let none = evaluate_block_frame( + &graph, + &mut traverser, + &mut hooks, + seq, + Rational::new(0, 1), + ); + assert!(matches!(none, Ok(None))); + } + + #[test] + fn blend_transition_without_sides_is_inert() { + let graph = oak_node::graph::Graph::new(); + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + let out = blend_transition( + &graph, + &mut traverser, + &mut hooks, + None, + None, + Rational::new(0, 1), + 0.5, + "linear", + ); + assert!(matches!(out, Ok(None))); + } + + #[test] + fn adjustment_chain_head_needs_an_effect_input() { + let mut graph = oak_node::graph::Graph::new(); + let (core, behavior) = oak_node::track::TrackBehavior::create(); + let track = graph.add_node(core, behavior); + assert!(adjustment_chain_head(&graph, track).is_none()); + assert!(adjustment_chain_head(&graph, oak_node::id::NodeId::INVALID).is_none()); + } + + #[test] + fn layer_progress_clamps_and_reports_degenerate_spans() { + assert_eq!( + layer_progress(Rational::new(1, 1), Rational::new(1, 1), Rational::new(1, 1)), + 0.0 + ); + assert_eq!( + layer_progress(Rational::new(2, 1), Rational::new(1, 1), Rational::new(1, 1)), + 0.0 + ); + assert_eq!( + layer_progress(Rational::new(0, 1), Rational::new(2, 1), Rational::new(1, 1)), + 0.5 + ); + assert_eq!( + layer_progress(Rational::new(0, 1), Rational::new(2, 1), Rational::new(9, 1)), + 1.0 + ); + } + + #[test] + fn audio_layout_rejects_degenerate_and_oversized_ranges() { + // Null denominator: the duration seconds stay 0. + let params = audio_params(TimeRange::new(Rational::NULL, Rational::NULL)); + assert!(render_audio_samples(¶ms).is_err()); + + // Longer than an hour. + let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(7200, 1))); + assert!(render_audio_samples(¶ms).is_err()); + } + + #[test] + fn mix_audio_montage_skips_clips_outside_the_range() { + let mut params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 48))); + let clip = |in_: Rational, out: Rational| crate::ticket::MontageClip { + filename: String::new(), + stream_index: 0, + in_time: in_, + out_time: out, + media_in: Rational::new(0, 1), + gain: 1.0, + effects: Vec::new(), + }; + params.montage = vec![ + // No overlap at all. + clip(Rational::new(2, 1), Rational::new(3, 1)), + // Starts at the last sample (start_frame >= total_frames). + clip(Rational::new(1999, 96000), Rational::new(1, 48)), + // Rounds to zero frames. + clip(Rational::new(1, 480000), Rational::new(4, 480000)), + ]; + let mut acc = vec![0.0f32; 1000 * 2]; + mix_audio_montage(¶ms, 48000, 2, 1000, &mut acc).expect("skips"); + assert!(acc.iter().all(|&v| v == 0.0), "uncovered range stays silent"); + } + + #[test] + fn scale_rgba_f32_bilinear_scales_and_clamps_alpha() { + let src_w = 2i32; + let src_h = 2i32; + let src_stride = (src_w * 16) as usize; + let mut src = vec![0u8; src_stride * src_h as usize]; + let put = |src: &mut [u8], x: usize, y: usize, rgba: [f32; 4]| { + let off = y * src_stride + x * 16; + for (i, v) in rgba.iter().enumerate() { + src[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + }; + put(&mut src, 0, 0, [1.0, 0.0, 0.0, 3.0]); + put(&mut src, 1, 0, [0.0, 1.0, 0.0, 3.0]); + put(&mut src, 0, 1, [0.0, 0.0, 1.0, 3.0]); + put(&mut src, 1, 1, [1.0, 1.0, 1.0, 3.0]); + + let dst_w = 4i32; + let dst_h = 4i32; + let dst_stride = (dst_w * 16) as usize; + let mut dst = vec![0u8; dst_stride * dst_h as usize]; + scale_rgba_f32( + src.as_ptr(), + src_stride as i32, + src_w, + src_h, + &mut dst, + dst_stride as i32, + dst_w, + dst_h, + ); + let read = |dst: &[u8], x: usize, y: usize| -> [f32; 4] { + let off = y * dst_stride + x * 16; + let mut out = [0f32; 4]; + for (i, v) in out.iter_mut().enumerate() { + *v = f32::from_le_bytes(dst[off + i * 4..off + i * 4 + 4].try_into().unwrap()); + } + out + }; + + // The top-left destination texel maps exactly onto the source + // corner; alpha 3.0 clamps to 1.0. + assert_eq!(read(&dst, 0, 0), [1.0, 0.0, 0.0, 1.0]); + // The bottom-right texel lands on the far corner. + assert_eq!(read(&dst, 3, 3), [1.0, 1.0, 1.0, 1.0]); + // A mid texel interpolates bilinearly (0.25, 0.25 in source space). + let mid = read(&dst, 1, 1); + for (got, want) in mid.iter().zip([0.625f32, 0.25, 0.25, 1.0]) { + assert!((got - want).abs() < 1e-5, "bilinear {mid:?}"); + } + + // Invalid geometry: nothing is written. + let mut untouched = vec![0xAAu8; 16]; + scale_rgba_f32( + src.as_ptr(), + src_stride as i32, + src_w, + src_h, + &mut untouched, + 16, + 0, + 1, + ); + assert!(untouched.iter().all(|&b| b == 0xAA)); + } + + #[test] + fn copy_rows_copies_strided_rows_and_rejects_bad_geometry() { + let src: Vec = (0..64u16).map(|i| i as u8).collect(); + let mut dst = vec![0u8; 128]; + assert!(copy_rows(&mut dst, 64, &src, 32, 2, 2)); + assert_eq!(&dst[..32], &src[..32]); + assert_eq!(&dst[64..96], &src[32..64]); + assert!(dst[32..64].iter().all(|&b| b == 0), "gap untouched"); + assert!(dst[96..].iter().all(|&b| b == 0), "gap untouched"); + + assert!(!copy_rows(&mut dst, 64, &src, 32, 0, 2)); + assert!(!copy_rows(&mut dst, 64, &src, 32, 2, 0)); + assert!(!copy_rows(&mut dst, 64, &src[..40], 32, 2, 2), "src too small"); + let mut small = vec![0u8; 64]; + assert!(!copy_rows(&mut small, 64, &src, 32, 2, 2), "dst too small"); + } + + /// A one-row F32 RGBA frame as raw bytes. + fn f32_frame_bytes(size: (i32, i32), rgba: [f32; 4]) -> Vec { + let mut frame = generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap(); + for px in frame.data.as_chunks_mut::<16>().0 { + for (c, v) in px.as_chunks_mut::<4>().0.iter_mut().zip(rgba) { + c.copy_from_slice(&v.to_le_bytes()); + } + } + frame.data + } + + fn read_f32_px(data: &[u8], stride: usize, x: usize, y: usize) -> [f32; 4] { + let off = y * stride + x * 16; + let mut out = [0f32; 4]; + for (i, v) in out.iter_mut().enumerate() { + *v = f32::from_le_bytes(data[off + i * 4..off + i * 4 + 4].try_into().unwrap()); + } + out + } + + fn adjustment_span( + in_: i64, + out: i64, + track_index: usize, + effects: Vec, + ) -> crate::ticket::AdjustmentSpan { + crate::ticket::AdjustmentSpan { + in_time: Rational::new(in_, 1), + out_time: Rational::new(out, 1), + track_index, + effects, + } + } + + fn unknown_effect(type_id: &str) -> crate::ticket::MontageEffect { + crate::ticket::MontageEffect { + type_id: type_id.to_string(), + enabled: true, + effect_input_id: Some("Source".to_string()), + params: Vec::new(), + } + } + + #[test] + fn adjustment_span_opacity_scales_and_unknown_effects_stage() { + let stride = 2 * 16; + // Opacity-only: the fast in-place scale. + let mut dst = f32_frame_bytes((2, 1), [0.8, 0.4, 0.2, 1.0]); + let span = adjustment_span(0, 2, 0, vec![opacity_effect(true, 0.5)]); + apply_adjustment_span(&mut dst, stride as i32, 2, 1, &span, Rational::new(0, 1)); + let px = read_f32_px(&dst, stride, 0, 0); + assert!((px[0] - 0.4).abs() < 1e-6, "{px:?}"); + assert!((px[3] - 0.5).abs() < 1e-6, "{px:?}"); + + // Unity opacity is skipped, an unknown effect forces the staged + // path and passes the frame through (with the warn-once log). + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + crate::ofxhost::install_client(None); + set_plugin_instance_factory(Some(Arc::new(|_id: &str| None))); + let mut dst = f32_frame_bytes((2, 1), [0.8, 0.4, 0.2, 1.0]); + let span = adjustment_span( + 0, + 2, + 0, + vec![ + opacity_effect(true, 1.0), + unknown_effect("com.example.eval-unknown"), + ], + ); + apply_adjustment_span(&mut dst, stride as i32, 2, 1, &span, Rational::new(0, 1)); + assert_eq!(read_f32_px(&dst, stride, 0, 0), [0.8, 0.4, 0.2, 1.0]); + set_plugin_instance_factory(None); + + // A span that does not cover the time is inert. + let mut dst = f32_frame_bytes((2, 1), [0.8, 0.4, 0.2, 1.0]); + let span = adjustment_span(5, 6, 0, vec![opacity_effect(true, 0.5)]); + apply_adjustment_span(&mut dst, stride as i32, 2, 1, &span, Rational::new(0, 1)); + assert_eq!(read_f32_px(&dst, stride, 0, 0), [0.8, 0.4, 0.2, 1.0]); + } + + #[test] + fn montage_frame_into_rejects_bad_geometry_and_skips_uncovered_clips() { + let params = crate::ticket::VideoTicketParams { + viewer: 1, + project: String::new(), + time: Rational::new(0, 1), + force_size: Some((2, 1)), + force_format: Some(PixelFormat::F32), + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: None, + montage: vec![crate::ticket::MontageClip { + filename: String::new(), + stream_index: 0, + in_time: Rational::new(1, 1), + out_time: Rational::new(2, 1), + media_in: Rational::new(0, 1), + gain: 1.0, + effects: Vec::new(), + }], + adjustments: Vec::new(), + }; + + // A short destination is rejected before anything is decoded. + let mut tiny = [0u8; 8]; + assert!(render_montage_frame_into( + Rational::new(0, 1), + ¶ms, + (2, 1), + &mut tiny, + 32 + ) + .is_err()); + + // A non-positive size is rejected too. + let mut dst = vec![0u8; 64]; + assert!(render_montage_frame_into( + Rational::new(0, 1), + ¶ms, + (0, 1), + &mut dst, + 32 + ) + .is_err()); + + // The clip does not cover t=0: the frame stays transparent black. + let mut dst = vec![0xFFu8; 2 * 16]; + render_montage_frame_into(Rational::new(0, 1), ¶ms, (2, 1), &mut dst, 32) + .expect("uncovered clip is skipped"); + assert!(dst.iter().all(|&b| b == 0)); + } + + #[test] + fn resolve_planar_footage_rejects_non_wgpu_contexts() { + assert!(resolve_planar_footage(&Texture::dummy()).is_err()); + assert!(resolve_planar_footage(&planar_texture((2, 2))).is_err()); + } + + #[test] + fn decoded_frame_cache_dedups_breaks_and_prunes_planar() { + let key = |n: i32| -> DecodedFrameKey { + (format!("frame{n}.mp4"), 0, (n as i64, 1), 2, 2) + }; + + // A duplicate key is a no-op. + let mut cache = std::collections::HashMap::new(); + insert_cached_frame(&mut cache, key(0), Texture::dummy(), 1); + insert_cached_frame(&mut cache, key(0), Texture::dummy(), 2); + assert_eq!(cache.len(), 1); + // Planar insertions take the planar-pruning path. + insert_cached_frame(&mut cache, key(1), planar_texture((2, 2)), 3); + assert_eq!(cache.len(), 2); + + // A cache full of tick-0 entries has no victim: the loop breaks. + let mut cache = std::collections::HashMap::new(); + for i in 0..MAX_CACHED_FRAMES { + cache.insert(key(i as i32), (Texture::dummy(), 0)); + } + insert_cached_frame(&mut cache, key(100), Texture::dummy(), 7); + assert_eq!(cache.len(), MAX_CACHED_FRAMES + 1); + + // Planar entries are pruned to the keep budget, LRU first. + let mut cache = std::collections::HashMap::new(); + for i in 0..(MAX_CACHED_PLANAR_FRAMES + 3) { + cache.insert(key(i as i32), (planar_texture((2, 2)), i as u64 + 1)); + } + prune_planar_frames(&mut cache, 2); + assert_eq!( + cache.values().filter(|(t, _)| t.is_planar()).count(), + 2, + "planar entries pruned to the budget" + ); + // The oldest planar entries went first. + assert!(cache.contains_key(&key(MAX_CACHED_PLANAR_FRAMES as i32 + 2))); + // At or under the budget: a no-op. + prune_planar_frames(&mut cache, 99); + assert_eq!(cache.values().filter(|(t, _)| t.is_planar()).count(), 2); + } + + #[test] + fn eviction_victim_falls_back_to_software_sessions() { + type DecoderMap = + std::collections::HashMap<(String, i32), (Arc, u64)>; + let mut cache: DecoderMap = std::collections::HashMap::new(); + cache.insert( + ("a.mp4".to_string(), 0), + (Arc::new(FFmpegDecoder::new()), 5), + ); + cache.insert( + ("b.mp4".to_string(), 0), + (Arc::new(FFmpegDecoder::new()), 3), + ); + assert_eq!( + eviction_victim(&cache), + Some(("b.mp4".to_string(), 0)), + "no hardware session: the LRU software one goes" + ); + assert_eq!(eviction_victim(&DecoderMap::new()), None); + } + + #[test] + fn missing_colorimetry_warning_is_callable_more_than_once() { + warn_missing_colorimetry_once(); + warn_missing_colorimetry_once(); + } + + #[test] + fn opacity_factor_and_channel_scaling_edge_cases() { + assert!( + opacity_factor(&unknown_effect("com.example.opacity-test")).is_none(), + "a non-opacity effect has no factor" + ); + assert!(opacity_factor(&opacity_effect(true, 1.0)).is_none(), "unity is skipped"); + assert_eq!(opacity_factor(&opacity_effect(true, 0.5)), Some(0.5)); + assert_eq!( + opacity_factor(&opacity_effect(true, 2.0)), + Some(2.0), + "values above one scale up" + ); + // An Opacity effect without the value input defaults to unity. + let mut no_param = opacity_effect(true, 0.5); + no_param.params.clear(); + assert!(opacity_factor(&no_param).is_none()); + + let mut bytes = [0u8; 32]; + scale_channels_in_place(&mut bytes, 0, 2, 1, 2.0); + scale_channels_in_place(&mut bytes, 32, 0, 1, 2.0); + scale_channels_in_place(&mut bytes, 32, 2, 0, 2.0); + assert_eq!(bytes, [0u8; 32], "invalid geometry is inert"); + } + + #[test] + fn montage_opacity_on_non_cpu_textures_warns_and_passes_through() { + let effect = opacity_effect(true, 0.5); + let out = apply_montage_effect(planar_texture((2, 1)), &effect, Rational::new(0, 1)); + assert!(out.is_planar(), "a planar texture is returned unchanged"); + let gpu = Texture::gpu(Arc::new(UnusedCtx), 1, 2, 1, PixelFormat::F32); + let out = apply_montage_effect(gpu, &effect, Rational::new(0, 1)); + assert!(matches!(out, Texture::Gpu { .. })); + } + + #[test] + fn montage_effect_without_a_resolvable_evaluator_passes_through() { + let clip = clip_with_effects(vec![unknown_effect("com.example.no-evaluator")]); + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + crate::ofxhost::install_client(None); + set_plugin_instance_factory(Some(Arc::new(|_id: &str| None))); + let out = apply_clip_effects( + solid_texture(0.8, 0.4, 0.2, 1.0), + &clip, + Rational::new(0, 1), + ); + assert_eq!(first_pixel(&out), [0.8, 0.4, 0.2, 1.0]); + set_plugin_instance_factory(None); + } + + #[test] + fn produced_frame_non_f32_uses_the_cpu_generator() { + for format in [PixelFormat::U8, PixelFormat::U16] { + let params = crate::ticket::VideoTicketParams { + viewer: 1, + project: String::new(), + time: Rational::new(0, 1), + force_size: Some((3, 2)), + force_format: Some(format), + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: None, + montage: Vec::new(), + adjustments: Vec::new(), + }; + let tex = render_produced_frame(Rational::new(1, 1), ¶ms).unwrap(); + assert!(matches!(tex, Texture::Cpu(_)), "{format:?} uses the CPU producer"); + assert_eq!(tex.size(), (3, 2)); + assert_eq!(tex.format(), format); + } + } + + #[test] + fn generate_frame_with_an_invalid_format_reports_nomem() { + assert!(generate_frame( + Rational::new(0, 1), + (4, 4), + PixelFormat::Invalid + ) + .is_err()); + } + + #[test] + fn convert_decoded_to_working_handles_missing_metadata_and_short_buffers() { + let _guard = working_space_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = oak_core::color::pipeline_working_space(); + let output = oak_core::color::pipeline_output_spec(); + oak_core::color::set_pipeline_color_settings( + oak_core::colormath::WorkingColorSpace::AcesCg, + output, + ); + let mut decoded = oak_codec::frame::Frame::new(); + decoded.params = None; // no colorimetry metadata + + // The generic sRGB fallback converts in place. + let mut dst = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(); + convert_decoded_to_working(&mut dst, &decoded); + + // Zero-sized destinations return before touching the buffer. + let mut zero = Frame::new(); + convert_decoded_to_working(&mut zero, &decoded); + + // A short buffer breaks out of the row loop. + let mut short = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(); + short.data.truncate(16); + convert_decoded_to_working(&mut short, &decoded); + + oak_core::color::set_pipeline_color_settings(previous, output); + } + + #[test] + fn footage_working_lut_caches_and_clears() { + let _guard = working_space_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = oak_core::color::pipeline_working_space(); + let output = oak_core::color::pipeline_output_spec(); + oak_core::color::set_pipeline_color_settings( + oak_core::colormath::WorkingColorSpace::AcesCg, + output, + ); + + let first = footage_working_lut(1, 1); + assert!(first.is_some()); + let second = footage_working_lut(1, 1); + assert_eq!( + first.expect("first LUT").0, + second.expect("cached LUT").0, + "the second request hits the cache" + ); + // 16+ distinct colorimetries flush the per-process cache. + for i in 0..20i32 { + let _ = footage_working_lut(10 + i * 3, 20 + i * 7); + } + + oak_core::color::set_pipeline_color_settings(previous, output); + } + + #[test] + fn color_transform_lut_cache_hits_and_clears() { + let Some(processor) = red_doubling_processor("lutcache") else { + return; + }; + assert!(color_transform_lut(&processor).is_some()); + assert!( + color_transform_lut(&processor).is_some(), + "the second request hits the processor's cache entry" + ); + + // Distinct processors flush the cache once it holds 16 entries. + let mut ids = std::collections::HashSet::new(); + for i in 1..20 { + if let Some(p) = lut_processor(&format!("lutflush{i}"), 1.0 + i as f32 * 0.25) { + ids.insert(p.cache_id()); + let _ = color_transform_lut(&p); + } + } + if ids.len() < 2 { + eprintln!("OCIO cache ids are not distinct; cache flush not exercised"); + } + } + + /// Serializes the tests that set process-wide environment variables + /// (`OAK_PERF`) or the decode-service slot. + static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Saves `OAK_PERF` and restores its previous value (or absence) on + /// drop, so a panicking assertion cannot leak the perf override into + /// the next serialized env test. Callers hold [`ENV_TEST_LOCK`]. + struct PerfEnvGuard(Option); + + impl PerfEnvGuard { + fn enable() -> PerfEnvGuard { + let previous = std::env::var_os("OAK_PERF"); + std::env::set_var("OAK_PERF", "1"); + PerfEnvGuard(previous) + } + } + + impl Drop for PerfEnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("OAK_PERF", value), + None => std::env::remove_var("OAK_PERF"), + } + } + } + + /// Serializes the tests that flip [`GPU_COMPOSITE_FAILED`]. + static COMPOSITE_FLAG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// A footage node probed from `path`, wired to a clip block spanning + /// `[in_, out)`, on `graph`. + fn add_footage_clip( + graph: &mut oak_node::graph::Graph, + path: &str, + in_: Rational, + out: Rational, + ) -> oak_node::id::NodeId { + let mut footage = oak_node::footage::FootageBehavior::new(path); + footage.probe().expect("probe the test clip"); + let footage = graph.add_node(oak_node::node::NodeCore::new(), Box::new(footage)); + let (ccore, cbehavior) = oak_node::block::clip_create(); + let clip = graph.add_node(ccore, cbehavior); + graph + .connect( + footage, + clip, + oak_node::block::clip_input::TEXTURE_INPUT, + -1, + ) + .expect("footage -> clip"); + graph + .get_mut(clip) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("clip block") + .core + .range = TimeRange::new(in_, out); + clip + } + + /// An enabled clip block of `[in_, out)` with nothing connected. + fn add_bare_clip(graph: &mut oak_node::graph::Graph, in_: Rational, out: Rational) -> oak_node::id::NodeId { + let (ccore, cbehavior) = oak_node::block::clip_create(); + let clip = graph.add_node(ccore, cbehavior); + graph + .get_mut(clip) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("clip block") + .core + .range = TimeRange::new(in_, out); + clip + } + + /// One sequence with three video tracks: V0 (a plain footage clip), + /// V1 (two clips joined by a transition covering t=1) and V2 (an + /// adjustment block with an opacity chain). Rendering t=1 walks the + /// clip, transition and adjustment steps. + fn build_three_step_project(path: &str) -> (Arc>, oak_node::id::NodeId) { + let project = Project::new(); + let seq; + { + let mut p = project.lock().unwrap(); + let graph = &mut p.graph; + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + seq = graph.add_node(score, sbehavior); + let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create(); + let tl = graph.add_node(tlcore, tlbehavior); + + // V0: one clip covering t=1. + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let v0 = graph.add_node(tcore, tbehavior); + let c0 = add_footage_clip(graph, path, Rational::new(0, 1), Rational::new(2, 1)); + graph + .get_mut(v0) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(c0); + + // V1: two clips joined by a transition covering t=1. + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let v1 = graph.add_node(tcore, tbehavior); + let a = add_footage_clip(graph, path, Rational::new(0, 1), Rational::new(1, 1)); + let b = add_footage_clip(graph, path, Rational::new(1, 1), Rational::new(2, 1)); + let (trcore, trbehavior) = oak_node::block::transition_create(); + let transition = graph.add_node(trcore, trbehavior); + { + let t = graph + .get_mut(transition) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + t.core.range = TimeRange::new(Rational::new(1, 2), Rational::new(3, 2)); + t.core.enabled = true; + } + graph + .connect( + a, + transition, + oak_node::block::transition_input::OUT_BLOCK, + -1, + ) + .unwrap(); + graph + .connect( + b, + transition, + oak_node::block::transition_input::IN_BLOCK, + -1, + ) + .unwrap(); + { + let track = graph + .get_mut(v1) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + track.append_block(a); + track.append_block(transition); + track.append_block(b); + } + + // V2: an adjustment block with an opacity chain on top. + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let v2 = graph.add_node(tcore, tbehavior); + let (acore, abehavior) = oak_node::block::adjustment_create(); + let adjustment = graph.add_node(acore, abehavior); + { + let a = graph + .get_mut(adjustment) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + a.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)); + a.core.enabled = true; + } + let (ecore, ebehavior) = oak_node::nodes::opacity::create(); + let effect = graph.add_node(ecore, ebehavior); + graph + .connect( + effect, + adjustment, + oak_node::block::adjustment_input::TEXTURE_INPUT, + -1, + ) + .unwrap(); + graph.get_mut(effect).unwrap().core.set_standard_value( + oak_node::nodes::opacity::VALUE_INPUT, + -1, + NodeValue::Float(0.5), + ); + graph + .get_mut(v2) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(adjustment); + + let tl_behavior = graph + .get_mut(tl) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + tl_behavior.tracks.push(v0); + tl_behavior.tracks.push(v1); + tl_behavior.tracks.push(v2); + graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(tl); + } + (project, seq) + } + + /// A sequence whose track/track-block lists mix stale ids, foreign + /// behaviors and degenerate blocks; returns the sequence plus the + /// bare and undecodable clips for direct evaluation. + fn build_degenerate_project( + missing_media: &str, + ) -> ( + Arc>, + oak_node::id::NodeId, + oak_node::id::NodeId, + oak_node::id::NodeId, + ) { + use oak_node::id::NodeId; + let project = Project::new(); + let empty_clip; + let bad_clip; + let seq; + { + let mut p = project.lock().unwrap(); + let graph = &mut p.graph; + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + seq = graph.add_node(score, sbehavior); + let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create(); + let tl = graph.add_node(tlcore, tlbehavior); + + // Stale and foreign entries in the sequence's track lists. + let stale_list = NodeId::from_identity(900_001).unwrap(); + let (score2, sbehavior2) = oak_node::sequence::SequenceBehavior::create(); + let not_a_tracklist = graph.add_node(score2, sbehavior2); + + // A real list with stale/foreign entries in its track list. + let stale_track = NodeId::from_identity(900_002).unwrap(); + let (score3, sbehavior3) = oak_node::sequence::SequenceBehavior::create(); + let not_a_track = graph.add_node(score3, sbehavior3); + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let track = graph.add_node(tcore, tbehavior); + + // Blocks: a stale id, a transition with no neighbors and a + // bare clip. + let stale_block = NodeId::from_identity(900_003).unwrap(); + let (trcore, trbehavior) = oak_node::block::transition_create(); + let transition = graph.add_node(trcore, trbehavior); + { + let t = graph + .get_mut(transition) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + t.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)); + t.core.enabled = true; + } + empty_clip = add_bare_clip(graph, Rational::new(0, 1), Rational::new(2, 1)); + { + let track_behavior = graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + track_behavior.blocks.push(stale_block); + track_behavior.blocks.push(transition); + track_behavior.blocks.push(empty_clip); + } + + // A second real track whose clip references a missing file: + // the unresolved footage-job box is left in the table. + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let track2 = graph.add_node(tcore, tbehavior); + bad_clip = add_footage_clip( + graph, + missing_media, + Rational::new(0, 1), + Rational::new(2, 1), + ); + graph + .get_mut(track2) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(bad_clip); + + { + let tl_behavior = graph + .get_mut(tl) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + tl_behavior.tracks.push(stale_track); + tl_behavior.tracks.push(not_a_track); + tl_behavior.tracks.push(track); + tl_behavior.tracks.push(track2); + } + graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(stale_list); + graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(not_a_tracklist); + graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(tl); + } + (project, seq, empty_clip, bad_clip) + } + + #[test] + fn render_graph_frame_rejects_bad_format_and_size() { + let project = Project::new(); + let seq; + { + let mut p = project.lock().unwrap(); + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + seq = p.graph.add_node(score, sbehavior); + } + assert!(render_graph_frame( + &project, + seq, + Rational::new(0, 1), + (16, 16), + PixelFormat::U8 + ) + .is_err()); + assert!(render_graph_frame( + &project, + seq, + Rational::new(0, 1), + (0, 16), + PixelFormat::F32 + ) + .is_err()); + } + + #[test] + fn render_graph_frame_skips_stale_and_textureless_steps() { + // Probe a real clip, then delete the file: the footage job is built + // at evaluation time but its decode fails, so the unresolved box + // reaches the texture-channel checks. + let path = std::env::temp_dir().join(format!( + "oakrender_degenerate_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + let name = path.to_string_lossy().into_owned(); + let (project, seq, empty_clip, bad_clip) = build_degenerate_project(&name); + let _ = std::fs::remove_file(&path); + + let out = render_graph_frame( + &project, + seq, + Rational::new(1, 2), + (4, 4), + PixelFormat::F32, + ); + assert_eq!(out.expect("degenerate render").size(), (4, 4)); + + // Directly: a clip with nothing connected yields no texture, an + // undecodable footage job leaves its box behind. + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + { + let guard = project.lock().unwrap(); + let empty = evaluate_block_frame( + &guard.graph, + &mut traverser, + &mut hooks, + empty_clip, + Rational::new(1, 2), + ); + assert!(matches!(empty, Ok(None)), "a bare clip produces nothing"); + let bad = evaluate_block_frame( + &guard.graph, + &mut traverser, + &mut hooks, + bad_clip, + Rational::new(1, 2), + ); + assert!( + matches!(bad, Ok(None)), + "an unresolved footage job is not a texture" + ); + } + } + + #[test] + fn render_graph_frame_stamps_cpu_frames_when_the_gpu_composite_is_off() { + let project = Project::new(); + let seq; + { + let mut p = project.lock().unwrap(); + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + seq = p.graph.add_node(score, sbehavior); + } + let _guard = COMPOSITE_FLAG_LOCK.lock().unwrap(); + let previous = + GPU_COMPOSITE_FAILED.swap(true, std::sync::atomic::Ordering::Relaxed); + let out = render_graph_frame( + &project, + seq, + Rational::new(3, 1), + (4, 4), + PixelFormat::F32, + ); + GPU_COMPOSITE_FAILED.store(previous, std::sync::atomic::Ordering::Relaxed); + match out.expect("cpu composite") { + Texture::Cpu(frame) => { + assert_eq!(frame.timestamp, Rational::new(3, 1)); + assert_eq!((frame.width, frame.height), (4, 4)); + } + Texture::Gpu { .. } | Texture::Planar(_) => { + panic!("the CPU fallback must produce a CPU frame") + } + } + } + + #[test] + fn oak_perf_graph_render_logs_every_step() { + let _guard = ENV_TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "oakrender_perf_graph_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + let (project, seq) = build_three_step_project(&path.to_string_lossy()); + let _perf = PerfEnvGuard::enable(); + let out = render_graph_frame( + &project, + seq, + Rational::new(1, 1), + (16, 16), + PixelFormat::F32, + ); + assert_eq!(out.expect("perf render").size(), (16, 16)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn oak_perf_footage_decode_logs_and_reuses_sessions() { + let _guard = ENV_TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "oakrender_perf_decode_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + let name = path.to_string_lossy().into_owned(); + let _perf = PerfEnvGuard::enable(); + let first = open_decoder(&name, 0); + let second = open_decoder(&name, 0); + let decoded = render_footage_frame(&name, 0, Rational::new(0, 1), (16, 16), PixelFormat::F32); + assert!(first.is_ok(), "the first open logs (request)"); + assert!(second.is_ok(), "the second open logs CACHED"); + assert!(decoded.is_ok(), "the decode logs [decode]"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn shut_down_decode_service_falls_back_to_inline_decode() { + let _guard = ENV_TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "oakrender_stopped_service_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + let name = path.to_string_lossy().into_owned(); + let service = crate::pipeline::DecodeService::new(1, Arc::new(|| true)); + service.shutdown(); + crate::pipeline::install_decode_service(Some(service)); + let direct = render_footage_frame(&name, 0, Rational::new(0, 1), (16, 16), PixelFormat::F32); + let staged = + render_footage_frame_staged(&name, 0, Rational::new(0, 1), (16, 16), PixelFormat::F32); + crate::pipeline::install_decode_service(None); + assert!(direct.is_ok(), "a gone service falls back inline"); + assert!(staged.is_ok(), "the staged path falls back inline too"); + let _ = std::fs::remove_file(&path); + } + + // ---- Coverage edges: GPU shader jobs and composites ----------------- + + /// A payload that asks a registered node for a shader variant nobody + /// implements: the job warns and yields nothing. + #[test] + fn gpu_shader_job_reports_missing_shaders_and_bad_bindings() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.checkerboard".into(), + shader_id: "no-such-shader".into(), + effect_input: "tex_in".into(), + ..ShaderJobPayload::default() + }; + assert!( + RenderEvalHooks::new().process_shader_job(&payload).is_none(), + "an unknown shader id yields no texture" + ); + + // merge (no declared effect input): a real base plus a null handle + // and an unresolved planar texture. Both bad inputs are skipped, + // the pass still runs at the base's size. + let mut params = NodeValueRow::new(); + params.insert( + "base_in".into(), + texture_value(filled_frame((4, 4), [1.0, 0.0, 0.0, 1.0])), + ); + params.insert("blend_in".into(), NodeValue::Texture(CHandle::null())); + params.insert( + "extra_in".into(), + NodeValue::Texture(oak_node::handle::make_owned(planar_texture((4, 4)))), + ); + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.merge".into(), + shader_id: String::new(), + effect_input: String::new(), + params, + ..ShaderJobPayload::default() + }; + let out = RenderEvalHooks::new() + .process_shader_job(&payload) + .expect("the merge runs with the skippable inputs unbound"); + assert_eq!(out.size(), (4, 4)); + } + + #[test] + fn gpu_shader_job_binds_nested_shader_payloads() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + let nested = Job::ShaderJob(ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.solidgenerator".into(), + params: NodeValueRow::from([( + "color_in".to_string(), + NodeValue::Color([0.0, 1.0, 0.0, 1.0]), + )]), + ..ShaderJobPayload::default() + }); + let mut params = NodeValueRow::new(); + params.insert( + "base_in".into(), + texture_value(filled_frame((4, 4), [1.0, 0.0, 0.0, 1.0])), + ); + params.insert( + "blend_in".into(), + NodeValue::Texture(oak_node::handle::make_owned(nested)), + ); + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.merge".into(), + shader_id: String::new(), + effect_input: String::new(), + params, + ..ShaderJobPayload::default() + }; + let out = RenderEvalHooks::new() + .process_shader_job(&payload) + .expect("the nested generator resolves through the bind"); + let px = first_pixel(&out); + assert!( + px[1] > 0.9 && px[0] < 0.1, + "the generated green covers the red base: {px:?}" + ); + } + + #[test] + fn gpu_grading_job_splices_the_ocio_grading_stub() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + if oak_core::color::set_up_default_config().is_err() { + eprintln!("bundled OCIO missing; skipping"); + return; + } + if oak_core::color::grading_primary_function_shader(oak_core::color::GradingStyle::Lin) + .is_none() + { + eprintln!("no OCIO grading stub; skipping"); + return; + } + let mut params = NodeValueRow::new(); + params.insert( + "tex_in".into(), + texture_value(filled_frame((4, 4), [0.5, 0.5, 0.5, 1.0])), + ); + params.insert( + "OCIO_NAMESPACE_grading_primary_brightness".into(), + NodeValue::Float(1.0), + ); + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.ociogradingtransformlinear".into(), + shader_id: String::new(), + effect_input: "tex_in".into(), + params, + ..ShaderJobPayload::default() + }; + let out = RenderEvalHooks::new().process_shader_job(&payload); + assert!( + out.is_some(), + "the grading node renders with the spliced OCIO stub" + ); + } + + #[test] + fn gpu_composite_reads_foreign_textures_and_rejects_planar() { + let Some(ctx) = oak_core::backend::shared_gpu_or_skip("an eval GPU test") else { + return; + }; + assert!(composite_tracks_gpu(&ctx, &[], (0, 1)).is_err()); + + // A GPU texture from another context is read back and re-uploaded. + let foreign = echo_gpu_texture((2, 1), [0.25, 0.5, 0.75, 1.0], 0xDEAD_BEEF); + let out = composite_tracks_gpu(&ctx, &[foreign], (2, 1)).expect("foreign readback"); + let px = first_pixel(&out); + assert!( + (px[0] - 0.25).abs() < 5e-3 + && (px[1] - 0.5).abs() < 5e-3 + && (px[2] - 0.75).abs() < 5e-3, + "foreign texture survives the readback: {px:?}" + ); + + // An unresolved planar frame is a hard error; the accumulator is + // torn down on the way out. + let planar = planar_texture((2, 1)); + assert!(composite_tracks_gpu(&ctx, &[planar], (2, 1)).is_err()); + } + + #[test] + fn composite_tracks_cpu_fallback_skips_unreadable_and_mismatched_frames() { + let _guard = COMPOSITE_FLAG_LOCK.lock().unwrap(); + let previous = GPU_COMPOSITE_FAILED.swap(false, std::sync::atomic::Ordering::Relaxed); + // The planar frame fails the GPU pass (and flips the sticky flag); + // the CPU fallback then skips it, skips a wrongly-sized frame and + // composites the valid one. + let planar = planar_texture((2, 1)); + let wrong = filled_frame((3, 1), [0.0, 1.0, 0.0, 1.0]); + let right = filled_frame((2, 1), [1.0, 0.0, 0.0, 1.0]); + let out = composite_tracks(vec![planar, wrong, right], (2, 1)); + GPU_COMPOSITE_FAILED.store(previous, std::sync::atomic::Ordering::Relaxed); + assert_eq!(out.size(), (2, 1)); + let px = first_pixel(&out); + assert!( + (px[0] - 1.0).abs() < 1e-4 && px[1].abs() < 1e-4 && (px[3] - 1.0).abs() < 1e-4, + "only the valid frame composites: {px:?}" + ); + } + + // ---- Coverage edges: montage clip effects with GPU results ---------- + + fn montage_params_with_effect( + path: &str, + effect_type_id: &str, + ) -> crate::ticket::VideoTicketParams { + crate::ticket::VideoTicketParams { + viewer: 1, + project: String::new(), + time: Rational::new(0, 1), + force_size: Some((16, 16)), + force_format: Some(PixelFormat::F32), + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: None, + montage: vec![crate::ticket::MontageClip { + filename: path.to_string(), + stream_index: 0, + in_time: Rational::new(0, 1), + out_time: Rational::new(2, 1), + media_in: Rational::new(0, 1), + gain: 1.0, + effects: vec![unknown_effect(effect_type_id)], + }], + adjustments: Vec::new(), + } + } + + #[test] + fn montage_reads_back_gpu_textures_from_clip_effects() { + let _env = ENV_TEST_LOCK.lock().unwrap(); + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "oakrender_montage_gpu_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + crate::ofxhost::install_client(None); + set_plugin_instance_factory(Some(Arc::new(|_id: &str| Some(7)))); + set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| { + let frame = filled_frame(req.src.size(), [0.5, 0.5, 0.5, 1.0]).to_frame()?; + Ok(Texture::gpu( + Arc::new(FrameEchoCtx { frame }), + 0xE0, + req.src.size().0, + req.src.size().1, + PixelFormat::F32, + )) + }))); + let params = montage_params_with_effect(&path.to_string_lossy(), "com.example.gpu-effect"); + let mut dst = vec![0u8; 16 * 16 * 16]; + render_montage_frame_into(Rational::new(0, 1), ¶ms, (16, 16), &mut dst, 256) + .expect("the GPU effect result is read back and composited"); + let px = read_f32_px(&dst, 256, 1, 1); + assert!((px[0] - 0.5).abs() < 1e-4, "gpu effect pixels land: {px:?}"); + set_plugin_executor(None); + set_plugin_instance_factory(None); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn montage_rejects_planar_textures_from_clip_effects() { + let _env = ENV_TEST_LOCK.lock().unwrap(); + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "oakrender_montage_planar_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + crate::ofxhost::install_client(None); + set_plugin_instance_factory(Some(Arc::new(|_id: &str| Some(7)))); + set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| { + Ok(planar_texture(req.src.size())) + }))); + let params = + montage_params_with_effect(&path.to_string_lossy(), "com.example.planar-effect"); + let mut dst = vec![0u8; 16 * 16 * 16]; + let err = render_montage_frame_into(Rational::new(0, 1), ¶ms, (16, 16), &mut dst, 256); + set_plugin_executor(None); + set_plugin_instance_factory(None); + let _ = std::fs::remove_file(&path); + assert!(err.is_err(), "an unresolved planar texture is rejected"); + } + + /// The single OFX host client owns dispatch when one is installed: + /// the montage effect path takes the host branch, and a host that + /// cannot come up yields the purple failure frame. + #[test] + #[cfg(unix)] + fn montage_effect_uses_the_installed_ofx_host() { + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + let host = crate::ofxhost::OfxHost::new(crate::ofxhost::OfxHostConfig { + host_bin: Some(std::path::PathBuf::from("/bin/false")), + max_failures: 1, + ..Default::default() + }) + .unwrap(); + crate::ofxhost::install_client(Some(host)); + let out = apply_montage_effect( + solid_texture(0.8, 0.4, 0.2, 1.0), + &unknown_effect("com.example.host-effect"), + Rational::new(0, 1), + ); + crate::ofxhost::install_client(None); + assert_eq!( + first_pixel(&out), + [1.0, 0.0, 1.0, 1.0], + "a failed host render paints the purple frame" + ); + } + + /// A test-only node behavior that emits a null texture handle, so the + /// graph seams' null-handle guards are reachable without a producer + /// bug. + struct NullTextureBehavior; + + impl oak_node::node::NodeBehavior for NullTextureBehavior { + fn name(&self) -> &str { + "NullTexture" + } + fn type_id(&self) -> &str { + "org.oak.test.nulltexture" + } + fn duplicate( + &self, + _core: &oak_node::node::NodeCore, + ) -> Option> { + Some(Box::new(Self)) + } + fn value( + &self, + _core: &oak_node::node::NodeCore, + _inputs: &NodeValueRow, + _time: Rational, + table: &mut NodeValueTable, + ) { + table.push( + oak_node::value::ValueType::Texture, + NodeValue::Texture(CHandle::null()), + None, + ); + } + } + + #[test] + fn null_texture_outputs_are_skipped_by_the_graph_paths() { + let project = Project::new(); + let seq; + let clip; + { + let mut p = project.lock().unwrap(); + let graph = &mut p.graph; + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + seq = graph.add_node(score, sbehavior); + let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create(); + let tl = graph.add_node(tlcore, tlbehavior); + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let track = graph.add_node(tcore, tbehavior); + + let null_node = graph.add_node( + oak_node::node::NodeCore::new(), + Box::new(NullTextureBehavior), + ); + clip = add_bare_clip(graph, Rational::new(0, 1), Rational::new(1, 1)); + graph + .connect( + null_node, + clip, + oak_node::block::clip_input::TEXTURE_INPUT, + -1, + ) + .expect("null node -> clip"); + graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(clip); + graph + .get_mut(tl) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .tracks + .push(track); + graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(tl); + } + + // Direct: a null handle is not a texture (`Ok(None)`). + { + let guard = project.lock().unwrap(); + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + let out = evaluate_block_frame( + &guard.graph, + &mut traverser, + &mut hooks, + clip, + Rational::new(1, 2), + ); + assert!(matches!(out, Ok(None))); + } + + // Render: the clip's null texture is skipped and the frame stays + // transparent black. + let frame = render_graph_frame( + &project, + seq, + Rational::new(1, 2), + (4, 4), + PixelFormat::F32, + ) + .expect("null texture render"); + assert_eq!(frame.size(), (4, 4)); + let readback = frame.to_frame().expect("readback"); + assert!(readback.data.iter().all(|&b| b == 0)); + } + + // ---- Coverage edges: shader bind failures and adjustment sweeps ---- + + /// A behavior with a synthetic texture output, so the transition and + /// sweep seams can be driven without a real producer. `value` is + /// pushed on the texture channel (nothing when `None`). + struct FixedTextureBehavior { + value: Option, + } + + impl oak_node::node::NodeBehavior for FixedTextureBehavior { + fn name(&self) -> &str { + "FixedTexture" + } + fn type_id(&self) -> &str { + "org.oak.test.fixedtexture" + } + fn duplicate( + &self, + _core: &oak_node::node::NodeCore, + ) -> Option> { + Some(Box::new(Self { + value: self.value.clone(), + })) + } + fn value( + &self, + _core: &oak_node::node::NodeCore, + _inputs: &NodeValueRow, + _time: Rational, + table: &mut NodeValueTable, + ) { + if let Some(value) = &self.value { + table.push(oak_node::value::ValueType::Texture, value.clone(), None); + } + } + } + + /// A behavior whose fragment source cannot compile (the shader-job + /// path must warn and report no texture). + struct BadShaderBehavior; + + impl oak_node::node::NodeBehavior for BadShaderBehavior { + fn name(&self) -> &str { + "BadShader" + } + fn type_id(&self) -> &str { + "org.oak.test.badshader" + } + fn duplicate( + &self, + _core: &oak_node::node::NodeCore, + ) -> Option> { + Some(Box::new(Self)) + } + fn shader_code(&self, _request: &str) -> Option { + Some("%%% this is not valid glsl %%%".to_string()) + } + } + + /// A core with an effect input `tex_in`, optionally not connectable + /// (the sweep's refused-connection error path). + fn effect_head_core(connectable: bool) -> oak_node::node::NodeCore { + let mut core = oak_node::node::NodeCore::new(); + let mut input = oak_node::input::Input::new( + "tex_in", + oak_node::value::ValueType::Texture, + NodeValue::None, + ); + if !connectable { + input.flags |= oak_node::input::flags::NOT_CONNECTABLE; + } + core.add_input(input); + core.effect_input = "tex_in".to_string(); + core + } + + /// The nested-payload recursion ceiling in `bind`: a job box at the + /// depth limit is left unbound instead of recursing, and the pass + /// still runs against the placeholders. + #[test] + fn gpu_shader_job_depth_ceiling_leaves_the_nested_box_unbound() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + let nested = Job::ShaderJob(ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.solidgenerator".into(), + params: NodeValueRow::from([( + "color_in".to_string(), + NodeValue::Color([0.0, 1.0, 0.0, 1.0]), + )]), + ..ShaderJobPayload::default() + }); + let mut params = NodeValueRow::new(); + params.insert( + "blend_in".into(), + NodeValue::Texture(oak_node::handle::make_owned(nested)), + ); + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.merge".into(), + shader_id: String::new(), + effect_input: String::new(), + params, + ..ShaderJobPayload::default() + }; + let out = RenderEvalHooks::new() + .process_shader_job_depth(&payload, 8) + .expect("the pass runs with the nested box unbound"); + assert_eq!(out.size(), (1, 1), "no input bound: the 1x1 fallback"); + } + + /// Inputs whose scratch texture cannot be created (a 0x0 frame) or + /// uploaded (a frame with a short buffer) are skipped; the pass runs + /// against the placeholders. + #[test] + fn gpu_shader_job_skips_uncreatable_and_unuploadable_inputs() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + // A 0x0 CPU texture: `create_texture` rejects the geometry. + let zero = Texture::dummy(); + // A 2x2 frame with no payload: `upload` rejects the short buffer. + let mut short = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(); + short.data.clear(); + let mut params = NodeValueRow::new(); + params.insert("base_in".into(), texture_value(zero)); + params.insert("blend_in".into(), texture_value(Texture::wrap_frame(short))); + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.merge".into(), + shader_id: String::new(), + effect_input: String::new(), + params, + ..ShaderJobPayload::default() + }; + let out = RenderEvalHooks::new() + .process_shader_job(&payload) + .expect("the pass runs with both bad inputs skipped"); + assert_eq!(out.size(), (1, 1)); + } + + /// A bound token the context does not own fails the pass at run time + /// (and the reserved output texture is released). + #[test] + fn gpu_shader_job_run_failure_reports_none() { + let Some(ctx) = oak_core::backend::shared_gpu_or_skip("an eval GPU test") else { + return; + }; + let bogus = Texture::gpu(ctx, 0xDEAD_BEEF, 4, 4, PixelFormat::F32); + let mut params = NodeValueRow::new(); + params.insert("base_in".into(), texture_value(bogus)); + let payload = ShaderJobPayload { + type_id: "org.olivevideoeditor.Olive.merge".into(), + shader_id: String::new(), + effect_input: String::new(), + params, + ..ShaderJobPayload::default() + }; + assert!( + RenderEvalHooks::new().process_shader_job(&payload).is_none(), + "a foreign token fails the bind group and yields no texture" + ); + } + + /// A registered node whose shader source does not translate to WGSL + /// reports a compile failure instead of running. + #[test] + fn gpu_shader_job_reports_compile_failures() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + let _ = oak_node::factory::Factory::global().register_dynamic( + oak_node::factory::DynamicNodeMeta { + type_id: "org.oak.test.badshader".into(), + name: "BadShader".into(), + categories: Vec::new(), + sub_category: String::new(), + description: String::new(), + create: Arc::new(|| { + ( + oak_node::node::NodeCore::new(), + Box::new(BadShaderBehavior), + ) + }), + }, + ); + let payload = ShaderJobPayload { + type_id: "org.oak.test.badshader".into(), + shader_id: String::new(), + ..ShaderJobPayload::default() + }; + assert!( + RenderEvalHooks::new().process_shader_job(&payload).is_none(), + "an untranslatable shader compiles to nothing" + ); + } + + /// A color transform on a GPU texture whose token cannot be read back + /// surfaces the readback error (the LUT pass refused it first). + #[test] + fn color_transform_job_reports_a_failed_gpu_readback() { + if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() { + return; + } + let Some(processor) = red_doubling_processor("badtoken") else { + return; + }; + let Some(ctx) = oak_core::backend::GpuContext::shared() else { + return; + }; + let input = Texture::gpu(ctx, 0x0BAD_7001, 2, 2, PixelFormat::F32); + let payload = ColorTransformJobPayload { + color_processor: Arc::new(processor), + input: texture_value(input), + time: Rational::new(0, 1), + }; + assert!( + RenderEvalHooks::new() + .process_color_transform_job(&payload) + .is_err(), + "the invalid token cannot be converted" + ); + } + + /// An imported planar texture whose plane tokens the context does not + /// own fails the YUV pass and reports the error (the caller then + /// stages through the CPU decoder). + #[test] + fn resolve_planar_footage_reports_a_failed_yuv_pass() { + let Some(ctx) = oak_core::backend::shared_gpu_or_skip("an eval GPU test") else { + return; + }; + let planar = Texture::wrap_planar(oak_core::texture::PlanarTexture::new( + ctx, + oak_core::texture::PlanarFormat::Nv12, + (4, 4), + (0x0BAD_A001, 0x0BAD_A002), + oak_core::backend::YuvTransform::bt709_limited(), + (2, 2), + )); + assert!( + resolve_planar_footage(&planar).is_err(), + "missing plane tokens fail the planar pass" + ); + } + + /// A single-sided transition pads the missing side with transparent + /// black and blends (the head/tail fade); a 0x0 side cannot be padded + /// and falls through to the native side without a blend job. + #[test] + fn blend_transition_fills_a_missing_side_and_skips_the_blend_without_a_pair() { + let mut graph = oak_node::graph::Graph::new(); + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + + let from = graph.add_node( + oak_node::node::NodeCore::new(), + Box::new(FixedTextureBehavior { + value: Some(texture_value(filled_frame((4, 4), [1.0, 0.0, 0.0, 1.0]))), + }), + ); + let out = blend_transition( + &graph, + &mut traverser, + &mut hooks, + Some(from), + None, + Rational::new(0, 1), + 0.25, + "crossdissolve", + ) + .expect("single-sided blend"); + let blended = out.expect("the missing side is filled with black"); + assert_eq!(blended.size(), (4, 4)); + + // A 0x0 side: black() cannot create the fill, so the pair match + // falls through to the native side. + let dummy = graph.add_node( + oak_node::node::NodeCore::new(), + Box::new(FixedTextureBehavior { + value: Some(texture_value(Texture::dummy())), + }), + ); + let out = blend_transition( + &graph, + &mut traverser, + &mut hooks, + Some(dummy), + None, + Rational::new(0, 1), + 0.25, + "crossdissolve", + ) + .expect("degenerate single-sided blend"); + let native = out.expect("the native side is returned"); + assert_eq!(native.size(), (0, 0)); + } + + /// The sweep's head table can carry no texture, a null handle, or an + /// unboxable job handle: all three leave the boundary unchanged. + #[test] + fn flush_adjustment_layer_handles_textureless_heads() { + let cases: [(&str, Option); 3] = [ + ("none", None), + ("null", Some(NodeValue::Texture(CHandle::null()))), + ( + "job", + Some(NodeValue::Texture(oak_node::handle::make_owned( + Job::FootageJob(FootageJobPayload::default()), + ))), + ), + ]; + for (tag, value) in cases { + let mut graph = oak_node::graph::Graph::new(); + let (acore, abehavior) = oak_node::block::adjustment_create(); + let block = graph.add_node(acore, abehavior); + let head = graph.add_node( + effect_head_core(true), + Box::new(FixedTextureBehavior { value }), + ); + graph + .connect( + head, + block, + oak_node::block::adjustment_input::TEXTURE_INPUT, + -1, + ) + .expect("head -> block"); + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + let sweep = flush_adjustment_layer( + &mut graph, + &mut traverser, + &mut hooks, + block, + &[], + (4, 4), + Rational::new(0, 1), + 0.5, + ) + .expect(tag); + assert!(sweep.is_none(), "{tag}: the boundary is unchanged"); + } + } + + /// A chain head whose effect input is not connectable cannot be fed: + /// the sweep reports the refused connection. The graph driver maps the + /// same error out of `render_graph_frame`. + #[test] + fn flush_adjustment_layer_reports_a_refused_connection() { + let mut graph = oak_node::graph::Graph::new(); + let (acore, abehavior) = oak_node::block::adjustment_create(); + let block = graph.add_node(acore, abehavior); + let head = graph.add_node( + effect_head_core(false), + Box::new(FixedTextureBehavior { + value: Some(texture_value(filled_frame((2, 2), [0.0, 0.0, 0.0, 1.0]))), + }), + ); + graph + .connect( + head, + block, + oak_node::block::adjustment_input::TEXTURE_INPUT, + -1, + ) + .expect("head -> block"); + let mut traverser = oak_node::traverser::Traverser::new(); + let mut hooks = RenderEvalHooks::new(); + let err = flush_adjustment_layer( + &mut graph, + &mut traverser, + &mut hooks, + block, + &[], + (4, 4), + Rational::new(0, 1), + 0.5, + ) + .unwrap_err(); + assert!( + err.to_string().contains("cannot feed"), + "the error names the refused feed: {err}" + ); + } + + /// `render_graph_frame` propagates an adjustment sweep failure instead + /// of dropping the frame. + #[test] + fn render_graph_frame_reports_a_refused_adjustment_feed() { + let project = Project::new(); + let seq; + { + let mut p = project.lock().unwrap(); + let graph = &mut p.graph; + let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create(); + seq = graph.add_node(score, sbehavior); + let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create(); + let tl = graph.add_node(tlcore, tlbehavior); + let (tcore, tbehavior) = oak_node::track::TrackBehavior::create(); + let track = graph.add_node(tcore, tbehavior); + + let (acore, abehavior) = oak_node::block::adjustment_create(); + let block = graph.add_node(acore, abehavior); + { + let a = graph + .get_mut(block) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + a.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)); + a.core.enabled = true; + } + let head = graph.add_node( + effect_head_core(false), + Box::new(FixedTextureBehavior { + value: Some(texture_value(filled_frame((4, 4), [0.0, 0.0, 0.0, 1.0]))), + }), + ); + graph + .connect( + head, + block, + oak_node::block::adjustment_input::TEXTURE_INPUT, + -1, + ) + .expect("head -> block"); + graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(block); + graph + .get_mut(tl) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .tracks + .push(track); + graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(tl); + } + let err = render_graph_frame( + &project, + seq, + Rational::new(0, 1), + (4, 4), + PixelFormat::F32, + ) + .unwrap_err(); + assert!( + err.to_string().contains("cannot feed"), + "the sweep error reaches the caller: {err}" + ); + } + + /// A montage clip whose media cannot be opened propagates the decode + /// error rather than compositing a hole. + #[test] + fn montage_reports_a_failed_clip_decode() { + let path = std::env::temp_dir().join(format!( + "oakrender_missing_montage_{}.mp4", + std::process::id() + )); + let params = montage_params_with_effect(&path.to_string_lossy(), "com.example.unused"); + let mut dst = vec![0u8; 16 * 16 * 16]; + assert!( + render_montage_frame_into( + Rational::new(0, 1), + ¶ms, + (16, 16), + &mut dst, + 256 + ) + .is_err(), + "a missing clip file fails the montage frame" + ); + } + + /// A clip effect that hands back a GPU texture on a context whose + /// readback fails surfaces the readback error. + #[test] + fn montage_reports_a_failed_gpu_readback() { + let _env = ENV_TEST_LOCK.lock().unwrap(); + let _guard = PLUGIN_TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "oakrender_montage_readback_{}.mp4", + std::process::id() + )); + oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip"); + crate::ofxhost::install_client(None); + set_plugin_instance_factory(Some(Arc::new(|_id: &str| Some(7)))); + set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| { + Ok(Texture::gpu( + Arc::new(UnusedCtx), + 0xE1, + req.src.size().0, + req.src.size().1, + PixelFormat::F32, + )) + }))); + let params = montage_params_with_effect(&path.to_string_lossy(), "com.example.no-readback"); + let mut dst = vec![0u8; 16 * 16 * 16]; + let result = render_montage_frame_into( + Rational::new(0, 1), + ¶ms, + (16, 16), + &mut dst, + 256, + ); + set_plugin_executor(None); + set_plugin_instance_factory(None); + let _ = std::fs::remove_file(&path); + assert!( + result.is_err(), + "an unreadable GPU effect result fails the montage frame" + ); + } + + /// Degenerate adjustment-span geometry is inert: a zero width cannot + /// stage a frame, and a destination too small for the staged copy is + /// left untouched. + #[test] + fn adjustment_span_degenerate_geometry_is_inert() { + let span = adjustment_span( + 0, + 2, + 0, + vec![unknown_effect("com.example.span-geometry")], + ); + + // w == 0: the staging frame cannot be generated. + let mut dst = vec![0xABu8; 16]; + apply_adjustment_span(&mut dst, 16, 0, 1, &span, Rational::new(0, 1)); + assert!(dst.iter().all(|&b| b == 0xAB), "zero width is inert"); + + // The destination is shorter than the staged frame geometry. + let mut dst = vec![0xCDu8; 8]; + apply_adjustment_span(&mut dst, 8, 2, 1, &span, Rational::new(0, 1)); + assert!(dst.iter().all(|&b| b == 0xCD), "a short buffer is inert"); + } + + /// The tests' GPU stand-ins and the null-texture behavior expose their + /// documented error/duplication contracts. + #[test] + fn test_context_stubs_report_their_contracts() { + use oak_core::backend::GpuContextLike; + use oak_node::node::NodeBehavior; + + let unused = UnusedCtx; + assert!(unused.upload(0, &Frame::dummy()).is_err()); + assert!(unused.download(0).is_err()); + assert!(unused.blit(0, 0, None).is_err()); + unused.destroy_texture(0); + + let echo = FrameEchoCtx { + frame: Frame::dummy(), + }; + assert!(echo.upload(7, &Frame::dummy()).is_ok()); + assert_eq!(echo.download(7).unwrap().width, 0); + assert!(echo.blit(0, 0, None).is_err()); + + let behavior = NullTextureBehavior; + assert_eq!(behavior.name(), "NullTexture"); + assert_eq!(behavior.type_id(), "org.oak.test.nulltexture"); + assert!(behavior.duplicate(&oak_node::node::NodeCore::new()).is_some()); + } + } -+ static LOCK: Mutex<()> = Mutex::new(()); diff --git a/crates/oak-render/src/pipeline.rs b/crates/oak-render/src/pipeline.rs index 61a3f4d09..22b28d403 100644 --- a/crates/oak-render/src/pipeline.rs +++ b/crates/oak-render/src/pipeline.rs @@ -953,14 +953,21 @@ mod tests { path } - /// Pin the working space to the legacy sRGB pass-through: these tests - /// assert the decoded pattern, not the color transform (the ACEScg - /// default would remap the values). - fn pin_legacy_working_space() { + /// Pin the working space to the legacy sRGB pass-through and hold the + /// crate-wide working-space test lock for the entire decoded-pattern + /// section: these tests assert the decoded pattern, not the color + /// transform (the ACEScg default would remap the values), while the + /// eval tests temporarily switch the same process-global settings. The + /// caller must keep the returned guard alive. + fn pin_legacy_working_space() -> std::sync::MutexGuard<'static, ()> { + let guard = crate::eval::working_space_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); oak_core::color::set_pipeline_color_settings( oak_core::colormath::WorkingColorSpace::SrgbLegacy, oak_core::colormath::OutputColorSpec::default(), ); + guard } fn request(filename: &std::path::Path, time: Rational) -> DecodeRequest { @@ -970,6 +977,7 @@ mod tests { time, size: (64, 64), format: PixelFormat::F32, + allow_import: true, } } @@ -1020,7 +1028,7 @@ mod tests { /// The service decodes real media through the real codec path. #[test] fn request_decodes_real_media() { - pin_legacy_working_space(); + let _guard = pin_legacy_working_space(); let path = test_clip("real"); let service = DecodeService::new(DECODE_LRU_CAP, always()); @@ -1045,7 +1053,7 @@ mod tests { /// follows is served from the cache with no decode at all. #[test] fn prefetch_then_request_hits_the_lru() { - pin_legacy_working_space(); + let _guard = pin_legacy_working_space(); let path = test_clip("prefetch"); let service = DecodeService::new(DECODE_LRU_CAP, always()); @@ -1084,6 +1092,7 @@ mod tests { time: Rational::new(0, 1), size: (64, 64), format: PixelFormat::F32, + allow_import: true, }; let err = service .request(req) @@ -1101,7 +1110,7 @@ mod tests { /// frame must be decoded again). #[test] fn lru_evicts_bounded() { - pin_legacy_working_space(); + let _guard = pin_legacy_working_space(); let path = test_clip("evict"); let service = DecodeService::new(2, always()); let time = |n: i64| request(&path, Rational::new(n, 10)); @@ -1135,7 +1144,7 @@ mod tests { /// (and counts) without queueing anything. #[test] fn prefetch_gate_refuses_and_recovers() { - pin_legacy_working_space(); + let _guard = pin_legacy_working_space(); let path = test_clip("gate"); let open = Arc::new(AtomicBool::new(false)); let gate_open = open.clone(); @@ -1166,7 +1175,7 @@ mod tests { /// prefetch sent before it has been decoded when it returns. #[test] fn wait_idle_barrier_covers_queued_commands() { - pin_legacy_working_space(); + let _guard = pin_legacy_working_space(); let path = test_clip("barrier"); // The barrier test needs four live entries; the production // hand-off capacity is deliberately tiny (see DECODE_LRU_CAP), so @@ -1188,7 +1197,7 @@ mod tests { /// eval path falls back to decoding inline instead of failing frames. #[test] fn shutdown_makes_the_service_unavailable() { - pin_legacy_working_space(); + let _guard = pin_legacy_working_space(); let path = test_clip("shutdown"); let service = DecodeService::new(DECODE_LRU_CAP, always()); service.shutdown(); @@ -1256,6 +1265,7 @@ mod tests { time, size: (16, 16), format: PixelFormat::F32, + allow_import: true, }; { let mut queue = lock(&shared.queue); diff --git a/crates/oak-render/src/procpool.rs b/crates/oak-render/src/procpool.rs index 59ebbb07a..877e099ba 100644 --- a/crates/oak-render/src/procpool.rs +++ b/crates/oak-render/src/procpool.rs @@ -2521,6 +2521,7 @@ fn build_audio_ticket_spec(ticket: i64, slot: u32, params: &AudioTicketParams) - #[cfg(test)] mod tests { use super::*; + use crate::worker::JobSchedule; #[test] fn slot_bytes_for_formats() { @@ -2577,8 +2578,23 @@ mod tests { assert_eq!(total, 8 << 30); assert_eq!(free, 6 << 30); - // No non-zero card -> None (a totally attr-less tree). - for f in [&dir.join("card1"), &dir.join("card0")] { + // card3: a numeric total but no `used` file -> used defaults to 0; + // card4: an unparsable total -> skipped. + let card3 = dir.join("card3").join("device"); + std::fs::create_dir_all(&card3).unwrap(); + std::fs::write(card3.join("mem_info_vram_total"), (4u64 << 30).to_string()).unwrap(); + let card4 = dir.join("card4").join("device"); + std::fs::create_dir_all(&card4).unwrap(); + std::fs::write(card4.join("mem_info_vram_total"), "not-a-number").unwrap(); + std::fs::write(card4.join("mem_info_vram_used"), "0").unwrap(); + // Remove the first winner so the walk reaches card3. + let _ = std::fs::remove_dir_all(dir.join("card1")); + let (free, total) = linux_drm_vram_bytes_from(&dir).expect("card3 wins"); + assert_eq!(total, 4 << 30); + assert_eq!(free, 4 << 30, "a missing `used` file counts as zero"); + + // No usable card -> None (display-only + zero-total + garbage total). + for f in [&dir.join("card0"), &dir.join("card3"), &dir.join("card4")] { let _ = std::fs::remove_dir_all(f); } assert!(linux_drm_vram_bytes_from(&dir).is_none()); @@ -2722,6 +2738,7 @@ mod tests { /// that drives the main-process plugin-progress dialog. #[test] fn plugin_progress_line_forwards_to_callback() { + let _lock = pool_test_lock(); let config = DispatcherConfig { worker_bin: Some(std::path::PathBuf::from("/bin/true")), workers: 1, @@ -2769,4 +2786,2104 @@ mod tests { assert_eq!(events.len(), 1); assert_eq!(events[0], ("render".to_string(), "pass 1".to_string(), 0.5)); } + + // ---- Branch-coverage fill-ins: pure helpers and synthetic workers ------ + + /// Serializes the synthetic-process / global-env tests below (they + /// register the process-wide dispatcher slot and mutate env vars). + static POOL_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn pool_test_lock() -> MutexGuard<'static, ()> { + POOL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn shm_key(name: &str) -> String { + format!("oak-procpool-ut-{}-{name}", std::process::id()) + } + + fn test_config(workers: usize, slots: u32) -> DispatcherConfig { + DispatcherConfig { + worker_bin: Some(std::path::PathBuf::from("/bin/true")), + workers, + slots_per_worker: slots, + width: 16, + height: 16, + batch_size: 2, + ..Default::default() + } + } + + fn video_params(frame: i64) -> VideoTicketParams { + VideoTicketParams { + viewer: 1, + project: String::new(), + time: oak_core::Rational::new(frame, 25), + force_size: Some((16, 16)), + force_format: None, + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: None, + montage: Vec::new(), + adjustments: Vec::new(), + } + } + + fn test_job( + sequence: u64, + frame: i64, + audio: Option>, + results: &Arc>>, + ) -> Job { + let results = results.clone(); + Job { + node_identity: sequence, + time: oak_core::Rational::new(frame, 25), + params: Arc::new(video_params(frame)), + audio, + produce: Arc::new(|_, _| { + Err(Error::Failed( + "process backend does not use the in-process producer".into(), + )) + }), + done: Box::new(move |result| { + results + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(result); + }), + schedule: JobSchedule::seek(), + cancelled: None, + } + } + + fn pump_until( + dispatcher: &ProcessDispatcher, + results: &Mutex>, + expected: usize, + ) { + let deadline = Instant::now() + Duration::from_secs(120); + loop { + dispatcher.poll(); + if results.lock().unwrap_or_else(|e| e.into_inner()).len() >= expected { + return; + } + if Instant::now() > deadline { + let have = results.lock().unwrap_or_else(|e| e.into_inner()).len(); + panic!("timeout: {have}/{expected} completions"); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Allocate a slot from `view`'s free ring, stamp `id` into its metadata + /// and publish it — what a worker does before `frame_ready`. + fn publish_slot(view: &ShmRegionView, id: i64) -> u32 { + let pool = view.pool(); + let mut slot = 0u32; + unsafe { + assert!(pool.acquire(&mut slot), "a free slot"); + let meta = pool.meta(slot); + (*meta).id = id; + (*meta).data_size = 8; + assert!(pool.publish(slot), "ready ring has room"); + } + slot + } + + /// Saves an environment variable and restores it on drop (the env is + /// process-global; all users hold [`POOL_TEST_LOCK`]). + struct EnvRestore { + key: &'static str, + value: Option, + } + + impl EnvRestore { + fn set(key: &'static str, value: &str) -> EnvRestore { + let saved = std::env::var_os(key); + std::env::set_var(key, value); + EnvRestore { key, value: saved } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + match self.value.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + /// The oak-worker binary: `$OAK_WORKER_BIN`, else the sibling of the + /// test executable under `target//`. Tests skip (with a + /// printed reason) when it was never built. + fn find_real_worker() -> Option { + if let Ok(p) = std::env::var("OAK_WORKER_BIN") { + let p = std::path::PathBuf::from(p); + if p.exists() { + return Some(p); + } + } + let exe = std::env::current_exe().ok()?; + let candidate = exe + .parent()? + .parent()? + .join(format!("oak-worker{}", std::env::consts::EXE_SUFFIX)); + candidate.exists().then_some(candidate) + } + + #[test] + fn slot_bytes_for_format_matrix() { + assert_eq!(slot_bytes_for(2, 2, SLOT_FORMAT_BGRA8), 16); + // U8: 1 byte/channel * 4 channels. + assert_eq!(slot_bytes_for(2, 2, 0), 16); + // U10 reports 4 bytes/channel (the packed RGBA10A2 word), so the + // slot math is 4 * channels bytes per pixel. + assert_eq!(slot_bytes_for(2, 2, 1), 64); + assert_eq!(slot_bytes_for(2, 2, 2), 32); // U16 + assert_eq!(slot_bytes_for(2, 2, 3), 32); // F16 + assert_eq!(slot_bytes_for(2, 2, 4), 64); // F32 + assert_eq!(slot_bytes_for(2, 2, 9), 64); // unknown -> F32 + // Negative dimensions clamp to zero pixels. + assert_eq!(slot_bytes_for(-3, 5, 0), 0); + assert_eq!(slot_bytes_for(3, -5, 0), 0); + } + + #[test] + fn bgra8_to_f32_rgba_converts_and_ignores_tail() { + let out = bgra8_to_f32_rgba(&[255, 128, 0, 255, 10]); + assert_eq!(out.len(), 4, "trailing byte without a full pixel is dropped"); + assert_eq!(out[0], 0.0); + assert!((out[1] - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(out[2], 1.0); + assert_eq!(out[3], 1.0); + assert!(bgra8_to_f32_rgba(&[]).is_empty()); + } + + #[test] + fn defaults_policies_edge_inputs() { + // slot_bytes 0 still yields a sane count (the max(1) guards the divide). + assert_eq!(default_slots_for_bytes(0, 8), 8); + // Shrinking below the floor clamps to 2. + assert_eq!(default_slots_for_bytes(8_300_000, 1), 2); + // A null divider falls back to a per-worker count >= 1. + assert!(default_worker_count(0, 0) >= 1); + assert_eq!(default_batch_size(0, 0), 1); + } + + #[test] + fn shm_view_refs_samples_and_debug() { + let key = shm_key("refs"); + let view = ShmRegionView::create(&key, 2, 64).expect("shm"); + let slot = 0u32; + let samples_in = [1.0f32, -2.5, 0.0]; + let bytes: Vec = samples_in.iter().flat_map(|v| v.to_le_bytes()).collect(); + unsafe { + let pool = view.pool(); + let dst = std::slice::from_raw_parts_mut(pool.slot_data(slot), bytes.len()); + dst.copy_from_slice(&bytes); + (*pool.meta(slot)).id = 7; + } + assert_eq!(view.key(), key); + assert_eq!(view.slot_count(), 2); + assert_eq!(view.slot_data_bytes(), 64); + assert_eq!(view.slot_bytes(slot).len(), 64); + assert_eq!(view.meta_copy(slot).id, 7); + + let meta = ShmFrameMeta { + id: 7, + time_num: 1, + time_den: 25, + width: 2, + height: 2, + format: crate::ipc::SLOT_FORMAT_AUDIO_F32, + channel_count: 2, + linesize: 8, + data_size: bytes.len() as i32, + colorspace: "linear".to_string(), + }; + let audio = ShmAudioRef { + worker: 0, + slot, + meta: meta.clone(), + shm: view.clone(), + sample_rate: 48000, + channel_layout: 0x3, + channel_count: 2, + }; + assert_eq!(audio.samples(), samples_in.to_vec()); + let decoded = audio.to_audio_samples(); + assert_eq!(decoded.samples, samples_in.to_vec()); + assert_eq!(decoded.sample_rate, 48000); + assert_eq!(decoded.channel_layout, 0x3); + assert_eq!(decoded.channel_count, 2); + assert!(format!("{audio:?}").contains("ShmAudioRef")); + let frame = audio.frame_ref(); + assert_eq!(frame.worker, 0); + assert_eq!(frame.slot, slot); + assert_eq!(frame.meta.id, 7); + assert!(format!("{frame:?}").contains("ShmFrameRef")); + + // A bogus (oversized / negative) data size yields no samples. + let mut clipped = audio.clone(); + clipped.meta.data_size = i32::MAX; + assert!(clipped.samples().is_empty()); + clipped.meta.data_size = -1; + assert!(clipped.samples().is_empty()); + } + + #[test] + fn shm_region_create_invalid_key_errors() { + // A NUL in the key makes every shm_open attempt fail: the create + // helper unlinks and retries once, then surfaces the error. + let err = ShmRegionView::create("bad\0key", 1, 64) + .err() + .expect("a NUL key can never create a segment"); + assert!(format!("{err}").contains("create shm segment")); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_drm_vram_default_query_smoke() { + // The real sysfs walk: no assertion on the result (GPU-dependent), + // just exercise the path that forwards to the fixture-testable walk. + let _ = linux_drm_vram_bytes(); + assert!(linux_drm_vram_bytes_from(std::path::Path::new("/nonexistent")).is_none()); + } + + #[test] + fn worker_count_for_size_with_hwaccel_disabled() { + let _lock = pool_test_lock(); + let _hwaccel = EnvRestore::set("OAK_HWACCEL", "0"); + assert!(!hwdecode_available()); + // Hardware decoding off: the GPU-vram policy never engages. + let base = default_worker_count(2, 128); + assert_eq!(worker_count_for_size(2, 128, (64, 64), 30), base); + // Re-enabling leaves the CPU/RAM policy as the upper bound. + std::env::remove_var("OAK_HWACCEL"); + let with_gpu = worker_count_for_size(2, 128, (64, 64), 30); + assert!( + with_gpu >= 1 && with_gpu <= base, + "the GPU-vram bound only caps the CPU/RAM policy ({with_gpu} vs {base})" + ); + } + + #[test] + fn cancel_frame_pending_claimed_and_unknown() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let key = FrameKey { + sequence: 9, + frame: 3, + version: 1, + }; + // Unknown key: the scheduler reports false and nothing fires. + dispatcher.cancel_frame(&key); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + let results2 = results.clone(); + { + let mut inner = lock(&dispatcher.inner); + inner.scheduler.submit(FrameRequest { + key, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 1, + slot_bytes: 64, + }); + inner.tickets.insert( + 1, + PendingTicket { + key, + params: Arc::new(video_params(3)), + audio: None, + done: Some(Box::new(move |result| { + results2 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(result); + })), + }, + ); + } + // The cancel locks the dispatcher internally: never call it while + // holding `dispatcher.inner` (that deadlocks the test). + dispatcher.cancel_frame(&key); + { + let inner = lock(&dispatcher.inner); + assert!(inner.tickets.is_empty()); + } + assert_eq!(results.lock().unwrap().len(), 1); + assert!(results.lock().unwrap()[0].is_err()); + + // Claimed (in-flight) cancel removes the claim and fires once. + let key2 = FrameKey { + sequence: 9, + frame: 4, + version: 1, + }; + let results2: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = results2.clone(); + { + let mut inner = lock(&dispatcher.inner); + inner.scheduler.submit(FrameRequest { + key: key2, + priority: crate::scheduler::FramePriority::Playback, + distance: 0, + payload: 2, + slot_bytes: 64, + }); + let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claimed"); + assert_eq!(claimed.frames.len(), 1); + inner.tickets.insert( + 2, + PendingTicket { + key: key2, + params: Arc::new(video_params(4)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + } + dispatcher.cancel_frame(&key2); + assert_eq!(results2.lock().unwrap().len(), 1); + assert!(results2.lock().unwrap()[0].is_err()); + assert_eq!(dispatcher.worker_count(), 1); + } + +#[test] + fn release_frame_stale_double_and_missing_worker() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let key = shm_key("release"); + let shm = ShmRegionView::create(&key, 2, 64).expect("shm"); + { + let mut inner = lock(&dispatcher.inner); + let mut handle = WorkerHandle::shell(0, shm.clone(), 2, 64); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + } + let meta = shm.meta_copy(0); + // Unknown worker index: ignored. + let unknown = crate::procpool::ShmFrameRef { + worker: 42, + slot: 0, + meta: meta.clone(), + shm: shm.clone(), + }; + dispatcher.release_frame(&unknown); + // Stale ref (same worker index, different segment): ignored. + let other_key = shm_key("release-other"); + let other = ShmRegionView::create(&other_key, 1, 64).expect("shm"); + let stale = crate::procpool::ShmFrameRef { + worker: 0, + slot: 0, + meta: meta.clone(), + shm: other, + }; + dispatcher.release_frame(&stale); + { + let inner = lock(&dispatcher.inner); + assert!(inner.workers[0].held.is_empty()); + } + // A held slot releases once; the second release is a no-op. + { + let mut inner = lock(&dispatcher.inner); + inner.workers[0].held.insert(0); + inner.workers[0].free_slots.clear(); + } + let frame = crate::procpool::ShmFrameRef { + worker: 0, + slot: 0, + meta, + shm, + }; + dispatcher.release_frame(&frame); + { + let inner = lock(&dispatcher.inner); + assert_eq!( + inner.workers[0].free_slots.iter().filter(|&&s| s == 0).count(), + 1 + ); + } + dispatcher.release_frame(&frame); + { + let inner = lock(&dispatcher.inner); + assert_eq!( + inner.workers[0].free_slots.iter().filter(|&&s| s == 0).count(), + 1, + "double release is ignored" + ); + } + } + + #[test] + fn audio_ref_release_delegates_and_cancels_sequence() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + // The audio release delegate reaches release_frame (unknown frame + // index -> early return, no panic). + let key = shm_key("audio-release"); + let shm = ShmRegionView::create(&key, 1, 64).expect("shm"); + let audio = ShmAudioRef { + worker: 99, + slot: 0, + meta: shm.meta_copy(0), + shm, + sample_rate: 48000, + channel_layout: 0x3, + channel_count: 2, + }; + dispatcher.release_audio_frame(&audio); + + // cancel_preview_sequence drops pending + claimed requests and + // fires their completions. + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + for (i, frame) in [10i64, 11].into_iter().enumerate() { + let key = FrameKey { + sequence: 77, + frame, + version: 0, + }; + let sink = results.clone(); + let mut inner = lock(&dispatcher.inner); + inner.scheduler.submit(FrameRequest { + key, + priority: crate::scheduler::FramePriority::Playback, + distance: 0, + payload: i as i64, + slot_bytes: 64, + }); + inner.tickets.insert( + i as i64, + PendingTicket { + key, + params: Arc::new(video_params(frame)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + } + { + let mut inner = lock(&dispatcher.inner); + // The Playback reserve claims one now; the other stays pending, + // so cancel_sequence covers both the pending and claimed arms. + let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claimed"); + assert_eq!(claimed.frames.len(), 1); + } + dispatcher.cancel_preview_sequence(77); + assert_eq!(results.lock().unwrap().len(), 2); + assert!(results.lock().unwrap().iter().all(|r| r.is_err())); + let _ = shm; + } + + #[test] + fn job_dispatch_trait_delegations() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + let raw: &ProcessDispatcher = &dispatcher; + JobDispatch::poll(raw); + assert_eq!( + JobDispatch::preview_window_capacity(raw), + Some(dispatcher.preview_window_capacity()) + ); + JobDispatch::cancel_preview_frame(raw, 1, 2, 3); + JobDispatch::set_graph_snapshot(raw, Some("/nonexistent/graph.xml".into())); + JobDispatch::set_graph_snapshot(raw, None); + let key = shm_key("trait-release"); + let shm = ShmRegionView::create(&key, 1, 64).expect("shm"); + let frame = ShmFrameRef { + worker: 99, + slot: 0, + meta: shm.meta_copy(0), + shm: shm.clone(), + }; + // Unknown worker: both release delegates take the early return. + JobDispatch::release_frame(raw, &frame); + let audio = ShmAudioRef { + worker: 99, + slot: 0, + meta: shm.meta_copy(0), + shm, + sample_rate: 48000, + channel_layout: 0x3, + channel_count: 2, + }; + JobDispatch::release_audio_frame(raw, &audio); + } + + #[test] + fn on_line_protocol_dispatch_and_failures() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + { + let mut inner = lock(&dispatcher.inner); + let key = shm_key("on-line"); + let shm = ShmRegionView::create(&key, 2, 64).expect("shm"); + let mut handle = WorkerHandle::shell(7, shm, 2, 64); + handle.state = WorkerState::Starting; + inner.workers.push(handle); + + // Malformed / non-object lines and unknown workers return early. + dispatcher.on_line(&mut inner, 0, "not json", &mut fired); + dispatcher.on_line(&mut inner, 0, "[1,2]", &mut fired); + dispatcher.on_line(&mut inner, 99, r#"{"type":"hello_caps"}"#, &mut fired); + + // A handshake on a handle without stdin fails to reply -> Dead. + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"handshake","protocol_version":1}"#, + &mut fired, + ); + assert!(inner.workers[0].startup_seen); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + + // hello_caps marks the worker alive; the pending load_graph send + // then fails (no stdin) and kills it again. + inner.config.graph_snapshot = Some("/nonexistent/graph.xml".into()); + inner.workers[0].state = WorkerState::Starting; + inner.workers[0].graph_sent = false; + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"hello_caps","protocol_version":1,"formats":[4],"max_slot_bytes":4096}"#, + &mut fired, + ); + assert!(inner.workers[0].caps.is_some()); + assert!(!inner.workers[0].reconfiguring); + assert!(inner.workers[0].graph_sent); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + + // batch_accepted increments the metric (no reply needed). + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"batch_accepted","batch_id":1,"tickets":[1,2]}"#, + &mut fired, + ); + assert_eq!(inner.workers[0].accepted_batches, 1); + + // A session-level error on a Starting worker recycles it. + inner.workers[0].state = WorkerState::Starting; + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"error","message":"shm attach failed"}"#, + &mut fired, + ); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + + // An error WITH a ticket routes to on_frame_failed. + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"error","ticket":5,"message":"boom"}"#, + &mut fired, + ); + // Unknown type and malformed known messages are ignored. + dispatcher.on_line(&mut inner, 0, r#"{"type":"something_else"}"#, &mut fired); + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"hello_caps","protocol_version":"x"}"#, + &mut fired, + ); + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"batch_accepted","batch_id":"x"}"#, + &mut fired, + ); + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"frame_ready","ticket":"x"}"#, + &mut fired, + ); + dispatcher.on_line(&mut inner, 0, r#"{"type":"frame_failed"}"#, &mut fired); + // plugin_progress forwards only when a callback is registered. + set_plugin_progress_cb(None); + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"plugin_progress","label":"a","message":"b","fraction":0.5}"#, + &mut fired, + ); + } + assert!(fired.is_empty()); + } + + #[test] + fn on_frame_ready_variants() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 8)).expect("dispatcher"); + let key = shm_key("frame-ready"); + let shm = ShmRegionView::create(&key, 8, 64).expect("shm"); + { + let mut inner = lock(&dispatcher.inner); + let mut handle = WorkerHandle::shell(0, shm.clone(), 8, 64); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + } + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + + // Unknown ticket: a late / duplicate frame_ready is ignored (the + // debug trace covers its log line). + { + let mut inner = lock(&dispatcher.inner); + let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1"); + dispatcher.on_frame_ready(&mut inner, 0, 404, 0, &mut fired); + drop(env); + } + assert!(fired.is_empty()); + + // Video ticket: the published slot completes as ShmFrame. + let video_slot = publish_slot(&shm, 42); + let video_results: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let mut inner = lock(&dispatcher.inner); + inner.workers[0].outstanding.insert(11, video_slot); + let sink = video_results.clone(); + inner.tickets.insert( + 11, + PendingTicket { + key: FrameKey { + sequence: 1, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + dispatcher.on_frame_ready(&mut inner, 0, 11, video_slot as i32, &mut fired); + } + assert_eq!(fired.len(), 1); + { + let (done, result) = fired.pop().unwrap(); + done(result); + } + let video_got = video_results.lock().unwrap(); + assert!( + matches!(&video_got[0], Ok(TicketPayload::ShmFrame(_))), + "expected ShmFrame, got {:?}", + video_got[0] + ); + if let Ok(TicketPayload::ShmFrame(frame)) = &video_got[0] { + assert_eq!(frame.meta.id, 42); + assert_eq!(frame.worker, 0); + assert_eq!(frame.slot, video_slot); + } + + // Audio ticket: the same slot hand-off yields ShmAudio. + let audio_slot = publish_slot(&shm, 43); + let audio_results: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let mut inner = lock(&dispatcher.inner); + inner.workers[0].outstanding.insert(12, audio_slot); + let sink = audio_results.clone(); + inner.tickets.insert( + 12, + PendingTicket { + key: FrameKey { + sequence: 2, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: Some(Arc::new(AudioTicketParams { + viewer: 1, + range: oak_core::TimeRange::new( + oak_core::Rational::new(0, 1), + oak_core::Rational::new(1, 48), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: Vec::new(), + })), + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + dispatcher.on_frame_ready(&mut inner, 0, 12, audio_slot as i32, &mut fired); + } + assert_eq!(fired.len(), 1); + { + let (done, result) = fired.pop().unwrap(); + done(result); + } + let audio_got = audio_results.lock().unwrap(); + assert!( + matches!(&audio_got[0], Ok(TicketPayload::ShmAudio(_))), + "expected ShmAudio, got {:?}", + audio_got[0] + ); + if let Ok(TicketPayload::ShmAudio(audio)) = &audio_got[0] { + assert_eq!(audio.meta.id, 43); + assert_eq!(audio.sample_rate, 48000); + assert_eq!(audio.channel_count, 2); + } + + // Cancelled in flight (completion taken): the slot recycles now. + let cancelled_slot = publish_slot(&shm, 44); + { + let mut inner = lock(&dispatcher.inner); + inner.workers[0].outstanding.insert(13, cancelled_slot); + inner.tickets.insert( + 13, + PendingTicket { + key: FrameKey { + sequence: 3, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: None, + done: None, + }, + ); + let before = inner.workers[0].free_slots.len(); + dispatcher.on_frame_ready(&mut inner, 0, 13, cancelled_slot as i32, &mut fired); + assert_eq!(inner.workers[0].free_slots.len(), before + 1); + assert!(!inner.workers[0].held.contains(&cancelled_slot)); + } + // An outstanding entry with no ticket row recycles too. + let orphan_slot = publish_slot(&shm, 45); + { + let mut inner = lock(&dispatcher.inner); + inner.workers[0].outstanding.insert(14, orphan_slot); + dispatcher.on_frame_ready(&mut inner, 0, 14, orphan_slot as i32, &mut fired); + } + assert!(fired.is_empty()); + + // Ready-ring out of sync: the reported slot wins, the mismatch is + // logged and the completion still lands. + let published = publish_slot(&shm, 46); + let reported = if published == 0 { 1 } else { 0 }; + unsafe { + (*shm.pool().meta(reported)).id = 46; + } + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let mut inner = lock(&dispatcher.inner); + inner.workers[0].outstanding.insert(15, reported); + let sink = results.clone(); + inner.tickets.insert( + 15, + PendingTicket { + key: FrameKey { + sequence: 4, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + dispatcher.on_frame_ready(&mut inner, 0, 15, reported as i32, &mut fired); + } + assert_eq!(fired.len(), 1); + { + let (done, result) = fired.pop().unwrap(); + done(result); + } + assert!(matches!( + &results.lock().unwrap()[0], + Ok(TicketPayload::ShmFrame(frame)) if frame.meta.id == 46 + )); + } + + #[test] + fn on_frame_failed_paths() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("frame-failed"), 2, 64).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 64); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + + // Unknown worker -> early return (with the debug trace on). + let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1"); + dispatcher.on_frame_failed(&mut inner, 99, 1, "x", &mut fired); + drop(env); + // Known worker, unknown ticket -> early return. + dispatcher.on_frame_failed(&mut inner, 0, 1, "x", &mut fired); + // recycle_slot with an unknown worker is a no-op. + dispatcher.recycle_slot(&mut inner, 99, 0); + + // A known outstanding ticket fails: slot recycled, completion Err. + inner.workers[0].outstanding.insert(3, 1); + inner.workers[0].held.insert(1); + let sink = results.clone(); + inner.tickets.insert( + 3, + PendingTicket { + key: FrameKey { + sequence: 2, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + dispatcher.on_frame_failed(&mut inner, 0, 3, "boom", &mut fired); + assert!(inner.workers[0].held.is_empty()); + assert!(inner.workers[0].free_slots.contains(&1)); + assert!(inner.tickets.is_empty()); + } + assert_eq!(fired.len(), 1); + { + let (done, result) = fired.pop().unwrap(); + done(result); + } + assert!(results.lock().unwrap()[0].is_err()); + } + + #[test] + fn rebuild_segment_failure_paths() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("rebuild"), 2, 128).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 128); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + + // A valid small grow with no stdin: the re-attach handshake + // send fails and the worker is marked Dead. + dispatcher.rebuild_segment(&mut inner, 0, 128).unwrap(); + assert!(inner.workers[0].reconfiguring); + assert_eq!(inner.workers[0].slot_bytes, 128); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + + // A hostile slot size makes the segment create fail: the grow + // error is logged and the handle keeps its old geometry. + inner.workers[0].state = WorkerState::Alive; + inner.workers[0].outstanding.clear(); + inner.workers[0].free_slots = (0..2).collect(); + inner.scheduler.submit(FrameRequest { + key: FrameKey { + sequence: 3, + frame: 0, + version: 0, + }, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 1, + slot_bytes: 1 << 50, + }); + dispatcher.dispatch_to(&mut inner, 0); + assert_eq!(inner.workers[0].slot_bytes, 128); + assert_eq!(inner.workers[0].state, WorkerState::Alive); + } + } + + #[test] + fn dispatch_to_grow_failure_and_starvation_log() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("starvation"), 2, 128).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 128); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + + // Grow failure: a pending request far larger than any segment + // fails the rebuild and dispatch stops for this pump. + let huge_key = FrameKey { + sequence: 100, + frame: 0, + version: 0, + }; + inner.scheduler.submit(FrameRequest { + key: huge_key, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 1, + slot_bytes: 1 << 50, + }); + dispatcher.dispatch_to(&mut inner, 0); + assert_eq!(inner.scheduler.pending_len(), 1); + inner.scheduler.cancel_sequence(100); + + // Starvation: outstanding work blocks the grow; the pending + // request is too large for the current slots -> claim reports + // None, and the debug flag logs why. + inner.workers[0].outstanding.insert(999, 0); + inner.workers[0].free_slots = (0..2).collect(); + inner.scheduler.submit(FrameRequest { + key: FrameKey { + sequence: 101, + frame: 0, + version: 0, + }, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 2, + slot_bytes: 4096, + }); + let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1"); + dispatcher.dispatch_to(&mut inner, 0); + drop(env); + assert_eq!(inner.scheduler.pending_len(), 1); + assert_eq!(inner.workers[0].outstanding.len(), 1); + assert_eq!(inner.workers[0].free_slots.len(), 2); + } + } + + #[test] + fn dispatch_to_skips_claimed_request_without_ticket() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("no-ticket"), 2, 128).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 128); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + inner.scheduler.submit(FrameRequest { + key: FrameKey { + sequence: 4, + frame: 0, + version: 0, + }, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 12345, + slot_bytes: 64, + }); + // The claimed request has no ticket entry: the slot stays + // assigned and the dispatch loop continues cleanly. + dispatcher.dispatch_to(&mut inner, 0); + assert_eq!(inner.scheduler.pending_len(), 0); + assert_eq!(inner.workers[0].outstanding.len(), 1); + assert_eq!(inner.workers[0].free_slots.len(), 1); + } + } + + #[cfg(unix)] + #[test] + fn dispatch_to_video_audio_and_control_line_sends() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let mut child = std::process::Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("/bin/cat"); + let stdin = child.stdin.take(); + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("cat-sends"), 2, 256).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 256); + handle.state = WorkerState::Alive; + handle.stdin = stdin; + handle.child = Some(child); + inner.workers.push(handle); + + // Control-plane writes succeed: handshake reply, then the + // hello_caps-triggered load_graph. + dispatcher.on_line(&mut inner, 0, r#"{"type":"handshake"}"#, &mut fired); + inner.config.graph_snapshot = Some("/nonexistent/graph.xml".into()); + inner.workers[0].graph_sent = false; + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"hello_caps","protocol_version":1,"formats":[4],"max_slot_bytes":4096}"#, + &mut fired, + ); + assert!(inner.workers[0].graph_sent); + assert_eq!(inner.workers[0].state, WorkerState::Alive); + + // A mixed video+audio batch is written as two messages (the + // debug log runs with the env var set). + inner.tickets.insert( + 1, + PendingTicket { + key: FrameKey { + sequence: 5, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: None, + done: None, + }, + ); + inner.tickets.insert( + 2, + PendingTicket { + key: FrameKey { + sequence: 5, + frame: 1, + version: 0, + }, + params: Arc::new(video_params(1)), + audio: Some(Arc::new(AudioTicketParams { + viewer: 1, + range: oak_core::TimeRange::new( + oak_core::Rational::new(0, 1), + oak_core::Rational::new(1, 480), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: Vec::new(), + })), + done: None, + }, + ); + inner.scheduler.submit(FrameRequest { + key: FrameKey { + sequence: 5, + frame: 0, + version: 0, + }, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 1, + slot_bytes: 64, + }); + inner.scheduler.submit(FrameRequest { + key: FrameKey { + sequence: 5, + frame: 1, + version: 0, + }, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 2, + slot_bytes: 64, + }); + dispatcher.dispatch_to(&mut inner, 0); + assert_eq!(inner.workers[0].outstanding.len(), 2); + assert_eq!(inner.workers[0].free_slots.len(), 0); + // The scheduler must know about the synthetic workers below. + inner.scheduler.set_worker_count(3); + + // Finish here: `post`/`shutdown` lock the dispatcher + // internally, so they run outside the guard below. + } + dispatcher.set_graph_snapshot(Some("/nonexistent/graph2.xml".into())); + dispatcher.set_graph_snapshot(None); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + assert!(dispatcher.post(test_job(8, 0, None, &results))); + drop(env); + { + let mut inner = lock(&dispatcher.inner); + if let Some(handle) = inner.workers.get_mut(0) { + if let Some(mut c) = handle.child.take() { + let _ = c.kill(); + let _ = c.wait(); + } + handle.stdin = None; + } + } + dispatcher.shutdown(); + } + + /// A missing stdin on a dispatchable worker fails the send and marks + /// the worker dead (recycled). + #[test] + fn dispatch_to_missing_stdin_recycles_the_worker() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let key = FrameKey { + sequence: 11, + frame: 0, + version: 0, + }; + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("no-stdin"), 2, 256).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 256); + handle.state = WorkerState::Alive; + // No stdin: the send must fail and recycle the worker. + inner.workers.push(handle); + inner.tickets.insert( + 9, + PendingTicket { + key, + params: Arc::new(video_params(0)), + audio: None, + done: None, + }, + ); + inner.scheduler.submit(FrameRequest { + key, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 9, + slot_bytes: 64, + }); + dispatcher.dispatch_to(&mut inner, 0); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + } + } + + #[test] + fn post_replace_inflight_oversized_and_shutdown() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + + // Replaced: a second post for the same key supersedes the first. + let mut first = test_job(1, 0, None, &results); + first.schedule.frame = Some(0); + assert!(dispatcher.post(first)); + let mut second = test_job(1, 0, None, &results); + second.schedule.frame = Some(0); + assert!(dispatcher.post(second)); + { + let got = results.lock().unwrap(); + assert_eq!(got.len(), 1, "the replaced ticket fires once"); + assert!(got[0].is_err()); + } + results.lock().unwrap().clear(); + + // InFlight: the key is already claimed by a worker. + let key = FrameKey { + sequence: 5, + frame: 0, + version: 0, + }; + { + let mut inner = lock(&dispatcher.inner); + inner.scheduler.submit(FrameRequest { + key, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 7, + slot_bytes: 64, + }); + let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claim"); + assert_eq!(claimed.frames.len(), 1); + } + let mut inflight = test_job(5, 0, None, &results); + inflight.schedule.frame = Some(0); + assert!(dispatcher.post(inflight)); + { + let got = results.lock().unwrap(); + assert_eq!(got.len(), 1, "the in-flight ticket is cancelled"); + assert!(got[0].is_err()); + } + results.lock().unwrap().clear(); + + // Oversized audio is refused (the arena falls back inline). + let big = Arc::new(AudioTicketParams { + viewer: 1, + range: oak_core::TimeRange::new( + oak_core::Rational::new(0, 1), + oak_core::Rational::new(175, 1), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: Vec::new(), + }); + assert!(!dispatcher.post(test_job(6, 0, Some(big), &results))); + // An invalid (zero-length) audio range is refused too. + let zero = Arc::new(AudioTicketParams { + viewer: 1, + range: oak_core::TimeRange::new( + oak_core::Rational::new(0, 1), + oak_core::Rational::new(0, 1), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: Vec::new(), + }); + assert!(!dispatcher.post(test_job(6, 0, Some(zero), &results))); + + // Debug post logging with a synthetic (Starting) worker present. + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("post-debug"), 2, 64).expect("shm"); + inner.workers.push(WorkerHandle::shell(0, shm, 2, 64)); + } + let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1"); + assert!(dispatcher.post(test_job(9, 0, None, &results))); + drop(env); + + // After shutdown posts are refused and open tickets are cancelled. + dispatcher.shutdown(); + assert!(!dispatcher.post(test_job(10, 0, None, &results))); + let got = results.lock().unwrap(); + assert!( + !got.is_empty() && got.iter().all(|r| r.is_err()), + "open tickets cancelled at shutdown" + ); + } + + #[cfg(unix)] + #[test] + fn start_failure_restart_budget_and_accessors() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + // Not started: set_target_workers is a no-op. + dispatcher.set_target_workers(4); + assert_eq!(dispatcher.worker_count(), 1); + assert_eq!(dispatcher.slots_per_worker(), 2); + assert_eq!(dispatcher.slot_bytes(), 16 * 16 * 4); + assert_eq!(dispatcher.slot_format(), SLOT_FORMAT_BGRA8); + assert!(!dispatcher.is_alive(0)); + assert_eq!(dispatcher.restarts_of(0), 0); + assert_eq!(dispatcher.accepted_batches_of(0), 0); + assert!(dispatcher.shm_of(0).is_none()); + // Nobody alive: the window reports the configured pool. + assert_eq!(dispatcher.preview_window_capacity(), 1); + + // `/bin/true` exits immediately: the restart budget exhausts and + // start() fails permanently. + assert!(dispatcher.start().is_err()); + assert!(!dispatcher.is_alive(0)); + assert!(dispatcher.restarts_of(0) > MAX_RESTARTS); + assert_eq!(dispatcher.accepted_batches_of(0), 0); + assert_eq!(dispatcher.accepted_batches_of(99), 0); + assert!(dispatcher.shm_of(0).is_some()); + assert!(dispatcher.shm_of(99).is_none()); + + // A second start is rejected; a same-target resize is a no-op. + assert!(dispatcher.start().is_err()); + dispatcher.set_target_workers(1); + + // poll() after shutdown is a no-op; shutdown is idempotent. + dispatcher.shutdown(); + dispatcher.poll(); + dispatcher.shutdown(); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + assert!(!dispatcher.post(test_job(1, 0, None, &results))); + assert!(results.lock().unwrap().is_empty()); + } + + #[cfg(unix)] + #[test] + fn resize_throttle_grow_failure_and_shrink_drain() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + assert!(dispatcher.start().is_err(), "/bin/true never handshakes"); + { + let mut inner = lock(&dispatcher.inner); + inner.bin = PathBuf::from("/nonexistent/oak-worker-for-resize-test"); + inner.last_resize_at = None; + } + // Grow: the spawn failure is logged and the grow loop breaks. + dispatcher.set_target_workers(2); + assert_eq!(dispatcher.worker_count(), 2); + // A request inside the throttle window only stores the target. + { + let mut inner = lock(&dispatcher.inner); + inner.last_resize_at = Some(Instant::now()); + } + dispatcher.set_target_workers(1); + { + let mut inner = lock(&dispatcher.inner); + assert_eq!(inner.next_target, Some(1)); + let shm = ShmRegionView::create(&shm_key("shrink"), 2, 64).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 64); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + // Open the throttle window so the next pump applies the target. + inner.last_resize_at = + Some(Instant::now() - MIN_RESIZE_INTERVAL - Duration::from_secs(1)); + } + dispatcher.poll(); + assert_eq!(dispatcher.worker_count(), 1); + { + let mut inner = lock(&dispatcher.inner); + assert_eq!( + inner.workers.len(), + 1, + "the retiring worker drained and was reaped" + ); + assert_eq!(inner.next_target, None); + // A Dead worker whose respawn fails stays Dead. + inner.workers[0].state = WorkerState::Dead; + inner.workers[0].retiring = false; + inner.workers[0].restarts = 0; + } + dispatcher.poll(); + { + let inner = lock(&dispatcher.inner); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + assert_eq!(inner.workers[0].restarts, 1); + } + // The retiring-crash path removes the worker outright. + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("retire-crash"), 2, 64).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 64); + handle.state = WorkerState::Dead; + handle.retiring = true; + inner.workers.push(handle); + let index = inner.workers.len() - 1; + let mut fired = Vec::new(); + dispatcher.restart_worker(&mut inner, index, &mut fired); + assert!(fired.is_empty()); + assert_eq!(inner.workers.len(), index); + } + } + + #[cfg(unix)] + #[test] + fn retiring_worker_hung_past_deadline_is_killed() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + let mut child = std::process::Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("/bin/cat"); + let stdin = child.stdin.take(); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("hung-retire"), 1, 64).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 1, 64); + handle.state = WorkerState::Starting; + handle.retiring = true; + handle.stdin = stdin; + handle.child = Some(child); + // Pretend the shutdown signal was sent 31 s ago. + handle.retire_sent_at = Some(Instant::now() - Duration::from_secs(31)); + inner.workers.push(handle); + } + // `poll` locks the dispatcher internally: never call it under the + // guard (that deadlocks). + dispatcher.poll(); + { + let inner = lock(&dispatcher.inner); + assert!(inner.workers.is_empty(), "hung retiring worker killed"); + } + } + + #[cfg(unix)] + #[test] + fn start_handshake_timeout_is_bounded() { + use std::os::unix::fs::PermissionsExt; + let _lock = pool_test_lock(); + // A silent stand-in worker: it ignores the `--backend` argument and + // stays alive without ever speaking the protocol. + let script = std::env::temp_dir().join(format!( + "oak-procpool-silent-{}.sh", + std::process::id() + )); + std::fs::write(&script, "#!/bin/sh\nsleep 5\n").expect("script"); + let mut perms = std::fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).expect("chmod"); + + let mut config = test_config(1, 1); + config.worker_bin = Some(script.clone()); + config.handshake_timeout_ms = 60; + let dispatcher = ProcessDispatcher::new(config).expect("dispatcher"); + let err = dispatcher.start().expect_err("no handshake arrives"); + assert!( + format!("{err}").contains("handshake"), + "timeout error surfaced: {err}" + ); + // Kill the silent worker (it would otherwise linger). + { + let mut inner = lock(&dispatcher.inner); + if let Some(handle) = inner.workers.get_mut(0) { + if let Some(mut c) = handle.child.take() { + let _ = c.kill(); + let _ = c.wait(); + } + handle.stdin = None; + } + } + let _ = std::fs::remove_file(&script); + } + + #[cfg(unix)] + #[test] + fn shutdown_drains_events_and_fires_open_tickets() { + use std::os::unix::fs::PermissionsExt; + let _lock = pool_test_lock(); + // A short-lived stand-in worker: alive for the first drain round, + // gone before the kill deadline. + let script = std::env::temp_dir().join(format!( + "oak-procpool-shutdown-{}.sh", + std::process::id() + )); + std::fs::write(&script, "#!/bin/sh\nsleep 0.1\n").expect("script"); + let mut perms = std::fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).expect("chmod"); + + let mut config = test_config(1, 1); + config.worker_bin = Some(script.clone()); + let dispatcher = ProcessDispatcher::new(config).expect("dispatcher"); + let mut child = std::process::Command::new(&script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("stand-in worker"); + let stdin = child.stdin.take(); + let shm = ShmRegionView::create(&shm_key("shutdown-drain"), 2, 64).expect("shm"); + let slot = publish_slot(&shm, 77); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let mut inner = lock(&dispatcher.inner); + let mut handle = WorkerHandle::shell(0, shm.clone(), 2, 64); + handle.state = WorkerState::Alive; + handle.stdin = stdin; + handle.child = Some(child); + handle.outstanding.insert(21, slot); + let sink = results.clone(); + inner.tickets.insert( + 21, + PendingTicket { + key: FrameKey { + sequence: 1, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(0)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + // A ticket with no frame in flight must fail with Error::State. + let sink = results.clone(); + inner.tickets.insert( + 22, + PendingTicket { + key: FrameKey { + sequence: 2, + frame: 0, + version: 0, + }, + params: Arc::new(video_params(1)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + inner.workers.push(handle); + // Inject the frame_ready the fake worker would have sent, so the + // shutdown drain delivers a completion. + let line = format!(r#"{{"type":"frame_ready","ticket":21,"slot":{slot}}}"#); + assert!(inner + .events_tx + .send(WorkerEvent::Line { + worker: 0, + generation: 0, + line, + }) + .is_ok()); + } + dispatcher.shutdown(); + { + let got = results.lock().unwrap(); + assert_eq!(got.len(), 2, "one rendered frame + one cancellation"); + assert!(got + .iter() + .any(|r| matches!(r, Ok(TicketPayload::ShmFrame(f)) if f.meta.id == 77))); + assert!(got.iter().any(|r| r.is_err())); + } + let _ = std::fs::remove_file(&script); + } + + #[test] + fn build_audio_ticket_spec_carries_montage() { + let params = AudioTicketParams { + viewer: 3, + range: oak_core::TimeRange::new( + oak_core::Rational::new(1, 2), + oak_core::Rational::new(3, 2), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: vec![crate::ticket::MontageClip { + filename: "clip.mp4".into(), + stream_index: 1, + in_time: oak_core::Rational::new(1, 4), + out_time: oak_core::Rational::new(3, 4), + media_in: oak_core::Rational::new(0, 1), + gain: 0.5, + effects: Vec::new(), + }], + }; + let spec = build_audio_ticket_spec(5, 2, ¶ms); + assert_eq!(spec.ticket, 5); + assert_eq!(spec.slot, 2); + assert_eq!(spec.time_num, 1); + assert_eq!(spec.time_den, 2); + assert_eq!(spec.duration_num, 1); + assert_eq!(spec.duration_den, 1); + assert_eq!(spec.sample_rate, 48000); + assert_eq!(spec.channels, 2); + assert_eq!(spec.montage.len(), 1); + assert_eq!(spec.montage[0].filename, "clip.mp4"); + assert_eq!(spec.montage[0].stream_index, 1); + assert_eq!(spec.montage[0].in_num, 1); + assert_eq!(spec.montage[0].out_den, 4); + assert_eq!(spec.montage[0].media_in_num, 0); + assert_eq!(spec.montage[0].gain, 0.5); + } + + #[test] + fn real_worker_video_audio_round_trip_and_release_paths() { + let _lock = pool_test_lock(); + let Some(bin) = find_real_worker() else { + eprintln!("oak-worker binary not found; run `cargo build -p oak-worker`; skipping"); + return; + }; + let config = DispatcherConfig { + worker_bin: Some(bin), + workers: 1, + slots_per_worker: 4, + width: 16, + height: 16, + slot_format: SLOT_FORMAT_BGRA8, + batch_size: 2, + graph_snapshot: None, + handshake_timeout_ms: 60_000, + }; + let dispatcher = ProcessDispatcher::new(config).expect("dispatcher"); + dispatcher.start().expect("worker starts"); + assert!(dispatcher.is_alive(0)); + assert!(dispatcher.shm_of(0).is_some()); + assert_eq!(dispatcher.slot_bytes(), 16 * 16 * 4); + assert!(dispatcher.preview_window_capacity() >= 1); + + // Plugin-cancel broadcast to a live worker. + request_plugin_cancel_all(); + // Graph snapshot push (the worker may reject the path; the send + // succeeds) and clear. + dispatcher.set_graph_snapshot(Some("/nonexistent/oak-graph.xml".into())); + dispatcher.poll(); + dispatcher.set_graph_snapshot(None); + + // A generated-frame video ticket renders into a BGRA8 slot. + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + assert!(dispatcher.post(test_job(1, 0, None, &results))); + pump_until(&dispatcher, &results, 1); + let result = results.lock().unwrap().pop().unwrap(); + let Ok(TicketPayload::ShmFrame(frame)) = result else { + panic!("ShmFrame expected"); + }; + assert_eq!(frame.meta.width, 16); + assert_eq!(frame.meta.height, 16); + assert_eq!(frame.meta.format, SLOT_FORMAT_BGRA8); + // A stale ref (different segment) is ignored. + let other = ShmRegionView::create(&shm_key("stale-release"), 1, 64).expect("shm"); + let stale = ShmFrameRef { + worker: 0, + slot: frame.slot, + meta: frame.meta.clone(), + shm: other, + }; + dispatcher.release_frame(&stale); + dispatcher.release_frame(&frame); + dispatcher.release_frame(&frame); // double release: ignored + + // An audio ticket: the range exceeds the slot, so the dispatcher + // grows the worker's segment before claiming. + let audio = Arc::new(AudioTicketParams { + viewer: 1, + range: oak_core::TimeRange::new( + oak_core::Rational::new(0, 1), + oak_core::Rational::new(1, 48), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: Vec::new(), + }); + let audio_results: Arc>> = Arc::new(Mutex::new(Vec::new())); + assert!(dispatcher.post(test_job(2, 0, Some(audio), &audio_results))); + pump_until(&dispatcher, &audio_results, 1); + let result = audio_results.lock().unwrap().pop().unwrap(); + let Ok(TicketPayload::ShmAudio(audio)) = result else { + panic!("ShmAudio expected"); + }; + assert_eq!(audio.sample_rate, 48000); + assert_eq!(audio.channel_count, 2); + JobDispatch::release_audio_frame(&*dispatcher, &audio); + dispatcher.shutdown(); + } + + #[test] + fn real_worker_crash_restarts_and_requeues() { + let _lock = pool_test_lock(); + let Some(bin) = find_real_worker() else { + eprintln!("oak-worker binary not found; run `cargo build -p oak-worker`; skipping"); + return; + }; + let marker = std::env::temp_dir().join(format!( + "oak-procpool-crash-ut-{}", + std::process::id() + )); + let _ = std::fs::remove_file(&marker); + let _crash = EnvRestore::set("OAK_WORKER_CRASH_ON_TICKET", "1"); + let _marker = EnvRestore::set( + "OAK_WORKER_CRASH_MARKER", + marker.to_string_lossy().as_ref(), + ); + let config = DispatcherConfig { + worker_bin: Some(bin), + workers: 1, + slots_per_worker: 4, + width: 16, + height: 16, + slot_format: SLOT_FORMAT_BGRA8, + batch_size: 2, + graph_snapshot: None, + handshake_timeout_ms: 60_000, + }; + let dispatcher = ProcessDispatcher::new(config).expect("dispatcher"); + dispatcher.start().expect("worker starts"); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + assert!(dispatcher.post(test_job(1, 0, None, &results))); + assert!(dispatcher.post(test_job(1, 1, None, &results))); + pump_until(&dispatcher, &results, 2); + let restarts = dispatcher.restarts_of(0); + assert!( + restarts >= 1, + "the crashed worker restarted (restarts={restarts})" + ); + let mut seen = 0usize; + for result in results.lock().unwrap().drain(..) { + let payload = result.expect("frame rendered despite the crash"); + if let TicketPayload::ShmFrame(frame) = payload { + seen += 1; + dispatcher.release_frame(&frame); + } + } + assert_eq!(seen, 2); + assert!(marker.exists(), "the crash hook fired"); + let _ = std::fs::remove_file(&marker); + dispatcher.shutdown(); + } + + #[test] + fn plugin_cancel_broadcast_marks_dead_and_snapshot_after_shutdown() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("plugin-cancel"), 1, 64).expect("shm"); + inner.workers.push(WorkerHandle::shell(0, shm, 1, 64)); // Starting, no stdin + } + // Point the process-wide slot at this dispatcher so the broadcast + // reaches it deterministically. + *dispatcher_slot().lock().unwrap_or_else(|e| e.into_inner()) = Arc::downgrade(&dispatcher); + request_plugin_cancel_all(); + { + let inner = lock(&dispatcher.inner); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + } + // After shutdown the graph-snapshot setter is a no-op. + dispatcher.shutdown(); + dispatcher.set_graph_snapshot(Some("/nonexistent/graph.xml".into())); + { + let inner = lock(&dispatcher.inner); + assert!(inner.config.graph_snapshot.is_none()); + } + } + + #[test] + fn stale_generation_events_are_dropped() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("stale-events"), 1, 64).expect("shm"); + let mut handle = WorkerHandle::shell(5, shm, 1, 64); + handle.state = WorkerState::PermanentlyDead; + let tx = inner.events_tx.clone(); + inner.workers.push(handle); + // Events from an older spawn generation are dropped. + assert!(tx + .send(WorkerEvent::Line { + worker: 0, + generation: 4, + line: r#"{"type":"batch_accepted","batch_id":1,"tickets":[]}"#.into(), + }) + .is_ok()); + assert!(tx + .send(WorkerEvent::Eof { + worker: 0, + generation: 4, + }) + .is_ok()); + // The current generation's EOF is applied (the state is kept + // PermanentlyDead rather than downgraded to Dead). + assert!(tx + .send(WorkerEvent::Eof { + worker: 0, + generation: 5, + }) + .is_ok()); + } + dispatcher.poll(); + { + let inner = lock(&dispatcher.inner); + assert_eq!(inner.workers[0].accepted_batches, 0, "stale line dropped"); + assert_eq!(inner.workers[0].state, WorkerState::PermanentlyDead); + } + } + + // ---- Branch-coverage fill-ins: control-plane send failures and + // ---- environment-dependent query paths ------------------------------- + + /// `set_graph_snapshot(Some(..))` on a worker whose stdin is gone marks + /// it Dead (the send failure recycles it); clearing still only updates + /// the config. + #[test] + fn set_graph_snapshot_marks_dead_on_send_failure() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("graph-send"), 1, 64).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 1, 64); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + } + dispatcher.set_graph_snapshot(Some("/nonexistent/oak-graph.xml".into())); + { + let inner = lock(&dispatcher.inner); + assert!(inner.workers[0].graph_sent); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + assert_eq!( + inner.config.graph_snapshot.as_deref(), + Some("/nonexistent/oak-graph.xml") + ); + } + dispatcher.set_graph_snapshot(None); + assert!(lock(&dispatcher.inner).config.graph_snapshot.is_none()); + } + + /// Restart budget exhausted: the reclaimed frames of the dead worker + /// fail permanently and the worker stays `PermanentlyDead`. + #[test] + fn restart_budget_exhausted_fails_reclaimed_frames() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher"); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + let key = FrameKey { + sequence: 42, + frame: 0, + version: 0, + }; + let mut fired = Vec::new(); + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("restart-budget"), 2, 64).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 2, 64); + handle.state = WorkerState::Dead; + // The next restart exceeds MAX_RESTARTS. + handle.restarts = MAX_RESTARTS + 1; + inner.workers.push(handle); + inner.scheduler.submit(FrameRequest { + key, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 1, + slot_bytes: 64, + }); + let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claimed"); + assert_eq!(claimed.frames.len(), 1, "the frame is in flight"); + let sink = results.clone(); + inner.tickets.insert( + 1, + PendingTicket { + key, + params: Arc::new(video_params(0)), + audio: None, + done: Some(Box::new(move |result| { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(result) + })), + }, + ); + dispatcher.restart_worker(&mut inner, 0, &mut fired); + assert_eq!(inner.workers[0].state, WorkerState::PermanentlyDead); + assert!(inner.tickets.is_empty(), "the ticket was reaped"); + } + assert_eq!(fired.len(), 1); + for (done, result) in fired { + done(result); + } + let got = results.lock().unwrap(); + assert_eq!(got.len(), 1); + assert!(got[0].is_err(), "the dropped frame fails permanently"); + } + + /// An audio-only batch on a worker without stdin: the video message is + /// skipped and the audio send failure recycles the worker. + #[test] + fn dispatch_to_audio_only_missing_stdin_recycles_the_worker() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + let key = FrameKey { + sequence: 12, + frame: 0, + version: 0, + }; + { + let mut inner = lock(&dispatcher.inner); + let shm = ShmRegionView::create(&shm_key("audio-no-stdin"), 1, 256).expect("shm"); + let mut handle = WorkerHandle::shell(0, shm, 1, 256); + handle.state = WorkerState::Alive; + inner.workers.push(handle); + inner.tickets.insert( + 10, + PendingTicket { + key, + params: Arc::new(video_params(0)), + audio: Some(Arc::new(AudioTicketParams { + viewer: 1, + range: oak_core::TimeRange::new( + oak_core::Rational::new(0, 1), + oak_core::Rational::new(1, 480), + ), + sample_rate: 48000, + channel_layout: 0x3, + montage: Vec::new(), + })), + done: None, + }, + ); + inner.scheduler.submit(FrameRequest { + key, + priority: crate::scheduler::FramePriority::Seek, + distance: 0, + payload: 10, + slot_bytes: 64, + }); + dispatcher.dispatch_to(&mut inner, 0); + assert_eq!(inner.workers[0].state, WorkerState::Dead); + assert_eq!( + inner.workers[0].outstanding.len(), + 1, + "the slot was assigned before the failed send" + ); + } + } + + #[test] + fn on_frame_ready_for_unknown_worker_is_ignored() { + let _lock = pool_test_lock(); + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + let mut fired = Vec::new(); + { + let mut inner = lock(&dispatcher.inner); + // No worker at index 99: the completion path returns early. + dispatcher.on_frame_ready(&mut inner, 99, 1, 0, &mut fired); + } + assert!(fired.is_empty()); + } + + /// The producer in `test_job` is a stub (the process backend renders + /// from the wire spec, never in-process); invoking it documents and + /// covers the never-taken inline path. + #[test] + fn test_job_produce_stub_errors() { + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + let job = test_job(1, 0, None, &results); + let out = (job.produce)(oak_core::Rational::new(0, 1), &job.params); + assert!( + out.is_err(), + "the process backend never runs the in-process producer" + ); + } + + #[test] + fn env_restore_restores_previous_value() { + let _lock = pool_test_lock(); + let key = "OAK_PROCPOOL_TEST_RESTORE"; + std::env::set_var(key, "old"); + { + let _restore = EnvRestore::set(key, "new"); + assert_eq!(std::env::var(key).as_deref(), Ok("new")); + } + assert_eq!( + std::env::var(key).as_deref(), + Ok("old"), + "a previously set value is restored, not removed" + ); + std::env::remove_var(key); + } + + #[test] + fn find_real_worker_honors_existing_env_override() { + let _lock = pool_test_lock(); + let _env = EnvRestore::set("OAK_WORKER_BIN", "/bin/sh"); + assert_eq!( + find_real_worker().as_deref(), + Some(std::path::Path::new("/bin/sh")), + "an existing OAK_WORKER_BIN wins over the sibling probe" + ); + } + + /// The vram probes are environment-dependent (NVIDIA CLI, DRM sysfs); + /// the query must never panic and the capacity either reports a sane + /// bound or falls back to the RAM policy. + #[test] + fn gpu_vram_query_smoke() { + let _ = nvidia_vram_bytes(); + let _ = gpu_vram_bytes(); + let cap = gpu_worker_capacity((1920, 1080), 30); + assert!(cap.is_none() || cap.unwrap() >= 1); + } + + /// With the NVIDIA CLI unavailable the probe falls through to the + /// Linux DRM sysfs walk (or the `None` fallback on hosts without DRM + /// attrs). PATH is narrowed only for the duration of this locked test. + #[cfg(target_os = "linux")] + #[test] + fn gpu_vram_bytes_falls_back_to_drm_without_nvidia_cli() { + let _lock = pool_test_lock(); + let _path = EnvRestore::set("PATH", "/nonexistent-oak-vram-probe"); + assert!( + nvidia_vram_bytes().is_none(), + "nvidia-smi cannot be spawned without PATH" + ); + // Exercises the sysfs arm (or the None fallback) of gpu_vram_bytes. + let _ = gpu_vram_bytes(); + } + + /// Every `nvidia-smi` response shape: non-zero exit, non-UTF-8 output, + /// a missing/malformed CSV pair, a negative free value and a zero + /// total are all rejected; a valid pair converts MiB to bytes. + #[cfg(unix)] + #[test] + fn nvidia_vram_bytes_rejects_every_bad_query() { + use std::os::unix::fs::PermissionsExt; + let _lock = pool_test_lock(); + let dir = std::env::temp_dir().join(format!("oak-vram-fake-smi-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("fixture dir"); + let script = dir.join("nvidia-smi"); + let write = |body: &str| { + std::fs::write(&script, body).expect("script"); + let mut perms = std::fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).expect("chmod"); + }; + let _path = EnvRestore::set("PATH", dir.to_str().expect("utf8 path")); + + // Non-zero exit -> None. + write("#!/bin/sh\nexit 1\n"); + assert!(nvidia_vram_bytes().is_none(), "failed query"); + // Non-UTF-8 stdout -> None. + write("#!/bin/sh\nprintf '\\377\\376'\n"); + assert!(nvidia_vram_bytes().is_none(), "non-UTF-8 query"); + // No comma in the first line -> None. + write("#!/bin/sh\nprintf '16155\\n'\n"); + assert!(nvidia_vram_bytes().is_none(), "missing separator"); + // Unparsable numbers -> None. + write("#!/bin/sh\nprintf 'abc, 24576\\n'\n"); + assert!(nvidia_vram_bytes().is_none(), "unparsable free"); + // Negative free (driver 'unknown') -> None. + write("#!/bin/sh\nprintf ' -1, 24576\\n'\n"); + assert!(nvidia_vram_bytes().is_none(), "negative free"); + // Zero total -> None. + write("#!/bin/sh\nprintf '100, 0\\n'\n"); + assert!(nvidia_vram_bytes().is_none(), "zero total"); + // A valid pair converts MiB -> bytes. + write("#!/bin/sh\nprintf '100, 200\\n'\n"); + assert_eq!(nvidia_vram_bytes(), Some((100 << 20, 200 << 20))); + + drop(_path); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The DRM walk skips a card whose `total` is unparsable and treats an + /// unparsable `used` as zero, then picks the next usable card. + #[cfg(target_os = "linux")] + #[test] + fn linux_drm_vram_walk_skips_unparsable_total() { + let dir = std::env::temp_dir().join(format!( + "oak_vram_fixture_bad_{}_{}", + std::process::id(), + std::thread::current().name().unwrap_or("t") + )); + let _ = std::fs::remove_dir_all(&dir); + // card0: an unparsable total -> skipped by the walk. + let card0 = dir.join("card0").join("device"); + std::fs::create_dir_all(&card0).unwrap(); + std::fs::write(card0.join("mem_info_vram_total"), "not-a-number").unwrap(); + // card1: a valid total, but an unparsable `used` counts as zero. + let card1 = dir.join("card1").join("device"); + std::fs::create_dir_all(&card1).unwrap(); + std::fs::write(card1.join("mem_info_vram_total"), (2u64 << 30).to_string()).unwrap(); + std::fs::write(card1.join("mem_info_vram_used"), "garbage").unwrap(); + + let (free, total) = linux_drm_vram_bytes_from(&dir).expect("card1 wins"); + assert_eq!(total, 2 << 30); + assert_eq!(free, 2 << 30, "an unparsable `used` counts as zero"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The spawn reader turns a non-UTF-8 stdout line into an EOF event + /// (`BufRead::read_line` errors on invalid UTF-8) instead of spinning. + #[cfg(unix)] + #[test] + fn spawn_worker_reader_reports_eof_on_invalid_utf8() { + use std::os::unix::fs::PermissionsExt; + let _lock = pool_test_lock(); + let script = std::env::temp_dir().join(format!( + "oak-procpool-badutf8-{}.sh", + std::process::id() + )); + std::fs::write(&script, "#!/bin/sh\nprintf '\\377\\n'\nsleep 5\n").expect("script"); + let mut perms = std::fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).expect("chmod"); + + let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher"); + let mut inner = lock(&dispatcher.inner); + inner.bin = script.clone(); + dispatcher.spawn_worker(&mut inner, 0).expect("spawn"); + // Bounded wait for the reader's EOF event. + let deadline = Instant::now() + Duration::from_secs(10); + let mut eof = false; + while !eof && Instant::now() < deadline { + while let Ok(ev) = inner.events_rx.try_recv() { + if matches!(ev, WorkerEvent::Eof { worker: 0, .. }) { + eof = true; + } + } + if !eof { + std::thread::sleep(Duration::from_millis(2)); + } + } + assert!(eof, "invalid UTF-8 must surface as an EOF event"); + if let Some(mut child) = inner.workers[0].child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + drop(inner); + let _ = std::fs::remove_file(&script); + } } diff --git a/crates/oak-render/tests/common/mod.rs b/crates/oak-render/tests/common/mod.rs index 35f1843bd..6f4264301 100644 --- a/crates/oak-render/tests/common/mod.rs +++ b/crates/oak-render/tests/common/mod.rs @@ -58,6 +58,45 @@ impl Drop for ManagerGuard { } } +/// A GPU context suitable for the M5 zero-copy hardware import (Vulkan on +/// Linux/Windows, Metal on macOS), or `None` when no such adapter exists +/// (CI's software Vulkan still qualifies — the *decoder* side decides +/// whether there is an importable hardware surface). +/// +/// Missing adapters follow the repo-wide `OAK_REQUIRE_GPU` policy used by +/// `backend::gpu_or_skip`/`shared_gpu_or_skip`: on a job that promises a +/// GPU (the CI runner has lavapipe) a missing adapter panics instead of +/// silently dropping the import signal; elsewhere the skip prints a +/// distinctive `SKIP:` marker so CI logs distinguish skipped from executed +/// tests. +pub fn gpu_context_for_import() -> Option> { + let created = oak_core::backend::GpuContext::create(oak_core::backend::BackendKind::Auto); + let Some(ctx) = created else { + skip_import_gpu("no GPU adapter"); + return None; + }; + if matches!( + ctx.kind(), + oak_core::backend::BackendKind::Vulkan | oak_core::backend::BackendKind::Metal + ) { + return Some(ctx); + } + skip_import_gpu("the adapter is not Vulkan/Metal"); + None +} + +/// Report (and under `OAK_REQUIRE_GPU`, fail) a missing import-capable +/// adapter. The single `SKIP:` line keeps the skip observable in CI logs. +fn skip_import_gpu(reason: &str) { + if oak_core::backend::require_gpu_adapter() { + panic!( + "no importable GPU context for the M5 hardware import ({reason}); \ + OAK_REQUIRE_GPU is set" + ); + } + eprintln!("SKIP: footage hardware import: {reason}"); +} + // --------------------------------------------------------------------------- // Host-symbol stand-ins (oakcore_* / fb_find_best_pix_fmt_of_list) // --------------------------------------------------------------------------- diff --git a/crates/oak-render/tests/footage_import_test.rs b/crates/oak-render/tests/footage_import_test.rs new file mode 100644 index 000000000..bda6367da --- /dev/null +++ b/crates/oak-render/tests/footage_import_test.rs @@ -0,0 +1,394 @@ +// 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 . + +//! M5 acceptance: the zero-copy hardware-decode import. +//! +//! With a shared GPU context installed (the app's render device), a +//! hardware-decoded frame is imported as planar GPU textures and resolved +//! to working-space RGBA on the GPU — `HW_TRANSFERS` (the CPU download +//! counter) must not move. Turning the import switch off must give the +//! same pixels through the CPU staging path (within decoder/swscale +//! rounding; the threshold is documented at the comparison). +//! +//! The assertions need an importable *hardware decoder* (VAAPI/NVDEC/ +//! D3D11VA/VideoToolbox), not just a GPU context: the lavapipe CI runner +//! has software Vulkan but no `/dev/dri`, so the decoder never produces +//! an importable surface and every test here logs a `SKIP:` line and +//! returns. The staging fallback is covered by the existing decode tests; +//! the real-hardware acceptance run is recorded in +//! `docs/zh/plans/render-pipeline-threads-m5-branch-coverage.txt` (M5 +//! platform rows) and has to be repeated on a VAAPI/D3D11VA/VideoToolbox +//! machine. + +use std::sync::Mutex; + +use oak_core::texture::Texture; +use oak_core::{PixelFormat, Rational}; + +mod common; + +/// Tests in this binary share the process-wide eval frame cache, the +/// import switch and the shared GPU context slot; serialize them. +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Forces the CPU staging decode (`OAK_GPU_IMPORT=0`) for the reference +/// frame, and restores the previous value on drop: the variable is +/// process-wide, so a mid-test panic (`expect("staging decode")`) must not +/// leak `"0"` into later tests, and an operator-preset value must survive +/// the test. The tests serialize on `SERIAL`, so the override is +/// race-free here (mirrors `SoftwareDecodeGuard` in +/// `render_threads_test.rs`). +struct StagingDecodeGuard { + prev: Option, +} + +impl StagingDecodeGuard { + fn set() -> Self { + let prev = std::env::var("OAK_GPU_IMPORT").ok(); + std::env::set_var("OAK_GPU_IMPORT", "0"); + Self { prev } + } +} + +impl Drop for StagingDecodeGuard { + fn drop(&mut self) { + match &self.prev { + Some(p) => std::env::set_var("OAK_GPU_IMPORT", p), + None => std::env::remove_var("OAK_GPU_IMPORT"), + } + } +} + +fn clip_path(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "oakrender_import_{tag}_{}.mp4", + std::process::id() + )) +} + +fn write_clip(tag: &str) -> std::path::PathBuf { + let path = clip_path(tag); + oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation"); + path +} + +fn pin_legacy_working_space() { + oak_core::color::set_pipeline_color_settings( + oak_core::colormath::WorkingColorSpace::SrgbLegacy, + oak_core::colormath::OutputColorSpec::default(), + ); +} + +/// Sample the decoded F32 frame at a pixel. +fn sample(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] { + assert_eq!( + frame.format, + PixelFormat::F32, + "sample() interprets the frame bytes as f32" + ); + let stride = frame.linesize_bytes(); + let off = y * stride + x * 16; + let mut out = [0f32; 4]; + for (i, channel) in out.iter_mut().enumerate() { + *channel = + f32::from_le_bytes(frame.data[off + i * 4..off + i * 4 + 4].try_into().unwrap()); + } + out +} + +#[test] +fn hardware_import_is_zero_copy_and_matches_staging() { + let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + pin_legacy_working_space(); + + // The import needs the render device the app installs; without a GPU + // there is nothing to test (the staging path is the fallback). + let Some(ctx) = common::gpu_context_for_import() else { + // The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU). + return; + }; + oak_core::backend::GpuContext::install_shared(Some(ctx.clone())); + + oak_codec::gpuinterop::reset_import_counters(); + let transfers_before = oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed); + + let path = write_clip("zero"); + let imported = oak_render::eval::render_footage_frame( + &path.to_string_lossy(), + 0, + Rational::new(0, 1), + (0, 0), // native size: the import cannot resize + PixelFormat::F32, + ) + .expect("decode frame 0"); + + if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 { + eprintln!( + "SKIP: hardware import unavailable; imports={} fallbacks={} transfers={}", + oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed), + oak_codec::gpuinterop::HW_IMPORT_FALLBACKS.load(std::sync::atomic::Ordering::Relaxed), + oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed), + ); + let _ = std::fs::remove_file(&path); + return; + } + + eprintln!( + "import took the frame: imports={} transfers={} (before {transfers_before})", + oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed), + oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed), + ); + + // The import took the frame: the result is GPU-resident and no CPU + // download happened. + assert!( + matches!(imported, Texture::Gpu { .. }), + "imported decode must resolve to a GPU texture, got {imported:?}" + ); + assert_eq!( + oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed), + transfers_before, + "the zero-copy path must not call av_hwframe_transfer_data" + ); + let imported_frame = imported.to_frame().expect("download resolved frame"); + assert_eq!((imported_frame.width, imported_frame.height), (64, 64)); + + // Staging reference: the same media copied to a second path (a + // distinct eval cache key) decoded with the import switch off. + let staging_path = clip_path("staging"); + std::fs::copy(&path, &staging_path).expect("copy clip"); + let staging_guard = StagingDecodeGuard::set(); + let staging = oak_render::eval::render_footage_frame( + &staging_path.to_string_lossy(), + 0, + Rational::new(0, 1), + (0, 0), + PixelFormat::F32, + ) + .expect("staging decode"); + drop(staging_guard); + let Texture::Cpu(staging_frame) = &staging else { + panic!("staging decode must stay on the CPU: {staging:?}"); + }; + assert_eq!((staging_frame.width, staging_frame.height), (64, 64)); + + // Same content (the GPU pass uses the frame's own matrix/range and + // bilinear chroma sampling; the CPU path uses swscale's chroma + // filtering, so the two differ slightly). 0.08 is the real + // hardware-vs-software decode precedent + // (`oak-codec/src/realmedia_tests.rs::hardware_decode_matches_software_decode`); + // the M5 reference hardware (RTX 5070 Ti + nvidia-vaapi-driver) + // measures 0.009 here, so the threshold has an order of magnitude of + // headroom. + let mut max_diff = 0.0f32; + for y in 0..64 { + for x in 0..64 { + let a = sample(&imported_frame, x, y); + let b = sample(staging_frame, x, y); + for c in 0..3 { + max_diff = max_diff.max((a[c] - b[c]).abs()); + } + } + } + eprintln!("sRGB import vs staging: max diff {max_diff}"); + assert!( + max_diff < 0.08, + "import and staging decodes diverge (max channel diff {max_diff})" + ); + + // The known test pattern survives the GPU path: left half red, right + // half blue on frame 0. + let [r, g, b, a] = sample(&imported_frame, 8, 32); + assert!(r > 0.5 && g < 0.4 && b < 0.4, "left half red: {r},{g},{b}"); + assert!(a > 0.9, "opaque: {a}"); + let [r, g, b, _] = sample(&imported_frame, 56, 32); + assert!(b > 0.5 && r < 0.4 && g < 0.4, "right half blue: {r},{g},{b}"); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&staging_path); +} + +#[test] +fn hardware_import_applies_the_source_to_working_lut() { + let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + // ACEScg working space: the import path must run the source→working + // transform on the GPU (the baked 3D LUT), matching the CPU path's + // exact per-pixel `decode_to_acescg`. + oak_core::color::set_pipeline_color_settings( + oak_core::colormath::WorkingColorSpace::AcesCg, + oak_core::colormath::OutputColorSpec::default(), + ); + + let Some(ctx) = common::gpu_context_for_import() else { + // The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU). + return; + }; + oak_core::backend::GpuContext::install_shared(Some(ctx.clone())); + + oak_codec::gpuinterop::reset_import_counters(); + let path = write_clip("aces"); + let imported = oak_render::eval::render_footage_frame( + &path.to_string_lossy(), + 0, + Rational::new(0, 1), + (0, 0), + PixelFormat::F32, + ) + .expect("decode frame 0"); + if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 { + eprintln!("SKIP: hardware import unavailable; skipping assertions"); + let _ = std::fs::remove_file(&path); + return; + } + let imported_frame = imported.to_frame().expect("download resolved frame"); + + let staging_path = clip_path("aces_staging"); + std::fs::copy(&path, &staging_path).expect("copy clip"); + let staging_guard = StagingDecodeGuard::set(); + let staging = oak_render::eval::render_footage_frame( + &staging_path.to_string_lossy(), + 0, + Rational::new(0, 1), + (0, 0), + PixelFormat::F32, + ) + .expect("staging decode"); + drop(staging_guard); + let Texture::Cpu(staging_frame) = &staging else { + panic!("staging decode must stay on the CPU: {staging:?}"); + }; + + // The GPU applies the LUT (interpolated); the CPU runs the exact + // per-pixel transform, so a small interpolation difference is + // expected — far below a visible grade mismatch. + let mut max_diff = 0.0f32; + for y in 0..64 { + for x in 0..64 { + let a = sample(&imported_frame, x, y); + let b = sample(staging_frame, x, y); + for c in 0..3 { + max_diff = max_diff.max((a[c] - b[c]).abs()); + } + } + } + eprintln!("ACEScg import vs staging: max diff {max_diff}"); + assert!( + max_diff < 0.05, + "working-space LUT diverges from the CPU transform: {max_diff}" + ); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&staging_path); +} + +#[test] +fn montage_native_size_with_host_gpu_composites_the_clip() { + let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + pin_legacy_working_space(); + + // This is the M5 audit regression: a sequence montage at the clip's + // native size with a host GPU installed used to import the clip and + // then silently skip it in the CPU compositor, producing an + // all-transparent black sequence. The montage path must stage on + // purpose and composite the clip. + let Some(ctx) = common::gpu_context_for_import() else { + // The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU). + return; + }; + oak_core::backend::GpuContext::install_shared(Some(ctx)); + + oak_codec::gpuinterop::reset_import_counters(); + let path = write_clip("montage_native"); + + // First import the frame at native size through the normal + // single-footage path: the planar texture is now cached under the + // (64, 64) key that the montage request will use. + let single = oak_render::eval::render_footage_frame( + &path.to_string_lossy(), + 0, + Rational::new(0, 1), + (64, 64), + PixelFormat::F32, + ) + .expect("single-footage decode"); + if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 { + eprintln!("SKIP: hardware import unavailable on this machine; skipping montage assertions"); + let _ = std::fs::remove_file(&path); + return; + } + assert!( + matches!(single, Texture::Gpu { .. }), + "the pre-step must produce the imported GPU texture" + ); + let imports_after_single = + oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed); + let transfers_after_single = + oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed); + + let params = oak_render::ticket::VideoTicketParams { + viewer: 1, + project: String::new(), + time: Rational::new(0, 1), + force_size: Some((64, 64)), // native: the import-triggering shape + force_format: None, + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: None, + montage: vec![oak_render::ticket::MontageClip { + filename: path.to_string_lossy().into_owned(), + stream_index: 0, + in_time: Rational::new(0, 1), + out_time: Rational::new(10, 1), + media_in: Rational::new(0, 1), + gain: 1.0, + effects: Vec::new(), + }], + adjustments: Vec::new(), + }; + + let texture = oak_render::eval::render_produced_frame(Rational::new(0, 1), ¶ms) + .expect("montage render"); + let frame = texture.to_frame().expect("montage frame"); + assert_eq!((frame.width, frame.height), (64, 64)); + assert!( + !frame.data.iter().all(|&b| b == 0), + "montage must not be transparent black" + ); + // Known pattern: left half red, right half blue. + let [r, g, b, a] = sample(&frame, 8, 32); + assert!(r > 0.5 && g < 0.4 && b < 0.4, "left half red: {r},{g},{b}"); + assert!(a > 0.9, "opaque: {a}"); + let [r, g, b, _] = sample(&frame, 56, 32); + assert!(b > 0.5 && r < 0.4 && g < 0.4, "right half blue: {r},{g},{b}"); + + assert_eq!( + oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed), + imports_after_single, + "the CPU montage compositor must stage on purpose (no new imports)" + ); + // The staged request must not have been served by the cached planar + // texture: it re-decoded through the CPU scaler (a hardware frame + // transfer happened for the staging path). + assert!( + oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed) + > transfers_after_single, + "the staged montage decode must produce CPU pixels" + ); + + let _ = std::fs::remove_file(&path); +} diff --git a/crates/oak-render/tests/render_threads_test.rs b/crates/oak-render/tests/render_threads_test.rs index a86790cba..96cef1e37 100644 --- a/crates/oak-render/tests/render_threads_test.rs +++ b/crates/oak-render/tests/render_threads_test.rs @@ -98,6 +98,32 @@ fn pin_legacy_working_space() { ); } +/// Force software decoding for the inline-vs-pipeline byte-exact +/// comparisons: hardware decoders (NVDEC/VAAPI) may differ from the +/// software decoder by a few LSBs, which is a decode-path property, not a +/// pipeline bug. Every test in this binary takes `lock()`, so the +/// process-wide env override is race-free here. +struct SoftwareDecodeGuard { + prev: Option, +} + +impl SoftwareDecodeGuard { + fn set() -> Self { + let prev = std::env::var("OAK_HWACCEL").ok(); + std::env::set_var("OAK_HWACCEL", "0"); + Self { prev } + } +} + +impl Drop for SoftwareDecodeGuard { + fn drop(&mut self) { + match &self.prev { + Some(p) => std::env::set_var("OAK_HWACCEL", p), + None => std::env::remove_var("OAK_HWACCEL"), + } + } +} + fn base_params(time: Rational) -> VideoTicketParams { VideoTicketParams { viewer: 0, @@ -638,6 +664,7 @@ fn oak_pipeline_env_selects_the_thread_backend() { #[test] fn pipeline_matches_inline_pixels_across_consecutive_frames() { let _lock = lock(); + let _software = SoftwareDecodeGuard::set(); pin_legacy_working_space(); let inline_path = test_clip("consecutive_inline"); let pipeline_path = test_clip_copy(&inline_path, "consecutive_pipeline"); @@ -674,6 +701,7 @@ fn pipeline_matches_inline_pixels_across_consecutive_frames() { #[test] fn pipeline_seek_out_of_order_matches_inline() { let _lock = lock(); + let _software = SoftwareDecodeGuard::set(); pin_legacy_working_space(); let inline_path = test_clip("seek_inline"); let pipeline_path = test_clip_copy(&inline_path, "seek_pipeline"); @@ -705,6 +733,7 @@ fn pipeline_seek_out_of_order_matches_inline() { #[test] fn pipeline_viewer_ticket_matches_inline_pixels() { let _lock = lock(); + let _software = SoftwareDecodeGuard::set(); let path = test_clip("viewer"); let filename = path.to_string_lossy().to_string(); let clip = (filename.as_str(), Rational::new(0, 1), Rational::new(1, 1)); @@ -1280,6 +1309,7 @@ fn pipeline_queue_backpressure_closes_the_prefetch_gate() { time: Rational::new(0, 1), size: (64, 64), format: PixelFormat::F32, + allow_import: true, }); assert!(!refused, "a saturated pipeline refuses prefetch"); let decode = service.stats(); diff --git a/crates/oak-worker/src/ofx_host.rs b/crates/oak-worker/src/ofx_host.rs index aea4b169f..2e1f7b9ba 100644 --- a/crates/oak-worker/src/ofx_host.rs +++ b/crates/oak-worker/src/ofx_host.rs @@ -480,9 +480,145 @@ pub fn ofx_host_main(args: &[String]) -> i32 { #[cfg(test)] mod tests { use super::*; + use oak_core::backend::{BackendKind, GpuContextLike}; + use oak_core::error::{Error as CoreError, Result as CoreResult}; + use serde_json::{json, Value}; + use std::sync::atomic::AtomicU32; + + /// The process-global plugin/progress factories are shared with the + /// `worker.rs` tests, so both modules serialize on this one lock. + fn global_factory_lock() -> MutexGuard<'static, ()> { + crate::worker::GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + /// A unique shm key + region holding an initialized frame slot pool. + fn test_region(name: &str, slots: u32, slot_bytes: usize) -> (String, SharedMemoryRegion) { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let key = SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) + + &format!("-ofx-{name}"); + let bytes = FrameSlotPool::bytes_needed(slots, slot_bytes); + let mut region = SharedMemoryRegion::new(); + assert!( + region.open(&key, bytes, ShmMode::Create), + "{}", + region.error() + ); + // SAFETY: live mapping sized by bytes_needed; the pool view is + // discarded — peers re-attach through the region. + let _ = unsafe { FrameSlotPool::create(region.data(), slots, slot_bytes) }; + (key, region) + } + + /// A handshake for `attach_pools`. + fn handshake_json( + out_key: &str, + out_slots: i32, + out_bytes: i64, + in_key: &str, + in_slots: i32, + in_bytes: i64, + ) -> Value { + json!({ + "type": TYPE_HANDSHAKE, + "shm_key": out_key, + "output_slots": out_slots, + "slot_data_bytes": out_bytes, + "input_shm_key": in_key, + "input_slots": in_slots, + "input_slot_data_bytes": in_bytes, + }) + } + + /// Attach a real output pool (and input pool when `in_slots > 0`), + /// returning the pools plus the owner regions (kept alive by the caller). + fn host_pools( + out_slots: u32, + out_bytes: usize, + in_slots: u32, + in_bytes: usize, + ) -> (HostPools, SharedMemoryRegion, SharedMemoryRegion) { + let (out_key, out_region) = test_region("out", out_slots, out_bytes); + let (in_key, in_region) = test_region("in", in_slots, in_bytes); + let hs = handshake_json( + &out_key, + out_slots as i32, + out_bytes as i64, + &in_key, + in_slots as i32, + in_bytes as i64, + ); + let pools = attach_pools(&hs).expect("handshake attaches the pools"); + (pools, out_region, in_region) + } + + /// Fill and publish `slot` from the parent (producer) side. Pops past + /// any other free slots (returning them to the ring) so callers do not + /// need to know the free-ring order left by earlier publishes. + unsafe fn publish_input( + region: &SharedMemoryRegion, + slot: u32, + width: i32, + height: i32, + data_size: i32, + ) { + // SAFETY: live region created by `test_region`. + let pool = unsafe { FrameSlotPool::attach(region.data()) }; + let mut skipped: Vec = Vec::new(); + loop { + let mut got = 0u32; + assert!(unsafe { pool.acquire(&mut got) }, "free ring is seeded"); + if got == slot { + break; + } + skipped.push(got); + } + // SAFETY: `slot` was acquired above; meta and data are live. + unsafe { + let meta = &mut *pool.meta(slot); + *meta = Default::default(); + meta.width = width; + meta.height = height; + meta.format = PixelFormat::F32 as i32; + meta.data_size = data_size; + std::ptr::write_bytes(pool.slot_data(slot), 0x5A, pool.slot_data_bytes()); + for other in skipped { + assert!(pool.release(other), "skipped slots go back"); + } + } + assert!(unsafe { pool.publish(slot) }, "ready ring has room"); + } + + /// A GPU context whose readback always fails, for the output-readback + /// error path (a real GPU texture cannot be produced headless). + struct FailingDownloadGpu; + + impl GpuContextLike for FailingDownloadGpu { + fn kind(&self) -> BackendKind { + BackendKind::Gl + } + fn destroy_texture(&self, _token: u64) {} + fn upload(&self, _token: u64, _frame: &Frame) -> CoreResult<()> { + Err(CoreError::Failed("fake upload".to_string())) + } + fn download(&self, _token: u64) -> CoreResult { + Err(CoreError::Failed("fake download".to_string())) + } + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&oak_core::color::ColorProcessor>, + ) -> CoreResult<()> { + Err(CoreError::Failed("fake blit".to_string())) + } + } #[test] fn cancel_flag_is_reset_by_progress_start() { + let _guard = global_factory_lock(); // 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 @@ -496,9 +632,511 @@ mod tests { // 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"); + // progressEnd forwards completion (fraction 1.0); the emit is a + // no-op under test, so this just exercises the reporter path. + reporter.end(); OFX_CANCEL.store(false, Ordering::Relaxed); } + #[test] + fn progress_factory_installs_a_working_reporter() { + let _guard = global_factory_lock(); + OFX_CANCEL.store(false, Ordering::Relaxed); + install_progress_factory(); + assert!( + oak_plugin::progress::has_reporter_factory(), + "the host factory must be installed for the plugin progress suite" + ); + // Drive the installed factory through the progress suite + // (progressStart -> host_progress_reporter, update, end). + oak_plugin::suites::progress::set_current(Some( + oak_plugin::progress::ProgressReporter::silent(), + )); + let v2 = oak_plugin::suites::progress::suite_v2(); + let label = std::ffi::CString::new("render").unwrap(); + let message = std::ffi::CString::new("frame 1").unwrap(); + // SAFETY: the suite takes the null handle by contract; the strings + // outlive the calls. + unsafe { + assert_eq!( + (v2.start)(std::ptr::null_mut(), label.as_ptr(), message.as_ptr()), + oak_plugin::suites::status::OK + ); + assert_eq!( + (v2.update)(std::ptr::null_mut(), 0.5), + oak_plugin::suites::status::OK + ); + assert_eq!( + (v2.end)(std::ptr::null_mut()), + oak_plugin::suites::status::OK + ); + } + oak_plugin::suites::progress::set_current(None); + } + + #[test] + fn lock_recovers_from_a_poisoned_mutex() { + // `emit` (the other user) is a no-op under cfg(test); exercise the + // poison-recovery helper directly so a panicking reporter can never + // wedge the host's stdout lock. + let _ = std::thread::spawn(|| { + let _guard = OUT_LOCK.lock().unwrap(); + panic!("poison OUT_LOCK on purpose"); + }) + .join(); + let guard = lock(&OUT_LOCK); + drop(guard); + } + + #[test] + fn crash_hooks_maybe_crash_skips_when_marker_exists() { + let marker = std::env::temp_dir().join(format!( + "oak-ofx-host-marker-{}.txt", + std::process::id() + )); + std::fs::write(&marker, b"already crashed").unwrap(); + let hooks = CrashHooks { + always: false, + once_marker: Some(marker.clone()), + }; + // The marker exists: the hook must return without aborting. + hooks.maybe_crash(); + let _ = std::fs::remove_file(&marker); + + // A dangling --ofx-crash-once without a value leaves no marker. + let hooks = CrashHooks::from_args(&[ + "oak-worker".to_string(), + "--ofx-host".to_string(), + "--ofx-crash-once".to_string(), + ]); + assert!(!hooks.always); + assert!(hooks.once_marker.is_none()); + } + + #[test] + fn attach_pools_rejects_bad_shapes_and_missing_geometry() { + // Wrong field types: HandshakeMsg deserialization fails. + let err = attach_pools(&json!({ + "type": TYPE_HANDSHAKE, + "protocol_version": "one", + })) + .err().expect("wrong types are invalid"); + assert!(err.starts_with("invalid handshake: "), "{err}"); + + // Empty geometry (all serde defaults). + let err = attach_pools(&json!({ "type": TYPE_HANDSHAKE })).err().expect("empty handshake"); + assert_eq!(err, "handshake missing output shared-memory geometry"); + + // Zero and negative geometry take the same branch. + for (slots, bytes) in [(0, 16), (1, 0), (-2, 16), (1, -4)] { + let err = attach_pools(&handshake_json("k", slots, bytes, "", 0, 0)) + .err().expect("degenerate output geometry"); + assert_eq!(err, "handshake missing output shared-memory geometry"); + } + } + + #[test] + fn attach_pools_attaches_real_segments_and_reports_failures() { + // Success: both pools attach with the announced geometry. + let (pools, _out, _in) = host_pools(2, 256, 3, 128); + assert!(pools.output.is_valid()); + assert_eq!(pools.output.slot_count(), 2); + assert_eq!(pools.output.slot_data_bytes(), 256); + assert!(pools.input.is_valid()); + assert_eq!(pools.input.slot_count(), 3); + assert_eq!(pools.input.slot_data_bytes(), 128); + + // Output segment missing. + let missing = format!("olive-rw-{}-ofx-missing-out", std::process::id()); + let err = attach_pools(&handshake_json(&missing, 1, 16, "", 0, 0)).err().expect("no segment"); + assert!(err.starts_with("failed to attach output shared memory: "), "{err}"); + + // Output segment present but not a pool (zeroed memory -> bad magic). + let raw_key = format!("olive-rw-{}-ofx-raw-out", std::process::id()); + let bytes = FrameSlotPool::bytes_needed(1, 16); + let mut raw = SharedMemoryRegion::new(); + assert!(raw.open(&raw_key, bytes, ShmMode::Create)); + let err = attach_pools(&handshake_json(&raw_key, 1, 16, "", 0, 0)) + .err().expect("zeroed output segment"); + assert_eq!(err, "output shared memory does not contain a frame slot pool"); + + // Output is fine but the announced input geometry is incomplete. + let (out_key, _out_region) = test_region("out-for-in", 1, 16); + let err = attach_pools(&handshake_json(&out_key, 1, 16, "", 2, 0)).err().expect("input bytes"); + assert_eq!(err, "handshake missing input shared-memory geometry"); + let err = attach_pools(&handshake_json(&out_key, 1, 16, "", 2, 32)) + .err().expect("empty input key"); + assert_eq!(err, "handshake missing input shared-memory geometry"); + let err = attach_pools(&handshake_json(&out_key, 1, 16, " ", 2, 32)) + .err().expect("blank input key attaches nothing"); + assert!(err.starts_with("failed to attach input shared memory: "), "{err}"); + + // Input segment missing. + let missing_in = format!("olive-rw-{}-ofx-missing-in", std::process::id()); + let err = attach_pools(&handshake_json(&out_key, 1, 16, &missing_in, 2, 32)) + .err().expect("no input segment"); + assert!(err.starts_with("failed to attach input shared memory: "), "{err}"); + + // Input segment present but not a pool. + let raw_key = format!("olive-rw-{}-ofx-raw-in", std::process::id()); + let mut raw_in = SharedMemoryRegion::new(); + assert!(raw_in.open(&raw_key, FrameSlotPool::bytes_needed(2, 32), ShmMode::Create)); + let err = attach_pools(&handshake_json(&out_key, 1, 16, &raw_key, 2, 32)) + .err().expect("zeroed input segment"); + assert_eq!(err, "input shared memory does not contain a frame slot pool"); + } + + #[test] + fn read_input_frame_reports_missing_mismatch_and_invalid_meta() { + let (out_key, _out_region) = test_region("read-out", 4, 64); + let (in_key, in_region) = test_region("read-in", 2, 64); + let hs = handshake_json(&out_key, 4, 64, &in_key, 2, 64); + let pools = attach_pools(&hs).expect("attach"); + + // Nothing published yet. + let err = read_input_frame(&pools.input, 0).expect_err("empty ready ring"); + assert_eq!(err, "input slot missing"); + + // Publish slot 1 while the job asks for slot 0: a protocol + // violation that must release the consumed slot and fail. + { + // SAFETY: live region; parent (producer) side view. + let parent = unsafe { FrameSlotPool::attach(in_region.data()) }; + let mut first = 0u32; + let mut second = 0u32; + assert!(unsafe { parent.acquire(&mut first) }); + assert!(unsafe { parent.acquire(&mut second) }); + assert_eq!((first, second), (0, 1)); + assert!(unsafe { parent.release(first) }); + unsafe { + let meta = &mut *parent.meta(second); + *meta = Default::default(); + meta.width = 4; + meta.height = 4; + meta.data_size = 64; + } + assert!(unsafe { parent.publish(second) }); + } + let err = read_input_frame(&pools.input, 0).expect_err("slot mismatch"); + assert_eq!(err, "input slot mismatch: expected 0, got 1"); + + // A published frame with degenerate metadata (zero width) is + // rejected and released. + unsafe { publish_input(&in_region, 0, 0, 4, 64) }; + let err = read_input_frame(&pools.input, 0).expect_err("zero width"); + assert_eq!(err, "input frame has invalid metadata"); + + // A negative data size is equally invalid. + unsafe { publish_input(&in_region, 0, 4, 4, -1) }; + let err = read_input_frame(&pools.input, 0).expect_err("negative size"); + assert_eq!(err, "input frame has invalid metadata"); + + // A valid frame is copied out; an oversized `data_size` is clamped + // to the slot capacity. + unsafe { publish_input(&in_region, 0, 2, 2, 9999) }; + let frame = read_input_frame(&pools.input, 0).expect("valid input frame"); + assert_eq!((frame.width, frame.height), (2, 2)); + assert_eq!(frame.format, PixelFormat::F32); + assert_eq!(frame.data.len(), 64, "clamped to the slot block"); + assert!(frame.data.iter().all(|&b| b == 0x5A)); + } + + #[test] + fn write_output_frame_handles_full_oversize_publish_and_success() { + let frame = cpu_frame(1, 1, 16); + + // No free slots at all: the pool view is attached directly, since + // `attach_pools` rightly rejects a zero-slot handshake. + let (_empty_key, empty_region) = test_region("write-empty", 0, 16); + // SAFETY: live region created by `test_region`. + let empty_pool = unsafe { FrameSlotPool::attach(empty_region.data()) }; + let err = write_output_frame(&empty_pool, &frame).expect_err("full pool"); + assert_eq!(err, "output pool is full"); + + // `attach_pools` always requires the input geometry too, so + // announce a (never used) one-slot input pool. + let (pools, out_region, _in_region) = host_pools(1, 16, 1, 16); + + // A frame larger than the slot is rejected. + let oversized = cpu_frame(2, 2, 64); + let err = write_output_frame(&pools.output, &oversized).expect_err("oversize"); + assert!( + err.starts_with("output frame is 64 bytes, larger than the output slot (16)"), + "{err}" + ); + + // Success: the parent consumes the published slot and sees the meta. + let slot = write_output_frame(&pools.output, &frame).expect("publish"); + assert_eq!(slot, 0); + // SAFETY: live region; parent (drainer) side view. + let parent = unsafe { FrameSlotPool::attach(out_region.data()) }; + let mut consumed = 0u32; + assert!(unsafe { parent.consume(&mut consumed) }); + assert_eq!(consumed, 0); + // SAFETY: `consumed` was just consumed. + let meta = unsafe { &*parent.meta_const(consumed) }; + assert_eq!((meta.width, meta.height), (1, 1)); + assert_eq!(meta.format, PixelFormat::F32 as i32); + assert_eq!(meta.channel_count, 4); + assert_eq!(meta.linesize, 16); + assert_eq!(meta.data_size, 16); + unsafe { parent.release(consumed) }; + + // Ready ring full: a duplicate publish fills the single-slot ring, + // the slot is recycled through the free ring, and the next publish + // fails. + unsafe { + let mut slot0 = 0u32; + assert!(pools.output.acquire(&mut slot0)); + assert!(pools.output.publish(slot0)); + assert!(pools.output.release(slot0)); + } + let err = write_output_frame(&pools.output, &frame).expect_err("ready ring full"); + assert_eq!(err, "output publish failed"); + } + + fn cpu_frame(width: i32, height: i32, bytes: usize) -> Frame { + let pod = VideoParamsPod { + width, + height, + format: PixelFormat::F32 as i32, + ..Default::default() + }; + let mut frame = Frame::new(); + frame.set_video_params(pod); + frame.data = vec![0x7F; bytes]; + frame + } + + #[test] + fn handle_job_rejects_malformed_and_unbacked_jobs() { + let (pools, _out, _in) = host_pools(2, 64, 2, 64); + + // Wrong wire types: OfxJobMsg deserialization fails. + let resp = handle_job(json!({ "job": "five" }), &pools); + assert_eq!(resp["type"], crate::ipc::TYPE_ERROR); + assert!( + resp["message"] + .as_str() + .unwrap() + .starts_with("invalid ofx_job: "), + "{resp}" + ); + + // A declared input with nothing published fails with the job id + // echoed and slot -1. + let resp = handle_job( + json!({ + "job": 9, + "type_id": "org.oak.test", + "inputs": [{ "name": "Source", "slot": 0 }], + }), + &pools, + ); + assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT); + assert_eq!(resp["job"], 9); + assert_eq!(resp["slot"], -1); + assert_eq!(resp["error"], "input slot missing"); + + // Same for a main-source slot that was never published. + let resp = handle_job( + json!({ "job": 10, "type_id": "org.oak.test", "src_slot": 0 }), + &pools, + ); + assert_eq!(resp["job"], 10); + assert_eq!(resp["error"], "input slot missing"); + } + + #[test] + fn handle_job_reports_factory_and_executor_failures() { + let _guard = global_factory_lock(); + let (pools, _out, _in) = host_pools(2, 64, 2, 64); + let job = json!({ "job": 11, "type_id": "org.oak.test" }); + + // No instance factory installed: the host cannot resolve anything. + eval::set_plugin_instance_factory(None); + eval::set_plugin_executor(None); + let resp = handle_job(job.clone(), &pools); + assert_eq!( + resp["error"], + "no plugin instance factory installed in the OFX host" + ); + assert_eq!(resp["slot"], -1); + + // Factory resolves nothing: unknown plugin. + eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| None))); + let resp = handle_job(job.clone(), &pools); + assert_eq!( + resp["error"], + "unknown or unavailable OFX plugin: org.oak.test" + ); + + // Instance resolved but no executor wired in. + eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| Some(7)))); + eval::set_plugin_executor(None); + let resp = handle_job(job.clone(), &pools); + assert_eq!( + resp["error"], + "no plugin executor installed in the OFX host" + ); + + eval::set_plugin_instance_factory(None); + } + + #[test] + fn handle_job_renders_through_the_executor_and_reports_errors() { + let _guard = global_factory_lock(); + let (pools, _out, in_region) = host_pools(2, 64, 4, 64); + // SAFETY: live region created by host_pools. + unsafe { publish_input(&in_region, 0, 1, 1, 16) }; + + // The custom executor asserts the resolved spec and returns a + // 1x1 frame; the host publishes it into the output pool. + let exec: Arc = Arc::new(|req: &PluginJobRequest<'_>| { + match req.spec { + JobSpec::Plugin { + instance, + type_id, + time, + effect_input_id, + inputs, + values, + } => { + assert_eq!(*instance, 7); + assert_eq!(type_id, "org.oak.test"); + assert!((*time - 0.5).abs() < 1e-9); + assert_eq!(effect_input_id.as_deref(), Some("Source")); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].0, "Source"); + assert_eq!(values.len(), 1); + assert_eq!(values[0].0, "brightness"); + assert_eq!(values[0].1, oak_node::value::NodeValue::Float(0.5)); + } + other => panic!("expected a plugin spec, got {other:?}"), + } + let frame = eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32) + .expect("generate"); + Ok(Texture::wrap_frame(frame)) + }); + let factory: Arc = Arc::new(|_type_id: &str| Some(7)); + eval::set_plugin_instance_factory(Some(factory)); + eval::set_plugin_executor(Some(exec)); + + let resp = handle_job( + json!({ + "job": 21, + "type_id": "org.oak.test", + "time": 0.5, + "effect_input_id": "Source", + "inputs": [{ "name": "Source", "slot": 0 }], + "values": [{ "input": "brightness", "value": { "t": "float", "v": 0.5 } }], + }), + &pools, + ); + assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT); + assert_eq!(resp["job"], 21); + assert!(resp["slot"].as_i64().unwrap() >= 0, "{resp}"); + assert_eq!(resp["error"], ""); + + // Executor failure is reported as a job failure; this one arrives + // through the explicit `src_slot` path. + // SAFETY: live region created by host_pools. + unsafe { publish_input(&in_region, 0, 1, 1, 16) }; + let failing: Arc = + Arc::new(|_req: &PluginJobRequest<'_>| Err(oak_core::error::Error::Failed("boom".into()))); + eval::set_plugin_executor(Some(failing)); + let resp = handle_job( + json!({ "job": 22, "type_id": "org.oak.test", "src_slot": 0 }), + &pools, + ); + assert_eq!(resp["job"], 22); + assert!( + resp["error"] + .as_str() + .unwrap() + .starts_with("plugin render failed: "), + "{resp}" + ); + + // A texture that cannot be read back fails before publishing. + let gpu_returning: Arc = Arc::new(|_req: &PluginJobRequest<'_>| { + Ok(Texture::gpu( + Arc::new(FailingDownloadGpu), + 1, + 1, + 1, + PixelFormat::F32, + )) + }); + eval::set_plugin_executor(Some(gpu_returning)); + let resp = handle_job( + json!({ "job": 23, "type_id": "org.oak.test" }), + &pools, + ); + assert_eq!(resp["job"], 23); + assert!( + resp["error"] + .as_str() + .unwrap() + .starts_with("plugin output readback failed: "), + "{resp}" + ); + + eval::set_plugin_instance_factory(None); + eval::set_plugin_executor(None); + } + + #[test] + fn handle_job_falls_back_to_first_input_then_dummy_source() { + let _guard = global_factory_lock(); + let (pools, _out, in_region) = host_pools(2, 64, 4, 64); + // SAFETY: live region created by host_pools. + unsafe { publish_input(&in_region, 0, 1, 1, 16) }; + + // Capture the src the fallback picked. Values stay empty: the + // resolver runs before the executor. + let kinds: Arc>> = Arc::new(Mutex::new(Vec::new())); + let record = kinds.clone(); + let exec: Arc = Arc::new(move |req: &PluginJobRequest<'_>| { + record + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(if req.src.is_dummy() { "dummy" } else { "frame" }); + let frame = eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32) + .expect("generate"); + Ok(Texture::wrap_frame(frame)) + }); + let factory: Arc = Arc::new(|_type_id: &str| Some(1)); + eval::set_plugin_instance_factory(Some(factory)); + eval::set_plugin_executor(Some(exec)); + + // The declared effect input is absent from `inputs`, so the first + // clip is used instead. + let resp = handle_job( + json!({ + "job": 31, + "type_id": "org.oak.test", + "effect_input_id": "Source", + "inputs": [{ "name": "Extra", "slot": 0 }], + }), + &pools, + ); + assert_eq!(resp["error"], "", "{resp}"); + + // No clips and no explicit src: the dummy texture is used. + let resp = handle_job( + json!({ "job": 32, "type_id": "org.oak.test", "effect_input_id": "Source" }), + &pools, + ); + assert_eq!(resp["error"], "", "{resp}"); + + let seen = kinds.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert_eq!(seen, vec!["frame", "dummy"]); + + eval::set_plugin_instance_factory(None); + eval::set_plugin_executor(None); + } + #[test] fn crash_hooks_parse_args() { let hooks = CrashHooks::from_args(&[ @@ -513,5 +1151,66 @@ mod tests { let hooks = CrashHooks::from_args(&["oak-worker".to_string(), "--ofx-crash-always".to_string()]); assert!(hooks.always); assert!(hooks.once_marker.is_none()); + + // Unknown flags and a missing option value are ignored. + let hooks = CrashHooks::from_args(&[ + "oak-worker".to_string(), + "--ofx-host".to_string(), + "--not-a-flag".to_string(), + "--ofx-crash-once".to_string(), + ]); + assert!(!hooks.always); + assert!(hooks.once_marker.is_none()); + } + + // ---- M16 R2 coverage additions ---------------------------------------- + + /// Every hook of the fake failing GPU context is callable and reports + /// the failure (the executor readback path uses `download`; the other + /// hooks document the trait contract). + #[test] + fn failing_download_gpu_hooks_return_errors() { + let gpu = FailingDownloadGpu; + assert_eq!(gpu.kind(), BackendKind::Gl); + gpu.destroy_texture(1); + let frame = cpu_frame(1, 1, 4); + assert!(gpu.upload(1, &frame).is_err(), "fake upload always fails"); + assert!(gpu.download(1).is_err(), "fake download always fails"); + assert!(gpu.blit(1, 2, None).is_err(), "fake blit always fails"); + } + + /// A successful plugin render whose output pool has no free slot fails + /// the job with "output pool is full" (the acquired-but-unpublished + /// slot is not leaked into the ready ring). + #[test] + fn handle_job_reports_output_pool_full() { + let _guard = global_factory_lock(); + let (pools, _out, _in) = host_pools(1, 16, 1, 16); + // Drain the only free output slot through the producer side, so the + // host's `write_output_frame` cannot acquire one. + let mut drained = 0u32; + // SAFETY: live attached pools; the test owns both sides and is + // single-threaded. + assert!(unsafe { pools.output.acquire(&mut drained) }); + assert_eq!(drained, 0); + + let exec: Arc = Arc::new(|_req: &PluginJobRequest<'_>| { + let frame = + eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32) + .expect("generate"); + Ok(Texture::wrap_frame(frame)) + }); + eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| Some(1)))); + eval::set_plugin_executor(Some(exec)); + let resp = handle_job(json!({ "job": 41, "type_id": "org.oak.test" }), &pools); + eval::set_plugin_instance_factory(None); + eval::set_plugin_executor(None); + + assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT); + assert_eq!(resp["job"], 41); + assert_eq!(resp["slot"], -1); + assert_eq!(resp["error"], "output pool is full"); + // Nothing was published. + assert!(!unsafe { pools.output.consume(&mut drained) }); } } diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs index 5b0319853..ad12ca16e 100644 --- a/crates/oak-worker/src/worker.rs +++ b/crates/oak-worker/src/worker.rs @@ -69,6 +69,12 @@ use crate::ipc::{ }; use crate::{log_error, PROTOCOL_VERSION}; +/// Serializes unit tests that install or drive the process-global +/// plugin/progress factories (the `ofx_host` tests share this lock: the +/// worker's and the host's reporter factories are process-wide). +#[cfg(test)] +pub(crate) static GLOBAL_FACTORY_TEST_LOCK: Mutex<()> = Mutex::new(()); + /// A loaded graph snapshot (M15 S1): the snapshot file path plus what it /// deserialized into — a full oaknode project, or only the copied-project /// identity (the minimal `{"project_copy":N}` payload the @@ -1557,10 +1563,71 @@ pub fn worker_main(backend: &str) -> i32 { #[cfg(test)] mod tests { use super::*; - use crate::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; + use crate::ipc::{ + FrameSlotPool, SharedMemoryRegion, ShmMode, WireEffectParam, WireMontageClip, + WireMontageEffect, WireNodeValue, + }; + use oak_core::colormath::{OutputColorSpec, WorkingColorSpace}; + use oak_node::id::NodeId; + use oak_node::project::Project; + use oak_node::sequence::SequenceBehavior; + use oak_node::track::TrackListBehavior; use serde_json::json; + use std::collections::HashMap; use std::ptr; + /// Serializes the tests that mutate the process-global pipeline color + /// settings (the two pre-existing adopt/sync tests included). + static COLOR_TEST_LOCK: Mutex<()> = Mutex::new(()); + + /// Saves the process-global pipeline color settings and restores them on + /// drop, so a panicking assertion cannot leak the override into the next + /// serialized test. All users hold [`COLOR_TEST_LOCK`]. + struct ColorSettingsGuard { + working: oak_core::colormath::WorkingColorSpace, + output: oak_core::colormath::OutputColorSpec, + } + + impl ColorSettingsGuard { + fn capture() -> ColorSettingsGuard { + ColorSettingsGuard { + working: oak_core::color::pipeline_working_space(), + output: oak_core::color::pipeline_output_spec(), + } + } + } + + impl Drop for ColorSettingsGuard { + fn drop(&mut self) { + oak_core::color::set_pipeline_color_settings(self.working, self.output); + } + } + + /// Test-only RAII environment override: restores the previous value (or + /// absence) on drop, so a panicking assertion cannot leak a crash-mode + /// override into the next serialized test. + struct EnvGuard { + name: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(name: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(name); + std::env::set_var(name, value); + Self { name, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.name, value), + None => std::env::remove_var(self.name), + } + } + } + fn test_key(name: &str) -> String { static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -1579,11 +1646,9 @@ mod tests { let out_key = test_key("out"); let out_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); let mut out_region = SharedMemoryRegion::new(); - assert!( - out_region.open(&out_key, out_bytes, ShmMode::Create), - "{}", - out_region.error() - ); + let opened = out_region.open(&out_key, out_bytes, ShmMode::Create); + let open_error = out_region.error(); + assert!(opened, "{open_error}"); // SAFETY: live mapping sized by bytes_needed. let _pool = unsafe { FrameSlotPool::create(out_region.data(), slots as u32, slot_bytes as usize) }; @@ -1633,6 +1698,20 @@ mod tests { assert!(s.shutdown_requested()); } + /// The M15 headless "cpu" backend: no renderer, but the session stays + /// fully operational (generated frames render through CPU eval). + #[test] + fn cpu_backend_session_is_headless_but_operational() { + assert!(is_cpu_backend("cpu")); + assert!(is_cpu_backend("CPU")); + assert!(!is_cpu_backend("auto")); + let mut s = WorkerSession::create("cpu").unwrap(); + assert!(!s.has_renderer(), "cpu means no GPU renderer"); + assert!(!s.shutdown_requested()); + assert!(s.handle_line(r#"{"type":"shutdown"}"#).is_none()); + assert!(s.shutdown_requested()); + } + #[test] fn startup_handshake_is_protocol_version_1_with_empty_geometry() { let s = WorkerSession::create("none").unwrap(); @@ -1904,6 +1983,8 @@ mod tests { #[test] fn load_graph_adopts_pipeline_colors_from_snapshot() { use oak_core::colormath::{OutputColorSpec, WorkingColorSpace}; + let _guard = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _colors = ColorSettingsGuard::capture(); // Reset the process global to something different from the snapshot's // settings so the adopt step is observable. oak_core::color::set_pipeline_color_settings( @@ -1931,16 +2012,13 @@ mod tests { "load_graph must adopt the snapshot's working space" ); let _ = std::fs::remove_file(&path); - // Restore the default global so parallel tests are not disturbed. - oak_core::color::set_pipeline_color_settings( - WorkingColorSpace::default(), - OutputColorSpec::default(), - ); } #[test] fn sync_pipeline_color_from_graph_restores_stale_global() { - use oak_core::colormath::{OutputColorSpec, WorkingColorSpace}; + use oak_core::colormath::WorkingColorSpace; + let _guard = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _colors = ColorSettingsGuard::capture(); let project = oak_node::project::Project::new(); { let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); @@ -1978,10 +2056,6 @@ mod tests { "no change means no frame-cache invalidation" ); let _ = std::fs::remove_file(&path); - oak_core::color::set_pipeline_color_settings( - WorkingColorSpace::default(), - OutputColorSpec::default(), - ); } #[test] @@ -2085,6 +2159,11 @@ mod tests { #[test] fn render_batch_stream_renders_generated_frames_and_reports_failures() { + // The crash-mode env vars are process-wide: serialize with the + // crash-hook test that mutates them. + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); let mut s = WorkerSession::create("none").unwrap(); // Slots sized for 8x8 BGRA8. let (hs, out_region, _in) = parent_side(4, 8 * 8 * 4, false); @@ -2137,6 +2216,10 @@ mod tests { #[test] fn render_audio_batch_stream_mixes_silence_into_slot() { + // Serialize with the crash-hook env test (see render_batch_stream). + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); // M15 S3: an empty-montage audio range pull (1/24 s at 48 kHz // stereo = 2000 frames x 2 ch = 16000 bytes) renders total silence // into the assigned slot and reports frame_ready with the audio @@ -2227,6 +2310,10 @@ mod tests { #[test] fn render_audio_batch_rejects_oversized_range() { + // Serialize with the crash-hook env test (see render_batch_stream). + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); // A range longer than the slot can hold must fail with frame_failed // (never a buffer overflow into the next slot). let mut s = WorkerSession::create("none").unwrap(); @@ -2263,13 +2350,10 @@ mod tests { assert_eq!(lines[0]["type"], "batch_accepted"); assert_eq!(lines[1]["type"], "frame_failed"); assert_eq!(lines[1]["ticket"], 21); + let error_text = lines[1]["error"].as_str().unwrap().to_string(); assert!( - lines[1]["error"] - .as_str() - .unwrap() - .contains("needs 16000 bytes"), - "oversized range reported: {}", - lines[1]["error"] + error_text.contains("needs 16000 bytes"), + "oversized range reported: {error_text}" ); // Slot 0 acquired but never published. let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; @@ -2311,4 +2395,1055 @@ mod tests { .unwrap(); assert_eq!(resp["message"], "invalid handshake message"); } + + // ---- M16 R2 branch-coverage additions --------------------------------- + + /// One render-batch ticket with the pipeline defaults for everything + /// the tests do not care about. + fn batch_spec(ticket: i64, slot: i32, width: i32, height: i32, format: i32) -> BatchTicketSpec { + BatchTicketSpec { + ticket, + slot, + time_num: 0, + time_den: 1, + width, + height, + format, + channels: 4, + ..Default::default() + } + } + + /// A project holding one empty sequence viewer; returns the project, + /// the viewer's loaded id and the project uuid. + fn sequence_project() -> (Arc>, NodeId, String) { + let project = Project::new(); + let seq; + { + let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); + let (core, behavior) = SequenceBehavior::create(); + seq = guard.graph.add_node(core, behavior); + } + let uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone(); + (project, seq, uuid) + } + + #[test] + fn renderer_creation_falls_back_or_succeeds_headless() { + // On a GPU-less host the dynamic -> direct-OpenGL fallback fails and + // the session continues headless (M16 S1); on a GPU host it simply + // initializes. Either is a valid production outcome — creation must + // never error out of `WorkerSession::create`, and the session's + // renderer flag must agree with what the factory can do on this host + // (the second `create` is the availability oracle; the renderer flag + // is not hand-rolled anywhere else). + let session = WorkerSession::create("auto").expect("session creation is tolerant"); + assert_eq!( + session.has_renderer(), + Renderer::create("auto").is_ok(), + "the session carries a renderer exactly when the factory can create one" + ); + } + + #[test] + fn renderer_is_open_gl_reports_the_backend_kind() { + let gl = Renderer { + inner: DisplayRenderer::new(BackendKind::Gl), + }; + assert!(gl.is_open_gl()); + let vk = Renderer { + inner: DisplayRenderer::new(BackendKind::Vulkan), + }; + assert!(!vk.is_open_gl()); + } + + #[test] + fn initialize_runtime_second_call_is_a_noop() { + let mut s = WorkerSession::create("none").unwrap(); + s.runtime_initialized = true; + assert!(s.initialize_runtime(), "already initialized"); + } + + #[test] + fn worker_progress_events_flush_in_order_and_cancel_is_sticky() { + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + WORKER_PROGRESS_EVENTS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed); + install_worker_progress_factory(); + assert!( + oak_plugin::progress::has_reporter_factory(), + "the worker factory must be installed for the plugin progress suite" + ); + // Drive progressStart -> worker factory -> update -> progressEnd + // through the plugin progress suite, exactly like a real render. + oak_plugin::suites::progress::set_current(Some( + oak_plugin::progress::ProgressReporter::silent(), + )); + let v2 = oak_plugin::suites::progress::suite_v2(); + let v1 = oak_plugin::suites::progress::suite_v1(); + let label = std::ffi::CString::new("render").unwrap(); + let message = std::ffi::CString::new("frame 1").unwrap(); + // SAFETY: the suite takes the null handle by contract; the C strings + // outlive the calls. + unsafe { + assert_eq!( + (v2.start)(std::ptr::null_mut(), label.as_ptr(), message.as_ptr()), + oak_plugin::suites::status::OK + ); + assert_eq!( + (v2.update)(std::ptr::null_mut(), 0.25), + oak_plugin::suites::status::OK + ); + // progressEnd is forwarded by the v1 suite (v2's end is a no-op). + assert_eq!( + (v1.end)(std::ptr::null_mut()), + oak_plugin::suites::status::OK + ); + } + oak_plugin::suites::progress::set_current(None); + + let mut out: Vec = Vec::new(); + flush_worker_progress(&mut out); + let events: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(events.len(), 3, "start + update + end"); + assert_eq!(events[0]["type"], crate::ipc::TYPE_PLUGIN_PROGRESS); + assert_eq!(events[0]["label"], "render"); + assert_eq!(events[0]["message"], "frame 1"); + assert_eq!(events[0]["fraction"], 0.0); + assert_eq!(events[1]["fraction"], 0.25); + assert_eq!(events[2]["fraction"], 1.0); + + // plugin_cancel is sticky until a fresh progressStart resets it. + let mut s = WorkerSession::create("none").unwrap(); + assert!(s + .handle_line(r#"{"type":"plugin_cancel"}"#) + .is_none()); + assert!(WORKER_PLUGIN_CANCEL.load(Ordering::Relaxed)); + WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed); + } + + /// A writer whose pipe is already closed. + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) + } + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) + } + } + + #[test] + fn flush_worker_progress_breaks_on_the_first_write_error() { + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + WORKER_PROGRESS_EVENTS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + push_worker_progress(0.1, "a", "b"); + push_worker_progress(0.2, "c", "d"); + let mut failing = FailingWriter; + flush_worker_progress(&mut failing); + // The buffer is drained even though the pipe failed. + let mut out: Vec = Vec::new(); + flush_worker_progress(&mut out); + assert!(out.is_empty()); + } + + #[test] + fn crash_hook_env_error_paths_are_inert() { + // The env vars are process-wide; the batch tests that call the hook + // take this same lock. The `EnvGuard`s restore the environment even + // if an assertion below fails (a leaked crash-mode override could + // abort a later serialized test). + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let session = WorkerSession::create("none").unwrap(); + + let marker = std::env::temp_dir().join(format!( + "oak_worker_crash_marker_{}", + std::process::id() + )); + let _ = std::fs::remove_file(&marker); + let _marker_env = EnvGuard::set("OAK_WORKER_CRASH_MARKER", &marker); + + // A non-numeric ticket id is ignored (no parse, no crash, and no + // marker file side effect). + let _ticket = EnvGuard::set("OAK_WORKER_CRASH_ON_TICKET", "not-a-number"); + session.maybe_crash_for_testing(1); + assert!( + !marker.exists(), + "a non-numeric ticket never reaches the marker write" + ); + drop(_ticket); + + // A matching ticket whose one-shot marker already exists does not + // crash again (the restarted worker renders for real) and leaves the + // marker untouched. + std::fs::write(&marker, b"crashed").unwrap(); + let _ticket = EnvGuard::set("OAK_WORKER_CRASH_ON_TICKET", i64::MIN.to_string()); + session.maybe_crash_for_testing(i64::MIN); + assert_eq!( + std::fs::read(&marker).unwrap(), + b"crashed".as_slice(), + "the existing marker is left untouched" + ); + session.maybe_crash_for_testing(7); // different ticket: early return + assert_eq!( + std::fs::read(&marker).unwrap(), + b"crashed".as_slice(), + "a mismatching ticket writes nothing" + ); + + let _ = std::fs::remove_file(&marker); + } + + #[test] + fn handshake_input_attach_failure_reports_error() { + let mut s = WorkerSession::create("none").unwrap(); + let (mut hs, _out, _in) = parent_side(2, 256, false); + hs["input_slots"] = json!(2); + hs["input_slot_data_bytes"] = json!(256); + hs["input_shm_key"] = json!(format!("olive-rw-{}-missing-in", std::process::id())); + let resp = s.handle_line(&hs.to_string()).unwrap(); + assert_eq!(resp["type"], "error"); + assert!( + resp["message"] + .as_str() + .unwrap() + .starts_with("failed to attach input shared memory: "), + "{resp}" + ); + } + + #[test] + fn handshake_rejects_non_pool_input_segment() { + let mut s = WorkerSession::create("none").unwrap(); + let (mut hs, _out, _in) = parent_side(2, 256, false); + let key = test_key("nopool-in"); + let bytes = FrameSlotPool::bytes_needed(2, 256); + let mut region = SharedMemoryRegion::new(); + assert!(region.open(&key, bytes, ShmMode::Create)); + hs["input_slots"] = json!(2); + hs["input_slot_data_bytes"] = json!(256); + hs["input_shm_key"] = json!(key); + let resp = s.handle_line(&hs.to_string()).unwrap(); + assert_eq!( + resp["message"], + "input shared memory does not contain a frame slot pool" + ); + } + + #[test] + fn load_graph_rejects_bad_shape_and_unreadable_files() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s.handle_line(r#"{"type":"load_graph","path":42}"#).unwrap(); + assert_eq!(resp["message"], "invalid load_graph message"); + + // A non-empty file that is not valid UTF-8: it passes the metadata + // checks but cannot be read as a project. + let unreadable = std::env::temp_dir().join(format!( + "oak_worker_unreadable_{}.ove", + std::process::id() + )); + std::fs::write(&unreadable, [0xFFu8, 0xFE, 0x00, 0x80]).unwrap(); + let resp = s + .handle_line( + &json!({ "type": "load_graph", "path": unreadable.display().to_string() }) + .to_string(), + ) + .unwrap(); + assert!( + resp["message"] + .as_str() + .unwrap() + .starts_with("graph file unreadable: "), + "{resp}" + ); + let _ = std::fs::remove_file(&unreadable); + + // The identity-only payload still lands as a graph context. + let ident = std::env::temp_dir().join(format!( + "oak_worker_identity_{}.ove", + std::process::id() + )); + std::fs::write(&ident, r#"{"project_copy":11}"#).unwrap(); + assert!(s + .handle_line( + &json!({ "type": "load_graph", "path": ident.display().to_string() }) + .to_string(), + ) + .is_none()); + let graph = s.graph.as_ref().expect("identity graph loaded"); + assert!(graph.project.is_none()); + assert_eq!(graph.project_copy, 11); + let _ = std::fs::remove_file(&ident); + } + + #[test] + fn render_frame_maps_every_supported_pixel_format() { + let mut s = WorkerSession::create("none").unwrap(); + // Five slots so each render can publish without draining. + let (hs, _out, _in) = parent_side(5, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + for format in [ + PixelFormat::U8, + PixelFormat::U10, + PixelFormat::U16, + PixelFormat::F16, + PixelFormat::F32, + ] { + let line = json!({ + "type": "render_frame", + "ticket": 100 + format as i64, + "time_num": 0, + "time_den": 1, + "width": 2, + "height": 2, + "format": format as i32, + }) + .to_string(); + let resp = s.handle_line(&line).unwrap(); + assert_eq!(resp["type"], "frame_ready", "{format:?}: {resp}"); + } + } + + #[test] + fn render_frame_rejects_invalid_message_and_unsupported_format() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s + .handle_line(r#"{"type":"render_frame","ticket":"nope"}"#) + .unwrap(); + assert_eq!(resp["message"], "invalid render_frame message"); + + let (hs, _out, _in) = parent_side(2, 256, false); + assert_eq!( + s.handle_line(&hs.to_string()).unwrap()["type"], + crate::ipc::TYPE_HELLO_CAPS + ); + let resp = s + .handle_line( + r#"{"type":"render_frame","ticket":9,"time_num":0,"time_den":1,"width":4,"height":4,"format":7}"#, + ) + .unwrap(); + assert_eq!(resp["message"], "render_frame: unsupported format 7"); + assert_eq!(resp["ticket"], 9); + } + + #[test] + fn render_frame_defaults_zero_size_and_rejects_the_oversized_frame() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + // 0x0 means "pipeline default" (1920x1080), which cannot fit the + // 64-byte slot. + let resp = s + .handle_line( + r#"{"type":"render_frame","ticket":10,"time_num":0,"time_den":1,"width":0,"height":0,"format":-1}"#, + ) + .unwrap(); + assert_eq!( + resp["message"], + "render_frame: frame larger than the shm slot" + ); + } + + #[test] + fn render_frame_reports_no_free_slot_on_shutdown() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 256, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + s.shutdown_requested = true; + let resp = s + .handle_line( + r#"{"type":"render_frame","ticket":11,"time_num":0,"time_den":1,"width":4,"height":4,"format":-1}"#, + ) + .unwrap(); + assert_eq!(resp["message"], "render_frame: no free shm slot"); + } + + #[test] + fn render_frame_reports_ready_ring_full() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 256, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + // Fill the single-entry ready ring, then recycle the slot through + // the free ring so the render can acquire it again. + let pool = s.output_pool.clone().unwrap(); + // SAFETY: live attached pool; single-threaded test. + unsafe { + let mut slot = 0u32; + assert!(pool.acquire(&mut slot)); + assert!(pool.publish(slot)); + assert!(pool.release(slot)); + } + let resp = s + .handle_line( + r#"{"type":"render_frame","ticket":12,"time_num":0,"time_den":1,"width":4,"height":4,"format":-1}"#, + ) + .unwrap(); + assert_eq!(resp["message"], "render_frame: ready ring full"); + } + + #[test] + fn batch_and_audio_batch_reject_invalid_messages() { + let mut s = WorkerSession::create("none").unwrap(); + let mut out: Vec = Vec::new(); + s.handle_render_batch_stream(r#"{"type":"render_batch","batch_id":"x"}"#, &mut out) + .unwrap(); + s.handle_render_audio_batch_stream(r#"{"type":"render_audio_batch","tickets":7}"#, &mut out) + .unwrap(); + let lines: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["message"], "invalid render_batch message"); + assert_eq!(lines[1]["type"], "error"); + assert_eq!(lines[1]["message"], "invalid render_audio_batch message"); + } + + #[test] + fn render_ticket_without_pool_and_with_wrong_slot_assignment() { + let mut s = WorkerSession::create("none").unwrap(); + let spec = batch_spec(1, 0, 4, 4, PixelFormat::F32 as i32); + assert_eq!( + s.render_ticket_to_slot(&spec).unwrap_err(), + "no shared-memory pool attached" + ); + + let (hs, _out, _in) = parent_side(2, 256, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let spec = batch_spec(1, 7, 4, 4, PixelFormat::F32 as i32); + assert_eq!( + s.render_ticket_to_slot(&spec).unwrap_err(), + "slot assignment mismatch: acquired 0, assigned 7" + ); + } + + #[test] + fn render_ticket_reports_a_full_ready_ring() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 256, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + // SAFETY: live attached pool; single-threaded test. + unsafe { + let mut slot = 0u32; + assert!(pool.acquire(&mut slot)); + assert!(pool.publish(slot)); + assert!(pool.release(slot)); + } + let spec = batch_spec(2, 0, 4, 4, PixelFormat::F32 as i32); + assert_eq!(s.render_ticket_to_slot(&spec).unwrap_err(), "ready ring full"); + } + + #[test] + fn render_spec_pixels_rejects_oversized_and_rerenders_short_cache_entries() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + + // 4x4 F32 needs 256 bytes; the slot holds 64. + let spec = batch_spec(3, 0, 4, 4, PixelFormat::F32 as i32); + let err = s.render_spec_pixels(&spec, &pool).unwrap_err(); + assert!(err.starts_with("frame 4x4 needs 256 bytes"), "{err}"); + + // A 2x2 frame fits exactly; a stale entry smaller than this + // geometry is defensively discarded and re-rendered. + let spec = batch_spec(4, 0, 2, 2, PixelFormat::F32 as i32); + let key = crate::framecache::spec_cache_key(&spec); + s.frame_cache.insert(key.clone(), vec![0u8; 4]); + s.render_spec_pixels(&spec, &pool).expect("2x2 fits"); + let cached = s.frame_cache.get(&key).expect("re-rendered and memoized"); + assert_eq!(cached.len(), 64); + } + + #[test] + fn render_spec_pixels_reports_footage_decode_failures() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(2, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + let missing = "/definitely/not/a/real/clip.mp4"; + + // F32 slot: the decode error propagates out of `render_f32_into`. + let spec = BatchTicketSpec { + footage_file: missing.to_string(), + ..batch_spec(5, 0, 2, 2, PixelFormat::F32 as i32) + }; + let err = s.render_spec_pixels(&spec, &pool).unwrap_err(); + assert!(err.starts_with("footage decode: "), "{err}"); + + // BGRA8 slot: the same failure through the scratch-buffer path. + let spec = BatchTicketSpec { + footage_file: missing.to_string(), + ..batch_spec(6, 1, 2, 2, SLOT_FORMAT_BGRA8) + }; + let err = s.render_spec_pixels(&spec, &pool).unwrap_err(); + assert!(err.starts_with("footage decode: "), "{err}"); + } + + #[test] + fn render_audio_ticket_without_pool_wrong_slot_and_full_ring() { + let mut s = WorkerSession::create("none").unwrap(); + assert_eq!( + s.render_audio_ticket_to_slot(&AudioTicketSpec::default()) + .unwrap_err(), + "no shared-memory pool attached" + ); + + let (hs, _out, _in) = parent_side(2, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let spec = AudioTicketSpec { + slot: 5, + ..Default::default() + }; + assert_eq!( + s.render_audio_ticket_to_slot(&spec).unwrap_err(), + "slot assignment mismatch: acquired 0, assigned 5" + ); + + // One sample frame of stereo audio needs 8 bytes; fill the + // single-entry ready ring first so the publish fails. + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 8, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + // SAFETY: live attached pool; single-threaded test. + unsafe { + let mut slot = 0u32; + assert!(pool.acquire(&mut slot)); + assert!(pool.publish(slot)); + assert!(pool.release(slot)); + } + let spec = AudioTicketSpec { + ticket: 2, + slot: 0, + time_num: 0, + time_den: 1, + duration_num: 1, + duration_den: 48000, + sample_rate: 48000, + channel_layout: 0x3, + channels: 2, + montage: Vec::new(), + }; + assert_eq!( + s.render_audio_ticket_to_slot(&spec).unwrap_err(), + "ready ring full" + ); + } + + #[test] + fn audio_ticket_params_maps_montage_and_effects() { + let s = WorkerSession::create("none").unwrap(); + let spec = AudioTicketSpec { + ticket: 1, + slot: 0, + time_num: 3, + time_den: 2, + duration_num: 1, + duration_den: 4, + sample_rate: 44100, + channel_layout: 0x3, + channels: 2, + montage: vec![WireMontageClip { + filename: "clip.mp4".to_string(), + stream_index: 2, + in_num: 1, + in_den: 2, + out_num: 3, + out_den: 2, + media_in_num: 0, + media_in_den: 1, + gain: 0.5, + effects: vec![WireMontageEffect { + type_id: "org.oak.gain".to_string(), + enabled: true, + effect_input_id: "Source".to_string(), + params: vec![WireEffectParam { + input: "gain".to_string(), + value: WireNodeValue::Float(2.0), + }], + }], + }], + }; + let params = s.audio_ticket_params(&spec).expect("valid geometry"); + assert_eq!(params.viewer, 0); + assert_eq!(params.range.in_(), Rational::new(3, 2)); + assert_eq!(params.range.out(), Rational::new(7, 4)); + assert_eq!(params.sample_rate, 44100); + assert_eq!(params.channel_layout, 0x3); + assert_eq!(params.montage.len(), 1); + let clip = ¶ms.montage[0]; + assert_eq!(clip.filename, "clip.mp4"); + assert_eq!(clip.stream_index, 2); + assert_eq!(clip.in_time, Rational::new(1, 2)); + assert_eq!(clip.out_time, Rational::new(3, 2)); + assert_eq!(clip.media_in, Rational::new(0, 1)); + assert_eq!(clip.gain, 0.5); + assert_eq!(clip.effects.len(), 1); + assert_eq!(clip.effects[0].type_id, "org.oak.gain"); + assert!(clip.effects[0].enabled); + assert_eq!(clip.effects[0].effect_input_id.as_deref(), Some("Source")); + assert_eq!(clip.effects[0].params.len(), 1); + assert_eq!(clip.effects[0].params[0].0, "gain"); + assert_eq!( + clip.effects[0].params[0].1, + oak_node::value::NodeValue::Float(2.0) + ); + } + + #[test] + fn sync_pipeline_color_without_a_project_is_a_noop() { + let mut s = WorkerSession::create("none").unwrap(); + assert!(!s.sync_pipeline_color_from_graph(), "no graph at all"); + s.graph = Some(LoadedGraph { + path: "identity".to_string(), + project: None, + project_uuid: None, + id_map: HashMap::new(), + project_copy: 3, + }); + assert!( + !s.sync_pipeline_color_from_graph(), + "identity-only graphs carry no color settings" + ); + } + + #[test] + fn render_spec_pixels_graph_mode_copies_the_sequence_frame() { + let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (project, seq, uuid) = sequence_project(); + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + + let mut graph = LoadedGraph { + path: "graph".to_string(), + project: Some(project), + project_uuid: Some(uuid.clone()), + id_map: HashMap::new(), + project_copy: 0, + }; + graph.id_map.insert(7, seq); + s.graph = Some(graph); + + let spec = BatchTicketSpec { + viewer_node: 7, + project_key: uuid, + ..batch_spec(41, 0, 2, 2, PixelFormat::F32 as i32) + }; + s.render_spec_pixels(&spec, &pool) + .expect("an empty sequence renders"); + // SAFETY: slot 0 of the attached pool is live and 64 bytes. + let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 64) }; + assert!( + dst.iter().all(|&b| b == 0), + "an empty sequence is transparent black" + ); + } + + #[test] + fn graph_mode_falls_back_for_mismatched_or_broken_viewers() { + let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (project, seq, uuid) = sequence_project(); + // A second, non-sequence node: mapping a viewer to it makes + // `render_graph_frame` fail and take the fallback path. + let track_list; + { + let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); + let (core, behavior) = TrackListBehavior::create(); + track_list = guard.graph.add_node(core, behavior); + } + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + + let mut graph = LoadedGraph { + path: "graph".to_string(), + project: Some(project), + project_uuid: Some(uuid.clone()), + id_map: HashMap::new(), + project_copy: 0, + }; + graph.id_map.insert(7, seq); + graph.id_map.insert(8, track_list); + s.graph = Some(graph); + + // (a) A stale project key: the snapshot must not answer it. + let spec = BatchTicketSpec { + viewer_node: 7, + project_key: "another-project".to_string(), + ..batch_spec(51, 0, 2, 2, PixelFormat::F32 as i32) + }; + s.render_spec_pixels(&spec, &pool) + .expect("stale identity falls back"); + + // (b) A viewer identity that is not even a valid NodeId. + let spec = BatchTicketSpec { + viewer_node: u64::MAX, + project_key: uuid.clone(), + ..spec.clone() + }; + s.render_spec_pixels(&spec, &pool) + .expect("absent viewer falls back"); + + // (c) A viewer mapped to a non-sequence node: graph render errors. + let spec = BatchTicketSpec { + viewer_node: 8, + project_key: uuid.clone(), + ..spec.clone() + }; + s.render_spec_pixels(&spec, &pool) + .expect("broken viewer falls back"); + + // (d) A loaded identity-only graph (uuid matches, no project). + s.graph = Some(LoadedGraph { + path: "identity".to_string(), + project: None, + project_uuid: Some(uuid.clone()), + id_map: HashMap::new(), + project_copy: 0, + }); + let spec = BatchTicketSpec { + viewer_node: 7, + project_key: uuid, + ..spec + }; + s.render_spec_pixels(&spec, &pool) + .expect("identity-only graph falls back"); + } + + #[test] + fn warn_graph_fallback_is_one_shot() { + // Smoke test: the only observable effect of the warning is a single + // stderr line, and the worker has no test-installable log sink, so + // the one-shot suppression itself cannot be asserted. What must hold + // either way: repeated and concurrent calls all return without + // corrupting shared state (the marker is an AtomicBool and the sink + // is stderr, so no call may deadlock or panic). + warn_graph_fallback(123, "first reason"); + warn_graph_fallback(123, "second reason"); + + let completed = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|scope| { + for viewer in 0..8u64 { + let completed = &completed; + scope.spawn(move || { + warn_graph_fallback(viewer, "concurrent reason"); + completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }); + } + }); + assert_eq!( + completed.load(std::sync::atomic::Ordering::Relaxed), + 8, + "every concurrent fallback warning must return" + ); + } + + #[test] + fn stale_pipeline_colors_are_refreshed_and_clear_the_frame_cache() { + let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Loop over both starting states so the opposite-selection branch + // runs both ways despite the shared process-global color state. + for current in [WorkingColorSpace::SrgbLegacy, WorkingColorSpace::AcesCg] { + oak_core::color::set_pipeline_color_settings(current, OutputColorSpec::default()); + let opposite = if current == WorkingColorSpace::SrgbLegacy { + WorkingColorSpace::AcesCg + } else { + WorkingColorSpace::SrgbLegacy + }; + let project = Project::new(); + { + let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); + guard.set_working_color_space(opposite); + } + let uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone(); + let mut s = WorkerSession::create("none").unwrap(); + s.graph = Some(LoadedGraph { + path: "graph".to_string(), + project: Some(project), + project_uuid: Some(uuid), + id_map: HashMap::new(), + project_copy: 0, + }); + s.frame_cache.insert("stale".to_string(), vec![1u8; 8]); + + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + let spec = batch_spec(61, 0, 2, 2, PixelFormat::F32 as i32); + s.render_spec_pixels(&spec, &pool) + .expect("renders under the refreshed colors"); + assert_eq!( + oak_core::color::pipeline_working_space(), + opposite, + "the loaded graph's colors are adopted" + ); + assert!( + s.frame_cache.get("stale").is_none(), + "a color change invalidates the frame cache" + ); + } + + oak_core::color::set_pipeline_color_settings( + WorkingColorSpace::default(), + OutputColorSpec::default(), + ); + } + + #[test] + fn apply_output_node_is_a_noop_in_the_legacy_working_space() { + let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + oak_core::color::set_pipeline_color_settings( + WorkingColorSpace::SrgbLegacy, + OutputColorSpec::default(), + ); + let mut bytes: Vec = [0.25f32, 0.5, 0.75, 1.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect(); + let before = bytes.clone(); + apply_output_node(&mut bytes, 1); + assert_eq!(bytes, before, "legacy sRGB is display-referred already"); + oak_core::color::set_pipeline_color_settings( + WorkingColorSpace::default(), + OutputColorSpec::default(), + ); + } + + #[test] + fn worker_main_without_renderer_exits_one() { + // Mirrors oakengine_worker_main(): an explicit no-renderer backend + // leaves nothing to evaluate, so the process exits 1 before the + // control loop. + assert_eq!(worker_main("none"), 1); + } + + // ---- M16 R2 branch-coverage additions (runtime + renderer) ------------ + + /// The full `initialize_runtime` body on a fresh session: color config, + /// text backends, plugin executor, OFX scan/registration and the + /// process-global progress factory. A plugin scan failure is tolerated, + /// so either outcome is a valid production result. + #[test] + fn initialize_runtime_installs_the_full_stack() { + // The reporter factory is process-global; serialize with every + // other factory test (worker and ofx_host share this lock). + let _guard = GLOBAL_FACTORY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = WorkerSession::create("none").unwrap(); + assert!(!s.runtime_initialized); + assert!(s.initialize_runtime(), "the runtime always initializes"); + assert!(s.runtime_initialized); + assert!( + oak_plugin::progress::has_reporter_factory(), + "the worker progress factory is installed" + ); + // The second call short-circuits before re-installing anything. + assert!(s.initialize_runtime()); + } + + /// The dynamic / direct-OpenGL renderer factories: a GPU host + /// initializes one, a GPU-less host fails both — either outcome is a + /// valid production result, so the assertions pin the invariants that + /// must hold either way: a successful dynamic attempt reports the + /// requested backend kind (init resolves adapters internally but never + /// rewrites the kind), a failure names the stage that failed, and the + /// chained `create` succeeds exactly when one of its two stages can, + /// reporting OpenGL exactly when the fallback produced it. + #[test] + fn renderer_create_variants_are_tolerant() { + match Renderer::create_dynamic("gl") { + Ok(renderer) => assert!(renderer.is_open_gl(), "the gl request reports OpenGL"), + Err(error) => assert!( + error.starts_with("failed to initialize dynamic gl renderer"), + "the gl failure names the stage: {error}" + ), + } + + let dynamic_auto = Renderer::create_dynamic("auto"); + let dynamic_metal = Renderer::create_dynamic("metal"); + for (label, attempt) in [("auto", &dynamic_auto), ("metal", &dynamic_metal)] { + match attempt { + Ok(renderer) => assert!( + !renderer.is_open_gl(), + "{label} is not an OpenGL request" + ), + Err(error) => assert!( + error.starts_with(&format!("failed to initialize dynamic {label} renderer")), + "the {label} failure names the stage: {error}" + ), + } + } + + let opengl = Renderer::create_opengl(); + match &opengl { + Ok(renderer) => assert!(renderer.is_open_gl(), "direct OpenGL reports OpenGL"), + Err(error) => assert!( + error.starts_with("failed to initialize direct OpenGL renderer"), + "the fallback failure names the stage: {error}" + ), + } + + // `Renderer::create` = dynamic stage, then direct-OpenGL fallback: + // its outcome is fully determined by the two stages observed above. + for (request, dynamic) in [("bogus", &dynamic_auto), ("metal", &dynamic_metal)] { + match Renderer::create(request) { + Ok(renderer) => { + assert_eq!( + renderer.is_open_gl(), + dynamic.is_err(), + "{request}: OpenGL is reported exactly when the dynamic stage failed" + ); + if dynamic.is_err() { + assert!( + opengl.is_ok(), + "{request}: the fallback must have initialized" + ); + } + } + Err(error) => { + assert!( + dynamic.is_err() && opengl.is_err(), + "{request}: a chained error means both stages failed" + ); + assert!( + error.contains(&format!("dynamic {request}")) + && error.contains("direct OpenGL fallback also failed"), + "{request}: the chained error names both stages: {error}" + ); + } + } + } + } + + /// An F32 cache hit copies the memoized bytes into the slot instead of + /// re-rendering (a miss would produce transparent black). + #[test] + fn render_spec_pixels_f32_cache_hit_copies_without_render() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + let spec = batch_spec(71, 0, 2, 2, PixelFormat::F32 as i32); + let key = crate::framecache::spec_cache_key(&spec); + let known: Vec = (0..64).map(|i| i as u8).collect(); + s.frame_cache.insert(key.clone(), known.clone()); + s.render_spec_pixels(&spec, &pool).expect("cache hit"); + // SAFETY: slot 0 of the attached pool is live and 64 bytes. + let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 64) }; + assert_eq!(dst, &known[..], "the cached F32 bytes land in the slot"); + assert!(s.frame_cache.get(&key).is_some(), "the entry stays memoized"); + } + + /// A BGRA8 cache hit runs the output node + format convert on the + /// memoized F32 bytes (a miss would produce transparent black BGRA8). + #[test] + fn render_spec_pixels_bgra8_cache_hit_converts_the_memoized_frame() { + let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + oak_core::color::set_pipeline_color_settings( + WorkingColorSpace::SrgbLegacy, + OutputColorSpec::default(), + ); + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + let pool = s.output_pool.clone().unwrap(); + let spec = batch_spec(72, 0, 2, 2, SLOT_FORMAT_BGRA8); + let key = crate::framecache::spec_cache_key(&spec); + // Four opaque-white F32 pixels. + let white: Vec = std::iter::repeat_n(1.0f32.to_le_bytes(), 2 * 2 * 4) + .flatten() + .collect(); + s.frame_cache.insert(key.clone(), white); + s.render_spec_pixels(&spec, &pool).expect("cache hit"); + // SAFETY: slot 0 of the attached pool holds the 2x2 BGRA8 frame. + let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 2 * 2 * 4) }; + assert!( + dst.iter().all(|&b| b == 255), + "cached white F32 converts to opaque white BGRA8: {dst:?}" + ); + oak_core::color::set_pipeline_color_settings( + WorkingColorSpace::default(), + OutputColorSpec::default(), + ); + } + + /// The batch path's acquire failure (shutdown) and a render error both + /// surface as `Err` from `render_ticket_to_slot` (the slot is never + /// published in either case). + #[test] + fn render_ticket_to_slot_reports_shutdown_and_render_errors() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(2, 256, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + + // Shutdown: `acquire_slot` refuses before touching the rings. + s.shutdown_requested = true; + let spec = batch_spec(1, 0, 4, 4, PixelFormat::F32 as i32); + assert_eq!( + s.render_ticket_to_slot(&spec).unwrap_err(), + "no free shm slot (shutdown or timeout)" + ); + s.shutdown_requested = false; + + // A render failure (missing footage) propagates out of the batch + // path unchanged. + let spec = BatchTicketSpec { + footage_file: "/definitely/not/a/real/clip.mp4".to_string(), + ..batch_spec(2, 0, 2, 2, PixelFormat::F32 as i32) + }; + let err = s.render_ticket_to_slot(&spec).unwrap_err(); + assert!(err.starts_with("footage decode: "), "{err}"); + } + + /// The audio batch path's acquire failure: a shutdown session returns + /// "no free shm slot" before mixing anything. + #[test] + fn render_audio_ticket_to_slot_reports_shutdown() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(1, 64, false); + assert!(s.handle_line(&hs.to_string()).is_some()); + s.shutdown_requested = true; + let spec = AudioTicketSpec { + ticket: 5, + slot: 0, + time_num: 0, + time_den: 1, + duration_num: 1, + duration_den: 48000, + sample_rate: 48000, + channel_layout: 0x3, + channels: 2, + montage: Vec::new(), + }; + assert_eq!( + s.render_audio_ticket_to_slot(&spec).unwrap_err(), + "no free shm slot (shutdown or timeout)" + ); + } }