Files
lightcraft/crates/gpu/src/exit.rs
T
hxwandClaude Opus 5.5 5a537d5d39 LR-BEHAV-GPU: no render is inside the GPU driver when the app quits (issue #620)
A render still running on a worker thread while the process exits crashes in the
driver (SIGSEGV in the NVIDIA Vulkan driver on Linux), where neither the panic
hook nor catch_unwind helps: pool workers were detached, the devices are statics
that are never dropped, and nothing on the way out waited for GPU work.

- gpu: every entry into a device (render, device creation, pool trim, denoise
  runner and tiles) is counted as work in flight (exit.rs). begin_shutdown()
  lets nothing new in; wait_idle(timeout) waits for what is inside.
- preview: JobPool keeps its workers. shutdown(timeout) and Drop discard the
  queued jobs and wait, bounded, for the running ones (2 s on drop); never for
  the calling thread, and not twice.
- engine: a render job caught by the shutdown gives up instead of rendering on
  the CPU, so quitting doesn't wait for it.
- app: on_exit ends with LightcraftApp::shutdown (2 s for everything), after
  settings and library are saved; the macOS menu-bar Quit goes through on_exit.

Not changed: the export, merge, denoise and import threads are still detached;
their GPU work goes through the gate, their CPU work is cut off at exit as before.

Tests: pool drop waits / is bounded / survives a panicked job and its own job
dropping it; the gate; GPU renders refused and the device not created after
begin_shutdown; the app's shutdown leaves no render worker. The crash itself
needs Linux + NVIDIA and was not reproduced here (Windows, DX12): 9 of 9 quits
of the app mid-render exit with code 0 in under 0.8 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:24 -05:00

133 lines
4.0 KiB
Rust

//! Leaving: no GPU work may be in flight when the process ends (issue #620).
//!
//! The devices are statics that are never dropped, and the threads that render (preview workers,
//! export, denoise, the device warm-up) are not the one that ends the process. Exiting runs the
//! driver's own teardown while those threads keep running: a render that is inside the driver at
//! that moment crashes there (seen as a SIGSEGV in the NVIDIA Vulkan driver), where neither the
//! panic hook nor `catch_unwind` can help.
//!
//! So every entry into a device goes through [`enter`], and the host ends the process with
//! [`crate::begin_shutdown`] (nothing new enters) and [`crate::wait_idle`] (what is in flight
//! finishes, up to a deadline).
use std::sync::{Condvar, Mutex, MutexGuard};
use std::time::{Duration, Instant};
struct State {
closed: bool,
/// [`Work`] in flight, on any thread.
busy: usize,
}
pub(crate) struct Gate {
state: Mutex<State>,
idle: Condvar,
}
/// GPU work in flight (until dropped).
pub(crate) struct Work<'a>(&'a Gate);
static GATE: Gate = Gate::new();
/// Start GPU work, or `None` when the process is ending: the caller then does without the GPU.
/// Work may nest (a render that creates the device).
pub(crate) fn enter() -> Option<Work<'static>> {
GATE.enter()
}
pub(crate) fn begin_shutdown() {
GATE.close();
}
pub(crate) fn shutting_down() -> bool {
GATE.lock().closed
}
pub(crate) fn wait_idle(timeout: Duration) -> bool {
GATE.wait_idle(timeout)
}
impl Gate {
pub const fn new() -> Gate {
Gate { state: Mutex::new(State { closed: false, busy: 0 }), idle: Condvar::new() }
}
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn enter(&self) -> Option<Work<'_>> {
let mut s = self.lock();
if s.closed {
return None;
}
s.busy = s.busy.saturating_add(1);
Some(Work(self))
}
/// Nothing enters from now on.
pub fn close(&self) {
self.lock().closed = true;
}
/// Wait up to `timeout` until no work is in flight; `false` when some still is.
pub fn wait_idle(&self, timeout: Duration) -> bool {
let t0 = Instant::now();
let mut s = self.lock();
while s.busy > 0 {
let Some(left) = timeout.checked_sub(t0.elapsed()).filter(|d| !d.is_zero()) else { return false };
s = self.idle.wait_timeout(s, left).unwrap_or_else(|e| e.into_inner()).0;
}
true
}
}
impl Drop for Work<'_> {
fn drop(&mut self) {
let mut s = self.0.lock();
s.busy = s.busy.saturating_sub(1);
if s.busy == 0 {
drop(s);
self.0.idle.notify_all();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn closed_gate_lets_nothing_in_and_waits_for_what_is_inside() {
let gate = Gate::new();
assert!(gate.wait_idle(Duration::ZERO), "idle: no wait");
let outer = gate.enter().unwrap();
let inner = gate.enter().unwrap();
gate.close();
assert!(gate.enter().is_none(), "nothing new once closed");
drop(inner);
let t0 = Instant::now();
assert!(!gate.wait_idle(Duration::from_millis(50)), "the outer work is still in flight");
assert!(t0.elapsed() >= Duration::from_millis(50));
std::thread::scope(|s| {
s.spawn(move || {
std::thread::sleep(Duration::from_millis(100));
drop(outer);
});
assert!(gate.wait_idle(Duration::from_secs(30)), "woken when the work ends");
});
assert!(gate.enter().is_none());
}
#[test]
fn work_that_panics_still_leaves() {
let gate = Gate::new();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _work = gate.enter();
panic!("a render panics (expected in this test)");
}));
assert!(r.is_err());
assert!(gate.wait_idle(Duration::ZERO));
}
}