collective

Browse / github.com/storytold/lightcraft / patch

Issue #3068: GPU device creation gives up after a time limit instead of hanging

GPU device creation has no time limit: with an unresponsive display server, every GPU-touching test hangs forever Part of #3057. Related: #410 (the window's device), #1948 (the readback timeout). ## Summary GPU readback and the face and neural-network polls all have deadlines, but GPU device setup has none: - requesting the adapter; - requesting the device; - popping error scopes. When any of these blocks, the caller blocks forever. In the tests, every ui-egui test that creates the GPU device hangs until the binary is killed, while using no CPU. ## How to reproduce (Linux) Point `WAYLAND_DISPLAY` at a Unix socket that accepts connections and never replies, and run one test: ```sh python3 -c 'import

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

How it went

No other operator has reported using it yet.

Diff

raw · apps/lightcraft/src/gpu_crash.rs crates/gpu/src/backend.rs crates/gpu/src/exit.rs crates/gpu/src/faces.rs crates/gpu/src/init.rs crates/gpu/src/lib.rs crates/gpu/src/nn.rs docs/gpu-pipeline.md

diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs--- a/apps/lightcraft/src/gpu_crash.rs+++ b/apps/lightcraft/src/gpu_crash.rs@@ -18,7 +18,9 @@ pub(crate) fn window_files(in_memory: bool) -> window_gpu::Files { }  /// Written while the GPU compute device is created, removed once that returned-/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).+/// (`lightcraft_gpu::backend::set_init_marker`); still there after a quit while a creation that+/// passed its time limit was stuck in the driver (#3068). Not with `LIGHTCRAFT_NO_PREFS` (tests,+/// scripts). fn gpu_marker_path() -> Option<std::path::PathBuf> {     if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {         return None;diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs--- a/crates/gpu/src/backend.rs+++ b/crates/gpu/src/backend.rs@@ -179,7 +179,8 @@ static ACTIVE: Mutex<u32> = Mutex::new(0); /// Write this file before the compute device is created or any shader / pipeline is compiled /// (the kernels, the denoise network's), and remove it once that returned (successfully or not). /// A file still there at the next launch means the process died inside the driver — e.g. Apple's-/// Metal compiler (`MTLCompilerService`) aborting on a kernel, issue #250: see+/// Metal compiler (`MTLCompilerService`) aborting on a kernel, issue #250 — or ended while a device+/// creation that passed its time limit was still stuck there (issue #3068): see /// [`take_init_marker`]. `None` (the default) writes nothing. pub fn set_init_marker(path: Option<PathBuf>) {     *MARKER.lock().unwrap_or_else(|e| e.into_inner()) = path;@@ -225,6 +226,7 @@ impl Drop for Armed<'_> {  /// Run driver work `f` (`what`: "device creation", "kernel compilation"…) between writing and /// removing the init marker (if one is set).+#[cfg_attr(not(feature = "denoise"), allow(dead_code))] // the denoise network's kernels pub(crate) fn with_marker<T>(what: &str, f: impl FnOnce() -> T) -> T {     let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone();     with_marker_on(marker, &ACTIVE, what, f)@@ -240,9 +242,32 @@ fn with_marker_on<T>(marker: Option<PathBuf>, active: &Mutex<u32>, what: &str, f     f() } -/// Run device creation (and the kernel build that follows it) `f` under the init marker.-pub(crate) fn with_init_marker<T>(backends: Backends, f: impl FnOnce() -> T) -> T {-    with_marker(&format!("device and kernel creation (backends {backends:?})"), f)+/// The init marker (if one is set) and its scope counter, taken on the thread that asks for a device+/// and carried to the thread that creates it (`init.rs`, issue #3068): the marker stays while the+/// driver work really runs, also past the caller's time limit.+pub(crate) struct InitMarker {+    path: Option<PathBuf>,+    active: &'static Mutex<u32>,+}++impl InitMarker {+    /// The process-wide marker ([`set_init_marker`]).+    pub(crate) fn current() -> InitMarker {+        InitMarker { path: MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(), active: &ACTIVE }+    }++    /// A marker at `path` on its own scope counter (tests: other tests create real devices through+    /// the process-wide one).+    #[cfg(test)]+    pub(crate) fn at(path: PathBuf, active: &'static Mutex<u32>) -> InitMarker {+        InitMarker { path: Some(path), active }+    }++    /// Run device creation (and the kernel build that follows it) `f` on `backends` between writing+    /// and removing the marker.+    pub(crate) fn run<T>(self, backends: Backends, f: impl FnOnce() -> T) -> T {+        with_marker_on(self.path, self.active, &format!("device and kernel creation (backends {backends:?})"), f)+    } }  /// If the init marker `path` is present — the last process died while creating the GPU device —@@ -443,12 +468,12 @@ mod tests {     }      #[test]-    fn with_init_marker_writes_the_process_wide_marker() {+    fn the_current_init_marker_is_the_process_wide_one() {         let dir = std::env::temp_dir().join(format!("lc-gpu-global-{}", std::process::id()));         let m = dir.join("gpu-init.marker");         let _ = std::fs::remove_dir_all(&dir);         set_init_marker(Some(m.clone()));-        let seen = with_init_marker(Backends::DX12, || m.exists());+        let seen = InitMarker::current().run(Backends::DX12, || m.exists());         set_init_marker(None);         // not checked for removal: device-creating tests running in parallel may still hold the file         // (the test above checks removal on its own scope counter)diff --git a/crates/gpu/src/exit.rs b/crates/gpu/src/exit.rs--- a/crates/gpu/src/exit.rs+++ b/crates/gpu/src/exit.rs@@ -35,6 +35,11 @@ pub(crate) fn enter() -> Option<Work<'static>> {     GATE.enter() } +/// The process's gate (device creation carries a [`Work`] of it to the thread that creates it).+pub(crate) fn gate() -> &'static Gate {+    &GATE+}+ pub(crate) fn begin_shutdown() {     GATE.close(); }diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs--- a/crates/gpu/src/faces.rs+++ b/crates/gpu/src/faces.rs@@ -87,9 +87,8 @@ fn dev() -> Result<&'static Dev, String> {  fn create_device() -> Result<Dev, String> {     let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };-    crate::backend::with_init_marker(backends, || {-        std::panic::catch_unwind(|| make_device(backends)).unwrap_or_else(|_| Err("device creation panicked".into()))-    })+    // on a thread of its own, under the crash sentinel, with a time limit (#3068, `init.rs`)+    crate::init::create(backends, move || make_device(backends)) }  fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {diff --git a/crates/gpu/src/init.rs b/crates/gpu/src/init.rsnew file mode 100644--- /dev/null+++ b/crates/gpu/src/init.rs@@ -0,0 +1,388 @@+//! A time limit on creating a GPU device (issue #3068).+//!+//! Creating the wgpu instance, adapter and device, and compiling the kernels after it, are calls+//! into the graphics driver, and on Linux the driver may talk to the display server first. With an+//! unresponsive one (a crash-looping WSLg compositor, a socket that accepts and never answers)+//! those calls never return, and wgpu's native calls are synchronous (`pollster::block_on` gets an+//! already-finished future), so there is nothing to time out from the calling thread.+//!+//! So every device (rendering, denoise, faces) is made by [`create`] on a thread of its own, and+//! the caller waits at most [`limit`] (`LIGHTCRAFT_GPU_INIT_TIMEOUT`). Past it the caller gets an+//! `Err` that it caches like any other creation failure — the GPU is unavailable for the process —+//! and the blocked thread is left behind. That thread keeps what has to last as long as the driver+//! work does: its share of the GPU work in flight (`exit.rs`: a quit then reports the GPU busy+//! instead of tearing the driver down under it) and the crash sentinel (`backend.rs`: a quit+//! leaves the marker, so the next launch starts on the CPU). While one is left behind, later creations (another kind+//! of device) fail at once with the same reason instead of leaving another thread behind; should+//! the driver come back, the late device is leaked (never dropped into a driver that just hung)+//! and creations are tried again.+//!+//! The error scopes popped after creation (`scope.pop()`) need no limit: on native wgpu they+//! resolve at once, and they are thread-local, so they could not move to another thread anyway.++use std::sync::atomic::{AtomicU8, Ordering};+use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, mpsc};+use std::time::Duration;++use crate::backend::InitMarker;+use crate::exit::Gate;++/// The environment variable that changes [`DEFAULT_LIMIT`] (whole seconds).+pub(crate) const LIMIT_ENV: &str = "LIGHTCRAFT_GPU_INIT_TIMEOUT";+/// How long device creation may take by default. Normally well under a second, but a first run+/// that compiles every kernel with a slow driver compiler (DX12's FXC) takes seconds: generous.+pub(crate) const DEFAULT_LIMIT: Duration = Duration::from_secs(30);+/// The longest limit [`LIMIT_ENV`] can ask for (an hour).+const MAX_LIMIT_SECS: u64 = 3600;++/// How long a device creation may take: [`LIMIT_ENV`], else [`DEFAULT_LIMIT`]. Read once.+pub(crate) fn limit() -> Duration {+    static LIMIT: OnceLock<Duration> = OnceLock::new();+    *LIMIT.get_or_init(|| {+        let v = std::env::var(LIMIT_ENV).ok();+        parse_limit(v.as_deref()).unwrap_or_else(|| {+            log::warn!(+                "gpu: {LIMIT_ENV}={:?} is not a whole number of seconds from 1 to {MAX_LIMIT_SECS}; using {} s",+                v.unwrap_or_default(),+                DEFAULT_LIMIT.as_secs()+            );+            DEFAULT_LIMIT+        })+    })+}++/// The limit `v` (whole seconds, at most [`MAX_LIMIT_SECS`]) asks for; unset or blank is the+/// default; `None` when it is not a number of seconds ≥ 1.+fn parse_limit(v: Option<&str>) -> Option<Duration> {+    let Some(v) = v.map(str::trim).filter(|v| !v.is_empty()) else { return Some(DEFAULT_LIMIT) };+    match v.parse::<u64>() {+        Ok(secs) if secs >= 1 => Some(Duration::from_secs(secs.min(MAX_LIMIT_SECS))),+        // a number too big for u64 is still "wait as long as allowed"+        Err(e) if *e.kind() == std::num::IntErrorKind::PosOverflow => Some(Duration::from_secs(MAX_LIMIT_SECS)),+        _ => None,+    }+}++/// `d` for a message: "30 s", or "250 ms" under a second.+fn show(d: Duration) -> String {+    if d >= Duration::from_secs(1) { format!("{} s", d.as_secs()) } else { format!("{} ms", d.as_millis()) }+}++/// Device creations that are still inside the driver past their limit, and why the first was+/// given up on.+pub(crate) struct Stuck(Mutex<StuckState>);++struct StuckState {+    threads: usize,+    reason: Option<String>,+}++impl Stuck {+    pub(crate) const fn new() -> Stuck {+        Stuck(Mutex::new(StuckState { threads: 0, reason: None }))+    }++    fn lock(&self) -> MutexGuard<'_, StuckState> {+        self.0.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Why device creation is not tried now (one is still stuck), or `None`.+    fn reason(&self) -> Option<String> {+        let s = self.lock();+        if s.threads > 0 { s.reason.clone() } else { None }+    }+}++/// The process's stuck device creations (every kind of device: they share the driver).+static STUCK: Stuck = Stuck::new();++/// An attempt's state, changed only under the [`Stuck`] lock.+const WAITING: u8 = 0;+/// The worker finished in time; its result is (being) sent.+const DONE: u8 = 1;+/// The caller gave up waiting; the worker leaks its result.+const ABANDONED: u8 = 2;++/// Why no device is created once the process is ending ([`crate::begin_shutdown`]).+const CLOSING: &str = "LightCraft is closing";++/// What a creation thread carries besides the work: the stuck-state it reports to, the shutdown+/// gate it counts as GPU work on, and the crash sentinel it runs under.+struct Scope {+    stuck: &'static Stuck,+    gate: &'static Gate,+    marker: InitMarker,+}++/// Create a device on `backends` with `make` on a thread of its own and wait at most [`limit`] for+/// it. A panic in it, or a deadline that passes, is an `Err` saying so.+///+/// The thread, not the caller, holds what must last as long as the driver work does: it counts as+/// GPU work in flight ([`crate::wait_idle`] is not idle until it ends, issue #620) and runs under+/// the crash sentinel ([`crate::backend::set_init_marker`]). So a creation left behind past its+/// limit keeps both: a quit meanwhile reports the GPU busy, and leaves the marker for the next+/// launch, which then starts on the CPU.+pub(crate) fn create<T: Send + 'static>(backends: wgpu::Backends, make: impl FnOnce() -> Result<T, String> + Send + 'static) -> Result<T, String> {+    let scope = Scope { stuck: &STUCK, gate: crate::exit::gate(), marker: InitMarker::current() };+    create_on(scope, limit(), backends, make)+}++/// [`create`] with an explicit limit and scope (the tests use their own, so that a test's stuck+/// creation cannot stop the real devices, or hold up the shutdown, of other tests in the process).+fn create_on<T: Send + 'static>(+    scope: Scope,+    limit: Duration,+    backends: wgpu::Backends,+    make: impl FnOnce() -> Result<T, String> + Send + 'static,+) -> Result<T, String> {+    const WHAT: &str = "device creation";+    let Scope { stuck, gate, marker } = scope;+    if let Some(why) = stuck.reason() {+        return Err(why);+    }+    // the thread's own share of "GPU work in flight", released when the driver work really ends+    let work = gate.enter().ok_or(CLOSING)?;+    let phase = Arc::new(AtomicU8::new(WAITING));+    let (tx, rx) = mpsc::sync_channel(1);+    let worker_phase = Arc::clone(&phase);+    std::thread::Builder::new()+        .name("lc-gpu-device".into())+        .spawn(move || {+            let _work = work;+            let r = marker+                .run(backends, || std::panic::catch_unwind(std::panic::AssertUnwindSafe(make)).unwrap_or_else(|_| Err(format!("{WHAT} panicked"))));+            {+                let mut s = stuck.lock();+                if worker_phase.load(Ordering::Relaxed) == ABANDONED {+                    // the caller gave up: never drop a device into the driver that just hung+                    s.threads = s.threads.saturating_sub(1);+                    if s.threads == 0 {+                        s.reason = None;+                    }+                    drop(s);+                    log::warn!("gpu: a device creation that was given up on has finished after all");+                    std::mem::forget(r);+                    return;+                }+                worker_phase.store(DONE, Ordering::Relaxed);+            }+            let _ = tx.send(r);+        })+        .map_err(|e| format!("{WHAT}: could not start its thread ({e})"))?;+    match rx.recv_timeout(limit) {+        Ok(r) => r,+        Err(mpsc::RecvTimeoutError::Disconnected) => Err(format!("{WHAT} panicked")),+        Err(mpsc::RecvTimeoutError::Timeout) => {+            let mut s = stuck.lock();+            if phase.load(Ordering::Relaxed) == DONE {+                // it finished just now: the result is on its way+                drop(s);+                return rx.recv().unwrap_or_else(|_| Err(format!("{WHAT} panicked")));+            }+            phase.store(ABANDONED, Ordering::Relaxed);+            let why = format!("GPU {WHAT} did not finish in {} ({LIMIT_ENV}): the graphics driver or display server is not responding", show(limit));+            log::warn!("gpu: {why}; the GPU is not used");+            s.threads = s.threads.saturating_add(1);+            Err(s.reason.get_or_insert(why).clone())+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::path::PathBuf;+    use std::sync::atomic::AtomicBool;+    use std::time::Instant;++    const B: wgpu::Backends = wgpu::Backends::VULKAN;++    /// A test's own stuck-state, gate and marker (a file in a fresh folder of its own).+    struct Local {+        stuck: &'static Stuck,+        gate: &'static Gate,+        active: &'static Mutex<u32>,+        dir: PathBuf,+    }++    impl Local {+        fn new(name: &str, stuck: &'static Stuck, gate: &'static Gate, active: &'static Mutex<u32>) -> Local {+            let dir = std::env::temp_dir().join(format!("lc-gpu-init-{name}-{}", std::process::id()));+            let _ = std::fs::remove_dir_all(&dir);+            Local { stuck, gate, active, dir }+        }++        fn marker(&self) -> PathBuf {+            self.dir.join("gpu-init.marker")+        }++        fn scope(&self) -> Scope {+            Scope { stuck: self.stuck, gate: self.gate, marker: InitMarker::at(self.marker(), self.active) }+        }+    }++    impl Drop for Local {+        fn drop(&mut self) {+            let _ = std::fs::remove_dir_all(&self.dir);+        }+    }++    /// Runs `f` on a thread and fails the test if it has not returned after 20 s (instead of+    /// hanging the test binary, which is what a creation without a limit did).+    fn within_20_s<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {+        let (tx, rx) = mpsc::channel();+        std::thread::spawn(move || {+            let _ = tx.send(f());+        });+        rx.recv_timeout(Duration::from_secs(20)).expect("device creation still blocked after 20 s: it has no time limit")+    }++    /// Waits up to 20 s for `done`.+    fn eventually(what: &str, done: impl Fn() -> bool) {+        let t0 = Instant::now();+        while !done() {+            assert!(t0.elapsed() < Duration::from_secs(20), "{what}");+            std::thread::sleep(Duration::from_millis(10));+        }+    }++    // Given a driver call that never returns (an unresponsive display server), device creation+    // fails after the limit with a reason; a second creation then fails at once without starting+    // another blocked thread; once the driver comes back, creation works again+    #[test]+    fn a_device_creation_that_never_returns_fails_after_the_limit() {+        static STUCK: Stuck = Stuck::new();+        static GATE: Gate = Gate::new();+        static ACTIVE: Mutex<u32> = Mutex::new(0);+        let local = Local::new("limit", &STUCK, &GATE, &ACTIVE);+        let (release, blocked) = mpsc::channel::<()>();+        let t0 = Instant::now();+        let scope = local.scope();+        let r = within_20_s(move || {+            create_on(scope, Duration::from_millis(200), B, move || -> Result<u32, String> {+                let _ = blocked.recv(); // "inside the driver" until the test lets go+                Ok(1)+            })+        });+        let took = t0.elapsed();+        let why = r.expect_err("a creation past its limit is an error");+        assert!(why.contains("did not finish in 200 ms"), "{why}");+        assert!(why.contains(LIMIT_ENV), "the message names the setting: {why}");+        assert!(took >= Duration::from_millis(200), "waited the limit: {took:?}");++        // another creation does not start a second blocked thread+        let started = Arc::new(AtomicBool::new(false));+        let s2 = Arc::clone(&started);+        let t1 = Instant::now();+        let scope = local.scope();+        let again = within_20_s(move || {+            create_on(scope, Duration::from_secs(30), B, move || {+                s2.store(true, Ordering::Relaxed);+                Ok(2)+            })+        });+        assert_eq!(again, Err(why.clone()), "the same reason, at once");+        assert!(t1.elapsed() < Duration::from_secs(10));+        assert!(!started.load(Ordering::Relaxed), "nothing new went into the driver");++        // the driver answers after all: the late result is let go and creation is tried again+        drop(release);+        eventually("the released thread never finished", || STUCK.reason().is_none());+        let scope = local.scope();+        assert_eq!(within_20_s(move || create_on(scope, Duration::from_secs(30), B, || Ok(3))), Ok(3));+    }++    // Given a creation left behind past its limit, the process is not idle for a quit (issue #620:+    // no driver teardown under it) and the crash sentinel stays, until the driver work really+    // ends; the quit's own wait stays bounded meanwhile+    #[test]+    fn a_creation_left_behind_keeps_the_gpu_busy_and_the_marker_until_it_ends() {+        static STUCK: Stuck = Stuck::new();+        static GATE: Gate = Gate::new();+        static ACTIVE: Mutex<u32> = Mutex::new(0);+        let local = Local::new("busy", &STUCK, &GATE, &ACTIVE);+        let (release, blocked) = mpsc::channel::<()>();+        let scope = local.scope();+        let r = within_20_s(move || {+            create_on(scope, Duration::from_millis(100), B, move || -> Result<u32, String> {+                let _ = blocked.recv();+                Ok(1)+            })+        });+        assert!(r.is_err(), "given up on");+        let marker = local.marker();+        assert!(marker.exists(), "the sentinel stays while the driver work runs");+        let text = std::fs::read_to_string(&marker).unwrap_or_default();+        assert!(text.contains("device and kernel creation (backends") && text.contains("VULKAN"), "{text}");++        // a quit now: nothing new starts, and the wait for what is in flight ends at its deadline+        GATE.close();+        let t0 = Instant::now();+        assert!(!GATE.wait_idle(Duration::from_millis(100)), "the creation left behind is still in flight");+        assert!(t0.elapsed() < Duration::from_secs(10), "the quit's wait stays bounded");+        assert!(marker.exists(), "a quit now leaves the marker for the next launch");++        // and a creation asked for once the gate is closed does not start+        let scope = local.scope();+        assert_eq!(create_on::<u32>(scope, Duration::from_secs(30), B, || Ok(2)), Err(STUCK.reason().unwrap_or_default()));++        drop(release);+        assert!(GATE.wait_idle(Duration::from_secs(20)), "idle once the driver work ended");+        eventually("the marker is removed once the driver work ended", || !marker.exists());+    }++    // Given a creation that returns in time, its result (device or reason) comes back unchanged,+    // and neither the work in flight nor the marker is left+    #[test]+    fn a_creation_in_time_returns_its_result() {+        static STUCK: Stuck = Stuck::new();+        static GATE: Gate = Gate::new();+        static ACTIVE: Mutex<u32> = Mutex::new(0);+        let local = Local::new("in-time", &STUCK, &GATE, &ACTIVE);+        let marker = local.marker();+        let m = marker.clone();+        assert_eq!(create_on(local.scope(), Duration::from_secs(30), B, move || Ok(m.exists())), Ok(true), "under the marker");+        assert!(!marker.exists());+        let no = create_on::<u32>(local.scope(), Duration::from_secs(30), B, || Err("no GPU adapter".into()));+        assert_eq!(no, Err("no GPU adapter".to_string()));+        assert!(STUCK.reason().is_none());+        assert!(GATE.wait_idle(Duration::from_secs(20)));+        assert!(!marker.exists());+        // once the process is ending, nothing is created+        GATE.close();+        assert_eq!(create_on::<u32>(local.scope(), Duration::from_secs(30), B, || Ok(1)), Err(CLOSING.to_string()));+    }++    // Given a creation that panics (inside the driver or ours), the caller gets an error, not the+    // panic, and the marker is removed (the process survived)+    #[test]+    fn a_creation_that_panics_is_an_error() {+        static STUCK: Stuck = Stuck::new();+        static GATE: Gate = Gate::new();+        static ACTIVE: Mutex<u32> = Mutex::new(0);+        let local = Local::new("panic", &STUCK, &GATE, &ACTIVE);+        #[allow(clippy::panic)]+        let r = create_on::<u32>(local.scope(), Duration::from_secs(30), B, || panic!("driver bug (expected in this test)"));+        assert_eq!(r, Err("device creation panicked".to_string()));+        assert!(STUCK.reason().is_none());+        assert!(GATE.wait_idle(Duration::from_secs(20)));+        assert!(!local.marker().exists());+    }++    // Given LIGHTCRAFT_GPU_INIT_TIMEOUT, whole seconds from 1 up to an hour are used; anything+    // else is refused (and the default used)+    #[test]+    fn the_limit_comes_from_whole_seconds() {+        assert_eq!(parse_limit(None), Some(DEFAULT_LIMIT));+        assert_eq!(parse_limit(Some("  ")), Some(DEFAULT_LIMIT));+        assert_eq!(parse_limit(Some("5")), Some(Duration::from_secs(5)));+        assert_eq!(parse_limit(Some(" 120 ")), Some(Duration::from_secs(120)));+        assert_eq!(parse_limit(Some("999999")), Some(Duration::from_secs(MAX_LIMIT_SECS)));+        assert_eq!(parse_limit(Some("99999999999999999999999")), Some(Duration::from_secs(MAX_LIMIT_SECS)));+        for bad in ["0", "-3", "1.5", "abc", "NaN", "inf", "30s"] {+            assert_eq!(parse_limit(Some(bad)), None, "{bad}");+        }+        assert_eq!(show(Duration::from_secs(30)), "30 s");+        assert_eq!(show(Duration::from_millis(250)), "250 ms");+    }+}diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs--- a/crates/gpu/src/lib.rs+++ b/crates/gpu/src/lib.rs@@ -7,7 +7,8 @@ //! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`. //! //! Which backends wgpu may load (DX12 only on Windows; `LIGHTCRAFT_GPU_BACKEND`, `WGPU_BACKEND`) and-//! the crash sentinel around device creation: [`backend`] (issue #136).+//! the crash sentinel around device creation: [`backend`] (issue #136). Device creation has a time+//! limit (`LIGHTCRAFT_GPU_INIT_TIMEOUT`, issue #3068, see `init.rs`). //! //! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`, //! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out@@ -37,6 +38,8 @@ mod ctx; mod exit; #[cfg(not(target_arch = "wasm32"))] pub mod faces;+#[cfg(not(target_arch = "wasm32"))]+mod init; #[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub mod nn; #[cfg(not(target_arch = "wasm32"))]@@ -288,9 +291,10 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> {     let _work = exit::enter()?;     GPU.get_or_init(|| {         let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };-        let r = backend::with_init_marker(backends, || {-            std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into()))-        });+        // on a thread of its own (under the crash sentinel, counted as GPU work), waited for at most+        // `init::limit()`: a driver or display server that never answers leaves the GPU unavailable+        // instead of every render waiting forever (#3068)+        let r = init::create(backends, move || ctx::Gpu::new(backends));         if let Err(why) = &r {             log::warn!("gpu: rendering on the CPU: {why}");         }diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs--- a/crates/gpu/src/nn.rs+++ b/crates/gpu/src/nn.rs@@ -76,9 +76,8 @@ const CLOSING: &str = "LightCraft is closing";  fn create_device() -> Result<Dev, String> {     let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };-    crate::backend::with_init_marker(backends, || {-        std::panic::catch_unwind(|| make_device(backends)).unwrap_or_else(|_| Err("device creation panicked".into()))-    })+    // on a thread of its own, under the crash sentinel, with a time limit (#3068, `init.rs`)+    crate::init::create(backends, move || make_device(backends)) }  fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md--- a/docs/gpu-pipeline.md+++ b/docs/gpu-pipeline.md@@ -49,6 +49,9 @@ Overrides (read once at launch): - `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected). - Both `LIGHTCRAFT_GPU=0` and `LIGHTCRAFT_GPU_BACKEND=off` also keep AI Denoise, the face models   and the AI mask model (SAM 3 on Metal) on the CPU.+- `LIGHTCRAFT_GPU_INIT_TIMEOUT=<seconds>` (whole seconds, 1–3600; default 30): how long creating a+  compute device (rendering, AI Denoise, faces) and compiling its kernels may take. See *Device+  creation time limit* below.  **DX12 shader compiler (issue #471).** Every instance (window and compute) compiles DX12 shaders with FXC (`d3dcompiler_47.dll`, part of Windows; `lightcraft_gpu::backend::backend_options`). wgpu's@@ -73,6 +76,24 @@ up (`gpu::warm_up` from the first frame's settings), and never while GPU renderi Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied before the window opens), `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off`, no GPU driver is loaded for rendering at all. +**Device creation time limit (issue #3068).** Creating the wgpu instance, adapter and device calls+into the driver, which on Linux may first talk to the display server; with one that accepts the+connection and never answers (a crash-looping WSLg compositor, a stale `WAYLAND_DISPLAY` socket) those+calls never returned, and every render waiting for the device, and every test that creates one, hung+for good. wgpu's native calls are synchronous, so each compute device (rendering, denoise, faces) is+now created, kernels included, on a thread of its own (`lc-gpu-device`, `crates/gpu/src/init.rs`), and+the caller waits at most `LIGHTCRAFT_GPU_INIT_TIMEOUT` seconds (default 30: a first run that compiles+every kernel with FXC takes seconds). Past it that device is unavailable for the process, with the+reason `GPU device creation did not finish in 30 s (LIGHTCRAFT_GPU_INIT_TIMEOUT): …` reported like any+other creation failure (`perf.gpuReason`, the no-GPU notice, the log), and the blocked thread is left+behind. That thread, not the caller, holds what must last as long as the driver work does: its share+of the GPU work in flight, so a quit meanwhile finds the GPU busy (`gpu::wait_idle` returns `false` at+its own deadline, *Quitting* below), and the crash sentinel, so a quit meanwhile leaves the marker and+the next launch starts on the CPU (*Crash sentinel* below). While it is left behind, creating another+kind of device fails at once with the same reason rather than leave a second thread behind; if the+driver ever answers, the marker is removed, its late device is leaked, not dropped, and later+creations are tried again. The desktop window's own device is not covered (#410).+ **Quitting.** The devices are never dropped, and renders run on worker threads: a render still inside the driver while the process exits crashes there (a SIGSEGV in the NVIDIA Vulkan driver on Linux, issue #620), which no panic hook catches. So every entry into a device counts as work in flight@@ -93,7 +114,9 @@ Mac). A kernel the driver refuses without crashing is caught by wgpu error scope (`gpu: rendering on the CPU: …`) and renders stay on the CPU. `LIGHTCRAFT_GPU=0` (or Settings ▸ uncheck *Use the GPU for rendering*) forces the CPU path from the start. If the marker is still there at the next launch, the process died inside the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the-marker and says so in a notice. Checking *Use the GPU for rendering* again tries the GPU once more+marker and says so in a notice. The same happens after a quit while a device creation that passed its+time limit was still inside the driver (above): the driver hung rather than crashed, but it would+likely hang again, so the next launch starts on the CPU too. Checking *Use the GPU for rendering* again tries the GPU once more (and re-arms the sentinel). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts), and not in a `--memory` session, which writes nothing: it neither arms the sentinel nor removes a marker it finds (it still starts with GPU rendering off when one is there, and the next ordinary launch reports and clears it). Killing the app during