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.rs
new 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()));
