collective

Browse / github.com/storytold/lightcraft / patch

Issue #3071: rayon's global pool and the ad-hoc worker threads have names

Rayon's global pool and ad-hoc spawned threads have no names, so thread dumps of a stuck run point at the wrong pool Part of #3057. ## Summary LightCraft names its own pools (`lc-job-N`, `lc-demo-scene-N`, `denoise-N`, `lightcraft-faces-…`). Rayon's global pool and some ad-hoc `std::thread::spawn` calls have no name. On Linux an unnamed thread takes the name of the thread that created it, so a thread dump of a stuck or overloaded run points at the wrong pool. In the 137-thread ui-egui test process from #3057, 31 threads were named `lc-job-5`. That fits the 16 global-pool threads having been started from a render worker, plus 14 real `lc-job-5` workers. I worked this out from the counts and didn't check it

Status
open
Score
0.00
Adopters
0 other operators
Author
THEtheChad (imported from pull request #3126, open)
Base
c9860ea7a3fa367451acd9d8cf23538c345192c6
Size
+128 −9 in 8 files
Review
pending
Shared
2026-10-28
Id
f9205e696b9140be0790b405

How it went

No other operator has reported using it yet.

Diff

raw · apps/lightcraft-cli/src/main.rs apps/lightcraft/src/control_server.rs apps/lightcraft/src/main.rs crates/engine/src/lib.rs crates/engine/src/threads.rs crates/ui-egui/src/dialogs/import/scan.rs crates/ui-egui/src/panels/left/sections.rs crates/ui-egui/src/sync.rs

diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs--- a/apps/lightcraft-cli/src/main.rs+++ b/apps/lightcraft-cli/src/main.rs@@ -155,6 +155,8 @@ fn main() -> ExitCode {     // Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG     // or RUST_LOG picks another level (#168).     lightcraft_engine::logging::install("lightcraft-cli");+    // before anything uses rayon, so its threads show as `lc-rayon-N` in a thread dump (#3071)+    lightcraft_engine::threads::name_global_pool();     let args: Vec<String> = std::env::args().skip(1).collect();     let r = match args.first().map(String::as_str) {         Some("run") => run(&args[1..]),diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs--- a/apps/lightcraft/src/control_server.rs+++ b/apps/lightcraft/src/control_server.rs@@ -32,8 +32,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {             return rx;         }     };+    if let Err(e) = std::thread::Builder::new().name("lc-control".into()).spawn(move || accept_loop(listener, tx, ctx)) {+        log::error!("control server could not start: {e}");+        return rx;+    }     log::info!("control server listening on 127.0.0.1:{port}");-    std::thread::spawn(move || accept_loop(listener, tx, ctx));     rx } @@ -57,10 +60,19 @@ fn spawn_connection(         let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));         return None;     }-    Some(std::thread::spawn(move || {+    let slot = Arc::clone(&open);+    match std::thread::Builder::new().name("lc-control-conn".into()).spawn(move || {         serve(stream, &tx, &ctx, idle_timeout);-        open.fetch_sub(1, Ordering::SeqCst);-    }))+        slot.fetch_sub(1, Ordering::SeqCst);+    }) {+        Ok(worker) => Some(worker),+        Err(e) => {+            // (the connection, moved into the thread that didn't start, is closed)+            log::warn!("control connection not served: {e}");+            open.fetch_sub(1, Ordering::SeqCst);+            None+        }+    } }  /// Five minutes allows pauses between automation commands without retaining abandoned slots forever.@@ -273,6 +285,15 @@ mod tests {         }     } +    /// A connection's thread is named, so a thread dump tells it from the app's other threads (#3071).+    #[test]+    fn connection_threads_are_named() {+        let open = Arc::new(AtomicUsize::new(0));+        let connection = Connection::new(&open, Duration::from_secs(1), |c| c.shutdown(std::net::Shutdown::Write).unwrap());+        let name = connection.worker.as_ref().and_then(|w| w.thread().name().map(String::from));+        assert_eq!(name.as_deref(), Some("lc-control-conn"));+    }+     #[test]     fn idle_connections_release_their_slots_without_dispatching() {         for input in ["", "{\"method\":\"engine.execute\""] {diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs--- a/apps/lightcraft/src/main.rs+++ b/apps/lightcraft/src/main.rs@@ -145,6 +145,8 @@ fn main() -> eframe::Result {     if let Some(logger) = logger {         logging::record_panics(logger);     }+    // before anything uses rayon, so its threads show as `lc-rayon-N` in a thread dump (#3071)+    lightcraft_engine::threads::name_global_pool();     alloc_release::install();     let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty()));     let mut files = Vec::new();diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -80,6 +80,7 @@ pub mod slideshow_export; pub mod smart; pub mod sync; pub mod text;+pub mod threads; mod view; pub mod walk; diff --git a/crates/engine/src/threads.rs b/crates/engine/src/threads.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/threads.rs@@ -0,0 +1,89 @@+//! Names for the threads of rayon's global pool (#3071). On Linux an unnamed thread takes the+//! name of the thread that started it, so without this a thread dump of a stuck run shows the+//! global pool's threads under the name of whichever render worker first used rayon.++/// The name of the global pool's thread `i` (Linux keeps the first 15 bytes of a name).+pub fn rayon_thread_name(i: usize) -> String {+    format!("lc-rayon-{i}")+}++/// Build rayon's global pool with its threads named [`rayon_thread_name`], at rayon's default+/// size. The app binaries call this once at start-up, before anything uses rayon; test binaries+/// keep rayon's lazy, unnamed pool. Returns whether the pool was built here: when it already+/// exists, or its threads can't start, it is left as it is (logged at debug level), never a+/// panic.+pub fn name_global_pool() -> bool {+    match rayon::ThreadPoolBuilder::new().thread_name(rayon_thread_name).build_global() {+        Ok(()) => true,+        Err(e) => {+            log::debug!("rayon's global pool keeps its thread names: {e}");+            false+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn names_fit_linux_thread_names() {+        assert_eq!(rayon_thread_name(0), "lc-rayon-0");+        assert!(rayon_thread_name(99_999).len() <= 15, "Linux would cut the index off");+    }++    /// `name_global_pool` in a process of its own (the global pool is built once per process):+    /// its threads carry the name, and a second call leaves the pool as it is.+    #[test]+    fn the_global_pool_threads_are_named() {+        use std::io::Read;+        use std::process::Stdio;+        let mut child = std::process::Command::new(std::env::current_exe().unwrap())+            .args(["--exact", "threads::tests::in_a_fresh_process", "--ignored", "--test-threads=1"])+            .env("LC_NAME_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<dyn Read + Send>| {+            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 stalled child (pool start-up, `broadcast`) fails the test instead of hanging the suite.+        let limit = std::time::Duration::from_secs(60);+        let deadline = std::time::Instant::now() + limit;+        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 {limit:?}");+            }+            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 the_global_pool_threads_are_named"]+    fn in_a_fresh_process() {+        if std::env::var_os("LC_NAME_GLOBAL_POOL").is_none() {+            return;+        }+        assert!(name_global_pool(), "the first call builds the pool");+        let names: Vec<Option<String>> = rayon::broadcast(|_| std::thread::current().name().map(String::from));+        assert_eq!(names.len(), rayon::current_num_threads());+        for (i, name) in names.iter().enumerate() {+            assert_eq!(name.as_deref(), Some(rayon_thread_name(i).as_str()));+        }+        assert!(!name_global_pool(), "a second call leaves the pool as it is");+    }+}diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs--- a/crates/ui-egui/src/dialogs/import/scan.rs+++ b/crates/ui-egui/src/dialogs/import/scan.rs@@ -69,7 +69,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String         let _ = tx.send(scan_with(input, &paths, &p));     };     #[cfg(not(target_arch = "wasm32"))]-    std::thread::spawn(job);+    std::thread::Builder::new().name("lc-import-scan".into()).spawn(job).map_err(|e| format!("could not start: {e}"))?;     #[cfg(target_arch = "wasm32")]     job();     let guard = scan_guard(app, &progress, false);@@ -134,7 +134,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->         let _ = tx.send(scan_with(input, &files, &p));     };     #[cfg(not(target_arch = "wasm32"))]-    std::thread::spawn(job);+    std::thread::Builder::new().name("lc-browse-scan".into()).spawn(job).map_err(|e| format!("could not start: {e}"))?;     #[cfg(target_arch = "wasm32")]     job();     let guard = scan_guard(app, &progress, true);diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs--- a/crates/ui-egui/src/panels/left/sections.rs+++ b/crates/ui-egui/src/panels/left/sections.rs@@ -409,8 +409,12 @@ pub(super) fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize             running.store(false, Relaxed);             ctx.request_repaint();         };+        // (a thread that can't start keeps the last count; the count is tried again once it is stale)         #[cfg(not(target_arch = "wasm32"))]-        std::thread::spawn(work);+        if let Err(e) = std::thread::Builder::new().name("lc-missing".into()).spawn(work) {+            log::warn!("counting missing photos: could not start: {e}");+            job.running.store(false, Relaxed);+        }         #[cfg(target_arch = "wasm32")]         work();         ui.data_mut(|d| d.insert_temp(id, job.clone()));diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs--- a/crates/ui-egui/src/sync.rs+++ b/crates/ui-egui/src/sync.rs@@ -55,7 +55,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu         let _ = tx.send(scan_with(input, &p));     };     #[cfg(not(target_arch = "wasm32"))]-    std::thread::spawn(job);+    std::thread::Builder::new().name("lc-sync-scan".into()).spawn(job).map_err(|e| format!("could not start: {e}"))?;     #[cfg(target_arch = "wasm32")]     job();     let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone()));@@ -270,7 +270,7 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {         }     };     #[cfg(not(target_arch = "wasm32"))]-    std::thread::spawn(job);+    std::thread::Builder::new().name("lc-sync".into()).spawn(job).map_err(|e| format!("could not start: {e}"))?;     #[cfg(target_arch = "wasm32")]     job();     let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone()));