//! Cached viewer diagnostics. Native scans run on one persistent worker, never while painting. //! Each viewer keeps only its newest request; obsolete scans stop between properties. On the //! single-threaded web build, one shared scan runs after interaction settles (no thread fallback). use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::sync::{Mutex, PoisonError}; use std::time::Duration; use effectcraft_engine::Session; use effectcraft_engine::commands::expr_tools::{self, ExprError}; use effectcraft_engine::project::{ItemId, Project}; use effectcraft_engine::render::{EvalCtx, ExprHost, FootageSource}; use effectcraft_engine::time::Tick; use web_time::Instant; const QUIET: Duration = Duration::from_millis(150); const MAX_VIEWERS: usize = 8; type Errors = Arc<[ExprError]>; type SyntaxCheck = fn(&str) -> Result<(), String>; #[derive(Clone)] struct Snapshot { project: Arc, revision: u64, comp: ItemId, time: Tick, expr: Option>, footage: Arc, check: Option, } impl Snapshot { fn new(s: &Session, comp: ItemId) -> Self { Self { project: s.project.clone(), revision: s.revision, comp, time: s.time_of(comp), expr: s.expr.clone(), footage: s.footage.clone(), check: s.expr_check, } } fn matches(&self, s: &Session, comp: ItemId) -> bool { self.revision == s.revision && self.comp == comp && self.time == s.time_of(comp) && Arc::ptr_eq(&self.project, &s.project) } } struct Request { snapshot: Snapshot, cancelled: Arc, #[cfg(not(target_arch = "wasm32"))] repaint: egui::Context, } struct Completed { #[cfg(not(target_arch = "wasm32"))] comp: ItemId, #[cfg(not(target_arch = "wasm32"))] cancelled: Arc, result: Result>, String>, } impl Request { fn run(&self) -> Completed { let s = &self.snapshot; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let comp = s.project.comp(s.comp)?; let ctx = EvalCtx { expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()), ..EvalCtx::new(&s.project, s.comp, comp, s.time) }; expr_tools::errors_in(&ctx, s.check, || !self.cancelled.load(Ordering::Relaxed)) })) .map_err(|_| "Expression diagnostics stopped unexpectedly; edit an expression to retry.".to_string()); Completed { #[cfg(not(target_arch = "wasm32"))] comp: s.comp, #[cfg(not(target_arch = "wasm32"))] cancelled: self.cancelled.clone(), result, } } } struct Entry { snapshot: Snapshot, changed: Instant, cancelled: Arc, submitted: bool, errors: Errors, } impl Drop for Entry { fn drop(&mut self) { self.cancelled.store(true, Ordering::Relaxed); } } #[derive(Default)] pub(crate) struct Diagnostics { entries: VecDeque, #[cfg(not(target_arch = "wasm32"))] worker: Option, failure: Option, } impl Diagnostics { pub fn clear(&mut self) { self.entries.clear(); } pub fn take_failure(&mut self) -> Option { self.failure.take() } /// Paint from completed results only. Changes discard stale results immediately, including /// errors whose layer/property no longer exists. Pointer interaction and playback defer scans. pub fn errors(&mut self, s: &Session, ctx: &egui::Context, comp: ItemId, interacting: bool) -> Errors { self.update(s, ctx, comp, interacting, Instant::now()) } fn update(&mut self, s: &Session, ctx: &egui::Context, comp: ItemId, interacting: bool, now: Instant) -> Errors { if !s.prefs.scripting.error_banner || s.expr.is_none() { self.clear(); return Arc::from([]); } let mut entry = self.entries.iter().position(|e| e.snapshot.comp == comp).and_then(|i| self.entries.remove(i)); if entry.as_ref().is_none_or(|e| !e.snapshot.matches(s, comp)) { entry = Some(Entry { snapshot: Snapshot::new(s, comp), changed: now, cancelled: Arc::new(AtomicBool::new(false)), submitted: false, errors: Arc::from([]), }); } let Some(mut entry) = entry else { return Arc::from([]) }; if interacting { entry.changed = now; } #[cfg(not(target_arch = "wasm32"))] if let Some(worker) = &self.worker { let done: Vec<_> = worker.shared.completed.lock().unwrap_or_else(PoisonError::into_inner).drain(..).collect(); for done in done { let target = if done.comp == comp { Some(&mut entry) } else { self.entries.iter_mut().find(|e| e.snapshot.comp == done.comp) }; if let Some(target) = target && Arc::ptr_eq(&target.cancelled, &done.cancelled) && !done.cancelled.load(Ordering::Relaxed) { match done.result { Ok(Some(errors)) => target.errors = errors.into(), Ok(None) => {} Err(e) => self.failure = Some(e), } } } } if !entry.submitted && !interacting { let quiet = now.saturating_duration_since(entry.changed); if quiet < QUIET { ctx.request_repaint_after(QUIET - quiet); } else { entry.submitted = true; let request = Request { snapshot: entry.snapshot.clone(), cancelled: entry.cancelled.clone(), #[cfg(not(target_arch = "wasm32"))] repaint: ctx.clone(), }; #[cfg(not(target_arch = "wasm32"))] { if self.worker.is_none() { match Worker::new() { Ok(worker) => self.worker = Some(worker), Err(e) => self.failure = Some(format!("Could not start expression diagnostics: {e}")), } } if let Some(worker) = &self.worker { worker.submit(request); } } // wasm32 cannot spawn Rust threads. Preserve diagnostics there, but eliminate // the duplicate scan and defer it until scrubbing/dragging/playback stops. #[cfg(target_arch = "wasm32")] match request.run().result { Ok(Some(errors)) => entry.errors = errors.into(), Ok(None) => {} Err(e) => self.failure = Some(e), } } } let result = entry.errors.clone(); self.entries.push_back(entry); if self.entries.len() > MAX_VIEWERS { self.entries.pop_front(); } result } } #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] struct Shared { pending: Mutex>, completed: Mutex>, wake: std::sync::Condvar, stopped: AtomicBool, } #[cfg(not(target_arch = "wasm32"))] struct Worker { shared: Arc, } #[cfg(not(target_arch = "wasm32"))] impl Worker { fn new() -> std::io::Result { let shared = Arc::new(Shared::default()); let work = shared.clone(); std::thread::Builder::new().name("ec-expression-diagnostics".into()).spawn(move || { loop { let request = { let mut pending = work.pending.lock().unwrap_or_else(PoisonError::into_inner); while pending.is_empty() && !work.stopped.load(Ordering::Relaxed) { pending = work.wake.wait(pending).unwrap_or_else(PoisonError::into_inner); } if work.stopped.load(Ordering::Relaxed) { return; } pending.pop_front() }; let Some(request) = request else { continue }; if request.cancelled.load(Ordering::Relaxed) { continue; } let result = request.run(); if !result.cancelled.load(Ordering::Relaxed) { let mut completed = work.completed.lock().unwrap_or_else(PoisonError::into_inner); completed.retain(|r| r.comp != result.comp && !r.cancelled.load(Ordering::Relaxed)); if completed.len() >= MAX_VIEWERS { completed.pop_front(); } completed.push_back(result); } request.repaint.request_repaint(); } })?; Ok(Self { shared }) } fn submit(&self, request: Request) { let mut pending = self.shared.pending.lock().unwrap_or_else(PoisonError::into_inner); pending.retain(|r| r.snapshot.comp != request.snapshot.comp && !r.cancelled.load(Ordering::Relaxed)); if pending.len() >= MAX_VIEWERS { pending.pop_front(); } pending.push_back(request); self.shared.wake.notify_one(); } } #[cfg(not(target_arch = "wasm32"))] impl Drop for Worker { fn drop(&mut self) { // Hold the predicate lock when waking, so shutdown cannot race with entering wait. let mut pending = self.shared.pending.lock().unwrap_or_else(PoisonError::into_inner); self.shared.stopped.store(true, Ordering::Relaxed); pending.clear(); self.shared.wake.notify_one(); // Do not join an in-flight expression on the UI thread. Entries cancel it on drop. } } #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests { use super::*; use effectcraft_engine::keyframe::Value; use effectcraft_engine::project::{Layer, Property}; use serde_json::json; use std::sync::atomic::AtomicUsize; use std::sync::mpsc; struct Host { main: std::thread::ThreadId, on_ui: AtomicUsize, calls: AtomicUsize, entered: mpsc::Sender<()>, gate: (Mutex, std::sync::Condvar), fail: bool, } impl ExprHost for Host { fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result { self.calls.fetch_add(1, Ordering::Relaxed); if std::thread::current().id() == self.main { self.on_ui.fetch_add(1, Ordering::Relaxed); return Err("diagnostics evaluated on UI thread".into()); } let _ = self.entered.send(()); let (lock, wake) = &self.gate; let guard = lock.lock().unwrap(); let (_guard, timed) = wake.wait_timeout_while(guard, Duration::from_secs(5), |open| !*open).unwrap(); assert!(!timed.timed_out(), "test did not release the worker"); if self.fail { Err(format!("failure at {}", ctx.time.0)) } else { Ok(value.clone()) } } } impl Host { fn release(&self) { *self.gate.0.lock().unwrap() = true; self.gate.1.notify_all(); } } fn fixture(fail: bool, blocked: bool) -> (Session, Arc, mpsc::Receiver<()>) { let (tx, rx) = mpsc::channel(); let host = Arc::new(Host { main: std::thread::current().id(), on_ui: AtomicUsize::new(0), calls: AtomicUsize::new(0), entered: tx, gate: (Mutex::new(!blocked), std::sync::Condvar::new()), fail, }); let mut s = Session { expr: Some(host.clone()), ..Session::default() }; s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap(); let hidden = s.execute("layer.newSolid", json!({"name":"Hidden expression"})).unwrap()["layer"].as_u64().unwrap(); s.execute("prop.setExpression", json!({"layer":hidden,"path":"transform/opacity","expression":"value"})).unwrap(); let visible = s.execute("layer.newSolid", json!({"name":"Solo plate"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.setSwitch", json!({"layers":[visible],"switch":"solo","value":true})).unwrap(); s.execute("layer.select", json!({"layers":[visible]})).unwrap(); (s, host, rx) } fn wait_done(d: &Diagnostics) { let until = Instant::now() + Duration::from_secs(3); loop { if d.worker.as_ref().is_some_and(|w| !w.shared.completed.lock().unwrap().is_empty()) { return; } assert!(Instant::now() < until, "diagnostics did not finish"); std::thread::sleep(Duration::from_millis(1)); } } fn start(d: &mut Diagnostics, s: &Session, ctx: &egui::Context, now: Instant) { let cid = s.active_comp_id().unwrap(); d.update(s, ctx, cid, false, now); d.update(s, ctx, cid, false, now + QUIET); } #[test] fn successful_hidden_expressions_run_once_off_the_ui_thread() { let (s, host, _) = fixture(false, false); let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); let cid = s.active_comp_id().unwrap(); start(&mut d, &s, &ctx, now); wait_done(&d); for _ in 0..5 { assert!(d.update(&s, &ctx, cid, false, now + QUIET).is_empty()); } assert_eq!(host.calls.load(Ordering::Relaxed), 1); assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); } #[test] fn newest_request_replaces_queued_times_and_discards_stale_errors() { let (mut s, host, entered) = fixture(true, true); let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); let cid = s.active_comp_id().unwrap(); start(&mut d, &s, &ctx, now); entered.recv_timeout(Duration::from_secs(3)).unwrap(); for i in 1..20 { s.set_time(Tick::from_seconds_f64(i as f64 / 30.)); start(&mut d, &s, &ctx, now + Duration::from_secs(i)); assert!(d.entries.back().unwrap().errors.is_empty()); } assert_eq!(d.worker.as_ref().unwrap().shared.pending.lock().unwrap().len(), 1); host.release(); wait_done(&d); let errors = d.update(&s, &ctx, cid, false, now + Duration::from_secs(30)); assert_eq!(errors.len(), 1); assert_eq!(errors[0].message, format!("failure at {}", s.time().0)); assert_eq!(host.calls.load(Ordering::Relaxed), 2); assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); } #[test] fn warning_preference_and_interaction_prevent_scanning_and_clear_stale_results() { let (mut s, host, _) = fixture(true, false); let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); let cid = s.active_comp_id().unwrap(); for i in 0..10 { d.update(&s, &ctx, cid, true, now + Duration::from_secs(i)); } assert!(d.worker.is_none()); s.prefs.scripting.error_banner = false; start(&mut d, &s, &ctx, now + Duration::from_secs(10)); assert!(d.worker.is_none()); assert_eq!(host.calls.load(Ordering::Relaxed), 0); s.prefs.scripting.error_banner = true; start(&mut d, &s, &ctx, now + Duration::from_secs(11)); wait_done(&d); assert_eq!(d.update(&s, &ctx, cid, false, now + Duration::from_secs(12)).len(), 1); s.prefs.scripting.error_banner = false; assert!(d.update(&s, &ctx, cid, false, now + Duration::from_secs(13)).is_empty()); assert!(d.entries.is_empty()); } #[test] fn replacement_project_with_reused_ids_does_not_reuse_errors() { let (s, _, _) = fixture(true, false); let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); let cid = s.active_comp_id().unwrap(); start(&mut d, &s, &ctx, now); wait_done(&d); assert_eq!(d.update(&s, &ctx, cid, false, now + QUIET).len(), 1); let (replacement, _, _) = fixture(false, false); assert_eq!(s.revision, replacement.revision); assert!(d.update(&replacement, &ctx, cid, false, now + QUIET).is_empty()); } #[test] fn scans_keep_disabled_syntax_errors_and_non_solo_runtime_errors() { let (mut s, _, _) = fixture(true, false); let cid = s.active_comp_id().unwrap(); let selected = s.state.selected_layers[0]; s.expr_check = Some(|_| Err("invalid syntax".into())); s.execute("prop.setExpression", json!({"layer":selected.0,"path":"transform/rotation","expression":"bad("})).unwrap(); let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); start(&mut d, &s, &ctx, now); wait_done(&d); let errors = d.update(&s, &ctx, cid, false, now + QUIET); assert_eq!(errors.len(), 2); assert!(errors.iter().any(|e| e.disabled && e.message == "invalid syntax")); assert!(errors.iter().any(|e| !e.disabled && e.layer_name == "Hidden expression")); } #[test] fn viewer_edits_do_not_wait_for_hidden_diagnostics_with_or_without_warnings() { use crate::{EffectcraftApp, bench::Driver}; let (s, host, entered) = fixture(false, true); let mut d = Driver::new(EffectcraftApp::new(s), egui::vec2(1680., 1020.)); for _ in 0..3 { d.step(); } let now = Instant::now() + Duration::from_secs(1); start(&mut d.app.expression_diagnostics, &d.app.session, &d.ctx, now); entered.recv_timeout(Duration::from_secs(3)).unwrap(); let layer = d.app.session.state.selected_layers[0]; d.app.session.state.snapping = false; let handle = format!("viewer.handle.{}.0", layer.0); let before = d.app.auto.find(&handle).unwrap().rect; let comp_rect = d.app.auto.find("viewer.comp").unwrap().rect; let from = egui::pos2(comp_rect[0] + comp_rect[2] * 0.4, comp_rect[1] + comp_rect[3] * 0.4); let to = from + egui::vec2(40., 20.); d.step_with(vec![ egui::Event::PointerMoved(from), egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, ]); d.step_with(vec![egui::Event::PointerMoved(to)]); d.step_with(vec![egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); d.step(); let after = d.app.auto.find(&handle).unwrap().rect; assert!(after[0] > before[0] + 30., "selection bounds did not follow the drag: {before:?} -> {after:?}"); for warnings in [true, false] { d.app.session.prefs.scripting.error_banner = warnings; for i in 0..4 { d.app.session.set_time(Tick::from_seconds_f64(i as f64 / 30.)); d.app.session.execute("layer.setTransform", json!({"layer":layer.0,"prop":"position","value":[170.+i as f64,90.,0.]})).unwrap(); d.app.session.execute("layer.setSwitch", json!({"layers":[layer.0],"switch":"video","value":i%2==0})).unwrap(); d.step(); } } // The diagnostic expression is still blocked on the worker, but every UI frame above // finished. This assertion also catches reintroducing a second, synchronous banner. assert!(!*host.gate.0.lock().unwrap()); assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); host.release(); } }