From 5b9161a6182ab793ba918ee81873a562ca4774f3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 21:25:49 -0800 Subject: [PATCH 1/2] chore(quest): claim quest/m0/gpu-tests-exit.md Quest-Claim: 13d7d41c7baba5ed From 85536ddc6a0eedb10b50c2ffa5d167c3a1f4befc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 22:43:39 -0800 Subject: [PATCH 2/2] fix(scene): join the overlay and matte threads, so GPU tests exit cleanly on NVIDIA The overlay thread was detached and freed Vello's pipelines after the compositor dropped, racing NVIDIA's exit handlers when the process ended: a SIGSEGV or free() abort after each overlay test passed. Overlays now stops the thread and joins it on drop. The matte thread held GPU objects the same way, so Matte joins its thread on drop too. Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 1 - quest/m0/gpu-tests-exit.md | 17 ----------------- rs/moq-scene/src/matte.rs | 32 ++++++++++++++++++++++++-------- rs/moq-scene/src/overlay.rs | 26 +++++++++++++++++++++++--- 4 files changed, 47 insertions(+), 29 deletions(-) delete mode 100644 quest/m0/gpu-tests-exit.md diff --git a/quest/m0/README.md b/quest/m0/README.md index c4f42cf0..fd9a336b 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -103,7 +103,6 @@ repository. - [Browser sources on the GPU under X11 on NVIDIA](/quest/m0/cef-x11-nvidia.md) - the X11 GPU path paints on NVIDIA, or falls back to the CPU path loudly - [A browser helper exits with the studio](/quest/m0/browser-helper-exit.md) - no orphaned CEF helper after a crash, kill, or timed-out test - [Nightly and the browser suite pass](/quest/m0/nightly-green.md) - render-sw, cookies, and the X11 clipboard test fixed at their causes -- [GPU tests exit cleanly on NVIDIA](/quest/m0/gpu-tests-exit.md) - compositor tests no longer crash on exit on NVIDIA - [The Apple signing identity is available](/quest/m0/signing.md) - the Apple credentials packaging and the macOS camera need; Windows ships unsigned - [Packaging](/quest/m0/packaging.md) - a signed macOS app, a Windows installer, and a Flatpak for Linux - [The persistable portal restore token is pinned](/quest/m0/pin-portal-token.md) - moq-video's restore token, for Wayland sources diff --git a/quest/m0/gpu-tests-exit.md b/quest/m0/gpu-tests-exit.md deleted file mode 100644 index 0bc30710..00000000 --- a/quest/m0/gpu-tests-exit.md +++ /dev/null @@ -1,17 +0,0 @@ -# [S] GPU tests exit cleanly on NVIDIA - -## Goal - -The compositor's GPU tests pass and exit cleanly on NVIDIA under Linux, as -they do on the software driver and on Metal. - -## Plan - -From [moq-dev/studio#74](https://github.com/moq-dev/studio/pull/74), picked -2026-11-07 (maintainer, /quest-complete), m0 by the rule that fixes join m0. - -On nixos (RTX 3070 Ti, NVIDIA 580), 7 compositor GPU tests, mostly the -overlay ones, pass and then crash when the process exits. The same happens -with `main`'s Vulkan setup and without CEF, and not on Mesa's software -driver. Reproduce, find the cause (teardown order of the wgpu device and what -holds it is a first suspect), and fix it rather than skipping the tests. diff --git a/rs/moq-scene/src/matte.rs b/rs/moq-scene/src/matte.rs index 9b8f9803..1d554d18 100644 --- a/rs/moq-scene/src/matte.rs +++ b/rs/moq-scene/src/matte.rs @@ -66,8 +66,8 @@ fn segment(model: &Model, rgba: &[u8]) -> Result, String> { run().map_err(|err| format!("the segmentation model: {err}")) } -/// Cuts a person out of one source's pictures. Dropping it stops its thread -/// once the picture in hand is done. +/// Cuts a person out of one source's pictures. Dropping it waits for the +/// picture in hand, then stops its thread. pub(crate) struct Matte { device: wgpu::Device, queue: wgpu::Queue, @@ -83,8 +83,13 @@ pub(crate) struct Matte { mask_bind: wgpu::BindGroup, /// The newest picture started, and its cut-out, at its size. held: Option, - jobs: mpsc::Sender, + /// Dropped first on drop, which ends the thread. + jobs: Option>, results: mpsc::Receiver, String>>, + /// Joined on drop, since the thread frees GPU objects on its way out: one + /// left running past `main` does so while the driver tears down, which + /// crashes NVIDIA's. + thread: Option>, /// Whether the thread has a picture, which also has `readback` mapped. busy: bool, /// Whether the picture the thread has was overtaken, so its cut-out is @@ -211,13 +216,13 @@ impl Matte { let (jobs, inbox) = mpsc::channel(); let (outbox, results) = mpsc::channel(); - let thread = Thread { + let worker = Thread { device: device.clone(), readback: readback.clone(), }; - std::thread::Builder::new() + let thread = std::thread::Builder::new() .name("moq-scene matte".into()) - .spawn(move || thread.run(inbox, outbox)) + .spawn(move || worker.run(inbox, outbox)) .map_err(|err| Error::Unsupported(format!("cannot start background removal: {err}")))?; Ok(Self { @@ -233,8 +238,9 @@ impl Matte { mask, mask_bind, held: None, - jobs, + jobs: Some(jobs), results, + thread: Some(thread), busy: false, stale: false, #[cfg(test)] @@ -276,7 +282,8 @@ impl Matte { let submission = self.queue.submit([encoder.finish()]); self.held = Some(held); // A thread that is gone reports so through `finish`. - let _ = self.jobs.send(submission); + let jobs = self.jobs.as_ref().expect("the job sender lives until drop"); + let _ = jobs.send(submission); self.busy = true; } @@ -413,6 +420,15 @@ impl Matte { } } +impl Drop for Matte { + fn drop(&mut self) { + drop(self.jobs.take()); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + /// What a matte's thread holds: the device to wait on, and the buffer the /// shrunk picture lands in. struct Thread { diff --git a/rs/moq-scene/src/overlay.rs b/rs/moq-scene/src/overlay.rs index 15e32c0f..a0cee607 100644 --- a/rs/moq-scene/src/overlay.rs +++ b/rs/moq-scene/src/overlay.rs @@ -904,13 +904,18 @@ fn fonts() -> FontContext { /// The overlays a compositor draws. Their documents live on a thread of their /// own, since a Blitz document cannot leave the thread that made it, and a -/// panic there fails the overlays rather than the compositor. +/// panic there fails the overlays rather than the compositor. Dropping it +/// stops the thread and waits for it. pub(crate) struct Overlays { device: wgpu::Device, requests: mpsc::Sender, /// What each paint failed on, by overlay. painted: mpsc::Receiver>, next: u64, + /// Joined on drop, since the thread frees GPU objects on its way out: one + /// left running past `main` does so while the driver tears down, which + /// crashes NVIDIA's. + thread: Option>, } enum Request { @@ -928,6 +933,8 @@ enum Request { Restart(u64, Instant), /// Paint these overlays as of this time on the program clock. Paint(Instant, Vec), + /// End the thread, though an [`Overlay`] may still hold a sender. + Stop, /// Stop the thread the way a panic in Blitz would. #[cfg(test)] Panic, @@ -939,7 +946,7 @@ impl Overlays { let (requests, received) = mpsc::channel(); let (reply, painted) = mpsc::channel(); let (device, queue) = (device.clone(), queue.clone()); - std::thread::Builder::new() + let thread = std::thread::Builder::new() .name("overlays".into()) .spawn({ let device = device.clone(); @@ -951,6 +958,7 @@ impl Overlays { requests, painted, next: 0, + thread: Some(thread), }) } @@ -1008,6 +1016,17 @@ impl Overlays { } } +impl Drop for Overlays { + fn drop(&mut self) { + // A thread that already stopped has nothing left to free, and one + // that panicked already failed every overlay. + let _ = self.requests.send(Request::Stop); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + #[cfg(test)] impl Overlays { /// Stop the thread as a panic in Blitz would. @@ -1056,7 +1075,7 @@ impl Drop for Overlay { } } -/// The overlay thread: serve requests until every sender is gone. +/// The overlay thread: serve requests until told to stop. fn run( device: &wgpu::Device, queue: &wgpu::Queue, @@ -1099,6 +1118,7 @@ fn run( document.painted = false; } } + Request::Stop => return, #[cfg(test)] Request::Panic => panic!("a test stopped the overlay thread"), Request::Paint(now, ids) => {