From 5bbb5214f4917d8af7c369a85af2c58c2444e19d Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Fri, 9 Oct 2026 17:39:11 +0200 Subject: [PATCH] LR-BEHAV-BG-TASKS: demo scenes: a rayon worker renders a scene alone, never in parallel (deadlock) Demo scene pixels were generated inside `OnceLock::get_or_init` on the caller's thread, and the render runs on rayon. A rayon worker that waits for rows another worker took runs queued jobs on top of itself; when one of those asked for the same scene, it waited in `get_or_init` on the generation further down its own stack, and every other request for that scene waited behind it, forever. The faces scan's pool threads hit it (two tests_face_recognize tests sat in `Once::wait` for 30 minutes); any rayon worker loading demo photos could. Each (scene, size) slot now has one generation at a time: the first caller renders, with no lock held, and later callers wait on a condvar for that generation's outcome. The render never needs a thread that waits: - a rayon worker (the faces scan, merges) renders on its own thread alone, without rayon (`Scene::render_fit_serial`, the same pixels), so it never runs other jobs mid-render, and the work stays within its pool and that pool's limits (scan pace, LIGHTCRAFT_FACE_THREADS); - other callers (the render workers, plain threads) render in parallel on a pool of rayon's default size (RAYON_NUM_THREADS, else the available parallelism), whose threads only render; where it can't start, they render alone too (no rayon, so no deadlock). Generating a scene never touches the global rayon pool (asking its size would start it, which panics where its threads can't start). The scan's first render of a demo scene now takes 1 to 2.3 s on one thread instead of 0.5 to 1.3 s on its light pace's two (M3 Max, 2560 px preview source); interactive loads are unchanged. A generation number gives each waiter the outcome of the generation it waited for, even when a retry starts first. A render that panics is an error for its callers (`scene_pixels` returns a Result) and the next request tries again. No duplicates, no unbounded threads. Tests (each waits with a timeout, so a deadlock fails instead of hanging): rayon workers keep asking a 4-thread pool for a scene while it is generated (24 replies counted, at least 4 overlapping; times out at 120 s on the OnceLock code); a scene is rendered once however many ask (the first version of this fix rendered 5 copies at once); a rayon worker renders alone on its own thread; callers wait for the generation running; a failed generation is an error and the next request retries; a retry never changes the outcome waited for (state-level, and a racing retrier: 60 of 60 waiters got the retry's pixels on the previous version); in a process whose global pool failed to start, a scene is still generated; the serial render is the parallel one, pixel for pixel. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/demo.rs | 480 +++++++++++++++++++++++++++++++++++-- crates/engine/src/media.rs | 2 +- crates/scenes/src/lib.rs | 26 +- crates/scenes/src/paint.rs | 24 +- 4 files changed, 510 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 48da21e27..6a89dda88 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -101,14 +101,15 @@ pub fn load(s: &mut Session) { /// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI /// VM). Each scene at each size is therefore generated once per process and shared: /// -/// - concurrent requests wait for one generation (Before and After at 1:1 both load the -/// original; sessions side by side, e.g. parallel tests, open the same photo); +/// - one generation per scene and size, which concurrent requests wait for (Before and After at +/// 1:1 both load the original; sessions side by side, e.g. parallel tests, open the same photo; +/// how it renders: [`generate`]); /// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same /// allocations the sessions' source caches hold, so they cost extra memory only once no session /// holds them any more. /// /// Only demo photos use this; real files are decoded by their loader as before. -pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc { +pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Result, String> { let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge }; let slot = { let mut c = scene_cache(); @@ -120,18 +121,152 @@ pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> e.slot.clone() } None => { - let slot = Arc::new(std::sync::OnceLock::new()); + let slot = Arc::new(SceneSlot::default()); c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick }); slot } } }; - // (no lock held while generating: other scenes and sizes are served meanwhile) - let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone(); + let pixels = slot.get_or_generate(|how| match how { + Render::Parallel => scene.render_fit(max_edge), + Render::Serial => scene.render_fit_serial(max_edge), + }); trim_scene_cache(&mut scene_cache(), scene_cache_limit()); pixels } +/// How a generation renders (see [`generate`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Render { + /// On rayon threads that do nothing else. + Parallel, + /// On the calling thread alone, without rayon. + Serial, +} + +const FAILED: &str = "the demo scene could not be generated"; + +/// Render a scene for [`SceneSlot::get_or_generate`], so that it never needs a thread that waits +/// for a generation. A rayon worker renders on its own thread alone: rendering in parallel, it +/// would run queued jobs while it waits for rows other workers took, and one of them could ask for +/// this very scene and wait for the generation further down its own stack (the faces scan's pool +/// threads hung that way). That also keeps the work within the caller's pool and its limits (the +/// scan's pace, `LIGHTCRAFT_FACE_THREADS`). Any other caller (the render workers, which are plain +/// threads) renders in parallel on [`render_pool`], whose threads only ever render, and blocks +/// without running other work meanwhile; where that pool can't be started it renders alone too. +fn generate(render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> lightcraft_raster::Rgb32f { + if rayon::current_thread_index().is_some() { + return render(Render::Serial); + } + match render_pool() { + Some(pool) => pool.install(|| render(Render::Parallel)), + None => render(Render::Serial), + } +} + +/// The threads non-rayon callers' generations render on: rayon's default count (`RAYON_NUM_THREADS`, +/// else the available parallelism), like the global pool they used before, without touching that +/// pool (asking it for its size starts it, which panics where its threads can't start); `None` +/// where this pool's threads can't start. +fn render_pool() -> Option<&'static rayon::ThreadPool> { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + POOL.get_or_init(|| rayon::ThreadPoolBuilder::new().thread_name(|i| format!("lc-demo-scene-{i}")).build().ok()).as_ref() +} + +/// One scene at one size: generated once, then shared. +#[derive(Default)] +struct SceneSlot { + state: std::sync::Mutex, + /// Signalled when a generation ends. + ended: std::sync::Condvar, +} + +#[derive(Default)] +struct SlotState { + generation: Generation, + /// Generations started so far; the one running or last ended is number `started`. + started: u64, + /// Callers waiting for the generation running (for the tests). + waiting: usize, +} + +#[derive(Default)] +enum Generation { + #[default] + Empty, + Running, + Ready(Arc), + /// The last generation failed (its render panicked); the next request tries again. + Failed, +} + +impl SlotState { + /// Start a generation; its number. + fn start(&mut self) -> u64 { + self.started += 1; + self.generation = Generation::Running; + self.started + } + + /// What came of generation `n`, once it has ended: its pixels, or an error when it failed + /// (even if a retry has started or finished since: callers get the outcome they waited for). + fn outcome(&self, n: u64) -> Option, String>> { + match &self.generation { + Generation::Running if self.started == n => None, + // pixels are kept for good once made, so only generation `n` itself can have made them + Generation::Ready(p) if self.started == n => Some(Ok(p.clone())), + _ => Some(Err(FAILED.into())), + } + } +} + +impl SceneSlot { + fn lock(&self) -> std::sync::MutexGuard<'_, SlotState> { + self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn ready(&self) -> Option> { + match &self.lock().generation { + Generation::Ready(p) => Some(p.clone()), + _ => None, + } + } + + /// The pixels `render` makes ([`generate`]), generated once: the first caller renders, with no + /// lock held, and later ones wait for its outcome. A render that panics is an error for the + /// callers of its generation, and the next request tries again. + fn get_or_generate(&self, render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> Result, String> { + let mut g = self.lock(); + let n = match &g.generation { + Generation::Ready(p) => return Ok(p.clone()), + Generation::Running => { + let n = g.started; + g.waiting += 1; + let outcome = loop { + if let Some(o) = g.outcome(n) { + break o; + } + g = self.ended.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner); + }; + g.waiting -= 1; + return outcome; + } + Generation::Empty | Generation::Failed => g.start(), + }; + drop(g); + let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| generate(render))); + let mut g = self.lock(); + g.generation = match made { + Ok(pixels) => Generation::Ready(Arc::new(pixels)), + Err(_) => Generation::Failed, + }; + let outcome = g.outcome(n).unwrap_or_else(|| Err(FAILED.into())); + drop(g); + self.ended.notify_all(); + outcome + } +} + /// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough /// for one 24 MP original and the previews around it). fn scene_cache_limit() -> usize { @@ -147,11 +282,9 @@ struct SceneKey { max_edge: usize, } -type SceneSlot = Arc>>; - struct SceneEntry { key: SceneKey, - slot: SceneSlot, + slot: Arc, used: u64, } @@ -167,7 +300,7 @@ fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> { } fn scene_bytes(slot: &SceneSlot) -> usize { - slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>())) + slot.ready().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>())) } /// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still @@ -178,7 +311,7 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) { if total <= limit { return; } - let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else { + let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.ready().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else { return; }; c.entries.swap_remove(i); @@ -196,12 +329,12 @@ mod tests { #[test] fn a_scene_is_generated_once_per_size_and_shared() { let s = small_scene(); - let a = scene_pixels(&s, 48); - let b = scene_pixels(&s, 48); + let a = scene_pixels(&s, 48).unwrap(); + let b = scene_pixels(&s, 48).unwrap(); assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation"); assert_eq!(a.width.max(a.height), 48); assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly"); - let c = scene_pixels(&s, 40); + let c = scene_pixels(&s, 40).unwrap(); assert_eq!(c.width.max(c.height), 40, "another size is its own generation"); } @@ -210,19 +343,332 @@ mod tests { let s = small_scene(); let edge = 37; // a size no other test asks for let got: Vec<_> = std::thread::scope(|sc| { - let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect(); + let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect(); hs.into_iter().map(|h| h.join().unwrap()).collect() }); assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); } + /// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it. + fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked") + } + + /// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation. + fn until_waiting(slot: &SceneSlot, n: usize) { + let started = std::time::Instant::now(); + while slot.lock().waiting < n { + assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + fn pixels(n: usize) -> lightcraft_raster::Rgb32f { + lightcraft_raster::Rgb32f::new(n, 1) + } + + /// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker + /// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the + /// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests + /// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one + /// of them must come back, at least four of them must have overlapped the generation, and the test waits with a + /// timeout, so a deadlock fails it instead of hanging. + #[test] + fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() { + let scene = Arc::new(small_scene()); + let edge = 601; // a size no other test asks for + const ASKED: usize = 24; + let (overlap, sizes) = within(120, move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + for _ in 0..ASKED { + let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone()); + issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + pool.spawn(move || { + let got = scene_pixels(&scene, edge).unwrap(); + // how many requests had been made when this one came back + let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len())); + }); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect(); + (replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::>()) + }); + assert_eq!(sizes.len(), ASKED, "every request came back"); + assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0)); + assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)"); + } + + /// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered + /// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original). + #[test] + fn a_scene_is_rendered_once_however_many_ask() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (renders, peak) = within(60, || { + let slot = Arc::new(SceneSlot::default()); + let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let render = { + let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone()); + move |how: Render| { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + renders.fetch_add(1, Ordering::SeqCst); + // work like the real render's: in parallel, the rendering thread waits for rows other threads took + use rayon::prelude::*; + let sum: u64 = match how { + Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(), + Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(), + }; + std::thread::sleep(std::time::Duration::from_millis(100)); + running.fetch_sub(1, Ordering::SeqCst); + pixels(sum as usize % 5 + 1) + } + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + for _ in 0..16 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + pool.spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + for _ in 0..4 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + std::thread::spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect(); + assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); + (renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst)) + }); + assert_eq!((renders, peak), (1, 1)); + } + + /// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool + /// and that pool's limits; other threads render in parallel on the scene pool. + #[test] + fn a_rayon_worker_renders_alone_on_its_own_thread() { + let ((how, caller, renderer), (other_how, other_name)) = within(30, || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let slot = SceneSlot::default(); + let mut seen = None; + pool.install(|| { + let caller = std::thread::current().id(); + slot.get_or_generate(|how| { + seen = Some((how, caller, std::thread::current().id())); + pixels(1) + }) + .unwrap(); + }); + let other = SceneSlot::default(); + let mut other_seen = None; + other + .get_or_generate(|how| { + other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string())); + pixels(1) + }) + .unwrap(); + (seen.unwrap(), other_seen.unwrap()) + }); + assert_eq!((how, renderer), (Render::Serial, caller)); + assert_eq!(other_how, Render::Parallel); + assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}"); + } + + /// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs + /// minutes of CPU time on a small machine). + #[test] + fn callers_wait_for_the_generation_running() { + let (a, b, renders) = within(30, || { + let slot = Arc::new(SceneSlot::default()); + let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx))); + let ask = |slot: &Arc| { + let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone()); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _ = started_tx.lock().unwrap().send(()); + let _ = go_rx.lock().unwrap().recv(); + pixels(1) + }) + }) + }; + let first = ask(&slot); + started.recv().unwrap(); + let second = ask(&slot); + until_waiting(&slot, 1); + go.send(()).unwrap(); + let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap()); + (a, b, renders.load(std::sync::atomic::Ordering::SeqCst)) + }); + assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared"); + assert_eq!(renders, 1); + } + + /// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request + /// generates the scene again. + #[test] + fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { + let (first, waiters, again) = within(30, || { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..2) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 2); + drop(go); + let first = failing.join().unwrap(); + let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect(); + (first, waiters, slot.get_or_generate(|_| pixels(3))) + }); + assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error"); + assert_eq!(again.unwrap().width, 3); + } + + /// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they + /// look: a failed generation followed by a retry is still an error for them. + #[test] + fn a_retry_never_changes_the_outcome_waited_for() { + let mut st = SlotState::default(); + let n = st.start(); + assert!(st.outcome(n).is_none(), "running"); + st.generation = Generation::Failed; + let m = st.start(); + assert!(matches!(st.outcome(n), Some(Err(_)))); + assert!(st.outcome(m).is_none()); + st.generation = Generation::Ready(Arc::new(pixels(1))); + assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); + assert!(matches!(st.outcome(m), Some(Ok(_)))); + // and with threads: a retry racing the waiters of a failed generation never hands them its result + let wrong = within(60, || { + let mut wrong = 0; + for _ in 0..20 { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..3) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 3); + let retry = { + let slot = slot.clone(); + std::thread::spawn(move || { + loop { + if matches!(slot.lock().generation, Generation::Failed) { + return slot.get_or_generate(|_| pixels(4)); + } + std::hint::spin_loop(); + } + }) + }; + drop(go); + let _ = failing.join().unwrap(); + wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count(); + assert!(retry.join().unwrap().is_ok()); + } + wrong + }); + assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels"); + } + + /// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it + /// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's. + #[test] + fn scenes_are_generated_when_the_global_pool_is_broken() { + use std::io::Read; + use std::process::Stdio; + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"]) + .env("LC_DEMO_BROKEN_GLOBAL_POOL", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Drain both pipes on threads so a chatty child can't block on a full pipe. + let drain = |mut r: Box| { + std::thread::spawn(move || { + let mut s = String::new(); + let _ = r.read_to_string(&mut s); + s + }) + }; + let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap()))); + // A deadlocked child fails the test instead of hanging CI. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("the child process did not finish within 120 s"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap()); + assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}"); + } + + #[test] + #[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"] + fn with_a_broken_global_pool() { + if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() { + return; + } + let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global(); + assert!(broken.is_err()); + assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken"); + let s = small_scene(); + let got = scene_pixels(&s, 33).unwrap(); + assert_eq!(got.data, s.render_fit_serial(33).data); + } + #[test] fn the_cache_keeps_the_most_recent_scenes_within_its_limit() { let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge }; let entry = |max_edge, used, px: Option| { - let slot: SceneSlot = Arc::new(std::sync::OnceLock::new()); + let slot = Arc::new(SceneSlot::default()); if let Some(n) = px { - let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); + slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); } SceneEntry { key: key(max_edge), slot, used } }; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1231221b9..5e82987b7 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -251,7 +251,7 @@ impl SourceRef { pub fn load_source(&self) -> Result { let image = match self { SourceRef::Loaded(a) => return Ok((**a).clone()), - SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge), + SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, SourceRef::File { path, max_edge, loader, fallback, denoise } => { let plain = || match loader { Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), diff --git a/crates/scenes/src/lib.rs b/crates/scenes/src/lib.rs index 0b7ad0163..1004b2ea9 100644 --- a/crates/scenes/src/lib.rs +++ b/crates/scenes/src/lib.rs @@ -70,9 +70,20 @@ impl Scene { /// Render fitting within `max_edge` on the long side. pub fn render_fit(&self, max_edge: usize) -> Rgb32f { + let (w, h) = self.fit(max_edge); + self.render(w, h) + } + + /// [`Scene::render_fit`] on the calling thread alone, without rayon (the same pixels). + pub fn render_fit_serial(&self, max_edge: usize) -> Rgb32f { + let (w, h) = self.fit(max_edge); + paint::render_serial(self.kind, self.seed, w, h) + } + + fn fit(&self, max_edge: usize) -> (usize, usize) { let a = self.aspect(); let (w, h) = if a >= 1.0 { (max_edge, (max_edge as f32 / a).round() as usize) } else { ((max_edge as f32 * a).round() as usize, max_edge) }; - self.render(w.max(1), h.max(1)) + (w.max(1), h.max(1)) } } @@ -203,6 +214,19 @@ mod tests { assert!(max > 2.0, "{max}"); } + /// The serial render is the parallel one, pixel for pixel (both with and without supersampling), and + /// an empty size is an empty image. + #[test] + fn the_serial_render_is_the_same_picture() { + for s in demo_library().iter().take(3) { + for edge in [40, 700] { + assert_eq!(s.render_fit_serial(edge), s.render_fit(edge), "{} at {edge}", s.name); + } + } + assert!(paint::render_serial(Kind::Dunes, 1, 0, 5).data.is_empty()); + assert!(demo_library()[0].render(0, 0).data.is_empty()); + } + #[test] fn resolution_independent() { let s = &demo_library()[1]; diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs index 5cfd61e3a..4f3c4560b 100644 --- a/crates/scenes/src/paint.rs +++ b/crates/scenes/src/paint.rs @@ -197,12 +197,25 @@ fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> } pub fn render(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { + render_rows(kind, seed, w, h, true) +} + +/// [`render`] on the calling thread alone, without rayon: the same pixels, for a caller that must +/// not run other queued work while it waits for rows (see `lightcraft_engine::demo`). +pub fn render_serial(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { + render_rows(kind, seed, w, h, false) +} + +fn render_rows(kind: Kind, seed: u32, w: usize, h: usize, parallel: bool) -> Rgb32f { + let mut img = Rgb32f::new(w, h); + if w == 0 || h == 0 { + return img; + } let aspect = w as f32 / h as f32; let p = P { seed, aspect }; let ss = if w * h <= 300_000 { 2 } else { 1 }; let to2020 = SRGB.to_space(&REC2020).to_f32(); - let mut img = Rgb32f::new(w, h); - img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let paint = |(y, row): (usize, &mut [[f32; 3]])| { for (xi, px) in row.iter_mut().enumerate() { let mut acc = [0.0f32; 3]; for sy in 0..ss { @@ -220,7 +233,12 @@ pub fn render(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { (m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]).max(0.0), ]; } - }); + }; + if parallel { + img.data.par_chunks_mut(w).enumerate().for_each(paint); + } else { + img.data.chunks_mut(w).enumerate().for_each(paint); + } img }