From 7954f515a59ffd78d067637f804ff30643855e53 Mon Sep 17 00:00:00 2001 From: francois-unity <105223066+francois-unity@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:43:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Read=20a=20Linux=20window=E2=80=99s=20frame?= =?UTF-8?q?=20back=20with=20render-to-image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render-to-image` (HUB-7425) compiles `Window::render_to_image` outside `test-support`, but no Linux window implemented it, so the trait’s default answered “not implemented for this platform”. Linux windows draw through the WGPU renderer, which already has an offscreen readback for visual tests: it renders the scene into a texture, copies that to a buffer and maps it. That readback was compiled only under `gpui_wgpu`’s `test-support`. Give `gpui_wgpu` a `render-to-image` feature that compiles the readback alone (`WgpuHeadlessRenderer` stays test-only), forward it from `gpui_linux` and `gpui_platform`, and let the X11 and Wayland windows call it. The readback now refuses while a window recovers a lost device, as the DirectX one does, rather than panicking on the torn-down GPU resources. --- crates/gpui_linux/Cargo.toml | 3 ++ crates/gpui_linux/src/linux/wayland/window.rs | 5 ++++ crates/gpui_linux/src/linux/x11/window.rs | 5 ++++ crates/gpui_platform/Cargo.toml | 1 + crates/gpui_wgpu/Cargo.toml | 3 ++ crates/gpui_wgpu/src/wgpu_renderer.rs | 28 +++++++++++++++++-- .../gpui_wgpu/src/wgpu_renderer/headless.rs | 15 +++++++++- 7 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 7354b4bcd6..09812ce830 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -15,6 +15,9 @@ path = "src/gpui_linux.rs" [features] default = ["wayland", "x11"] test-support = ["gpui/test-support"] +# Reads a window’s frame back through the WGPU renderer’s offscreen readback +# (`Window::render_to_image`) without the rest of `test-support`. +render-to-image = ["gpui/render-to-image", "gpui_wgpu?/render-to-image"] wayland = [ "bitflags", "gpui_wgpu", diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 5b6fd583ac..fdef53d765 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -2148,6 +2148,11 @@ impl PlatformWindow for WaylandWindow { self.0.schedule_frame(); } + #[cfg(feature = "render-to-image")] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + self.borrow_mut().renderer.render_to_image(scene) + } + fn sprite_atlas(&self) -> Arc { let state = self.borrow(); state.renderer.sprite_atlas().clone() diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 499d237a3f..c7a85a3989 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1796,6 +1796,11 @@ impl PlatformWindow for X11Window { } } + #[cfg(feature = "render-to-image")] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + self.0.state.borrow_mut().renderer.render_to_image(scene) + } + fn sprite_atlas(&self) -> Arc { let inner = self.0.state.borrow(); inner.renderer.sprite_atlas().clone() diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index bc5c3ef84d..c795d8b206 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -29,6 +29,7 @@ test-support = [ render-to-image = [ "gpui/render-to-image", "gpui_apple/render-to-image", + "gpui_linux/render-to-image", "gpui_macos/render-to-image", "gpui_windows/render-to-image", ] diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 9488b9215b..ffdf9de875 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -15,6 +15,9 @@ path = "src/gpui_wgpu.rs" [features] default = [] test-support = ["gpui/test-support", "dep:image"] +# Compiles `WgpuRenderer::render_to_image`, the offscreen readback, without the +# rest of `test-support`, so a window can read its own frame back. +render-to-image = ["dep:image"] custom-gpu = ["gpui/custom-gpu"] wgpu-surfaces = ["gpui/wgpu-surfaces"] diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 069246b160..e09c1cadbb 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -18,7 +18,10 @@ mod buffers; mod drawing; mod filters; mod frame; -#[cfg(all(feature = "test-support", not(target_family = "wasm")))] +#[cfg(all( + any(feature = "test-support", feature = "render-to-image"), + not(target_family = "wasm") +))] mod headless; mod path_types; mod pipelines; @@ -261,7 +264,10 @@ impl WgpuRenderer { frame::render_to_view(self, scene, frame_view, None).is_some() } - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[cfg(all( + any(feature = "test-support", feature = "render-to-image"), + not(target_family = "wasm") + ))] fn render_to_view_with_readback( &mut self, scene: &Scene, @@ -494,6 +500,24 @@ mod tests { Ok(()) } + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn render_to_image_refuses_without_gpu_resources() -> anyhow::Result<()> { + let context = WgpuContext::new_headless(None)?; + let size = Size { + width: DevicePixels(2), + height: DevicePixels(2), + }; + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + // What a window’s renderer looks like while it recovers a lost device. + renderer.destroy(); + let error = renderer + .render_to_image(&Scene::default()) + .expect_err("a renderer without GPU resources has no frame to read back"); + assert!(error.to_string().contains("lost device"), "{error}"); + Ok(()) + } + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] #[test] fn configurable_dashes_reach_both_wgpu_quad_pipelines() -> anyhow::Result<()> { diff --git a/crates/gpui_wgpu/src/wgpu_renderer/headless.rs b/crates/gpui_wgpu/src/wgpu_renderer/headless.rs index beff866c98..630b156526 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer/headless.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer/headless.rs @@ -1,10 +1,14 @@ +#[cfg(feature = "test-support")] use std::sync::Arc; use gpui::{DevicePixels, Scene, Size}; +#[cfg(feature = "test-support")] use crate::{WgpuAtlas, WgpuContext}; -use super::{WgpuRenderer, WgpuSurfaceConfig}; +use super::WgpuRenderer; +#[cfg(feature = "test-support")] +use super::WgpuSurfaceConfig; struct OffscreenTarget { texture: wgpu::Texture, @@ -15,6 +19,7 @@ struct OffscreenTarget { } impl WgpuRenderer { + #[cfg(feature = "test-support")] pub(super) fn new_headless( context: &WgpuContext, size: Size, @@ -127,6 +132,11 @@ impl WgpuRenderer { /// Renders through the normal scene path and reads back without presenting. pub fn render_to_image(&mut self, scene: &Scene) -> anyhow::Result { + // A window’s renderer drops its GPU resources while it recovers a lost device. + anyhow::ensure!( + self.resources.is_some() && !self.device_lost(), + "render_to_image unavailable while recovering from a lost device" + ); let target = self.create_offscreen_target(); let submission = self .render_to_view_with_readback(scene, &target.view, target.readback_copy()) @@ -148,11 +158,13 @@ impl OffscreenTarget { } /// Surface-free renderer used by GPUI visual tests and benchmarks. +#[cfg(feature = "test-support")] pub struct WgpuHeadlessRenderer { renderer: WgpuRenderer, target: Option, } +#[cfg(feature = "test-support")] impl WgpuHeadlessRenderer { pub fn new() -> anyhow::Result { let context = WgpuContext::new_headless(None)?; @@ -209,6 +221,7 @@ impl WgpuHeadlessRenderer { } } +#[cfg(feature = "test-support")] impl gpui::PlatformHeadlessRenderer for WgpuHeadlessRenderer { fn render_scene_to_image( &mut self, From b27fdbfdcee726e06f0e9e9680d78542da0425c2 Mon Sep 17 00:00:00 2001 From: francois-unity <105223066+francois-unity@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:17:17 -0400 Subject: [PATCH 2/2] Refuse a window readback in a format it cannot copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offscreen readback copies 8-bit rows and only swizzles `Bgra8Unorm`, which held while it served the headless renderer alone. A window’s surface can pick another format when it offers neither 8-bit UNORM one, so refuse those with an error rather than returning swapped channels or a blank image. Test the device-lost half of the guard too: the first test only covered missing GPU resources, so dropping the `device_lost()` check kept it green. The refusal now names both states, and the macOS `wgpu` stub’s comment no longer claims the readback needs `test-support`. --- crates/gpui_macos/src/wgpu_renderer.rs | 8 ++--- crates/gpui_wgpu/src/wgpu_renderer.rs | 29 +++++++++++++++++-- .../gpui_wgpu/src/wgpu_renderer/headless.rs | 11 ++++++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/gpui_macos/src/wgpu_renderer.rs b/crates/gpui_macos/src/wgpu_renderer.rs index 00e3e88f0d..6b3cd55507 100644 --- a/crates/gpui_macos/src/wgpu_renderer.rs +++ b/crates/gpui_macos/src/wgpu_renderer.rs @@ -123,10 +123,10 @@ impl MacWgpuRenderer { self.renderer.render_to_image(scene) } - // WGPU's offscreen rendering needs gpui_wgpu's test-support, which the - // `test-support` feature enables. `render-to-image` deliberately does not - // (it exists to render without test-support's frame scheduling), so with - // the `wgpu` renderer it reports the gap rather than failing to compile. + // WGPU’s offscreen readback needs gpui_wgpu’s `test-support` or + // `render-to-image`, and this crate’s `render-to-image` forwards neither, + // so with the `wgpu` renderer it reports the gap rather than failing to + // compile. #[cfg(all(any(test, feature = "render-to-image"), not(feature = "test-support")))] pub fn render_to_image(&mut self, _scene: &Scene) -> anyhow::Result { anyhow::bail!("rendering to an image with the wgpu renderer needs the test-support feature") diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index e09c1cadbb..fd10f20264 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -509,12 +509,37 @@ mod tests { height: DevicePixels(2), }; let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let mut scene = Scene::default(); + scene.finish(); // What a window’s renderer looks like while it recovers a lost device. renderer.destroy(); let error = renderer - .render_to_image(&Scene::default()) + .render_to_image(&scene) .expect_err("a renderer without GPU resources has no frame to read back"); - assert!(error.to_string().contains("lost device"), "{error}"); + assert!(error.to_string().contains("live GPU device"), "{error}"); + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn render_to_image_refuses_once_the_device_is_lost() -> anyhow::Result<()> { + let context = WgpuContext::new_headless(None)?; + let size = Size { + width: DevicePixels(2), + height: DevicePixels(2), + }; + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let mut scene = Scene::default(); + scene.finish(); + // A window between the device-lost callback and the draw that recovers it. + renderer + .faults + .device_lost + .store(true, std::sync::atomic::Ordering::Relaxed); + let error = renderer + .render_to_image(&scene) + .expect_err("a lost device has no frame to read back"); + assert!(error.to_string().contains("live GPU device"), "{error}"); Ok(()) } diff --git a/crates/gpui_wgpu/src/wgpu_renderer/headless.rs b/crates/gpui_wgpu/src/wgpu_renderer/headless.rs index 630b156526..29c8d013dc 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer/headless.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer/headless.rs @@ -135,7 +135,16 @@ impl WgpuRenderer { // A window’s renderer drops its GPU resources while it recovers a lost device. anyhow::ensure!( self.resources.is_some() && !self.device_lost(), - "render_to_image unavailable while recovering from a lost device" + "render_to_image unavailable without a live GPU device" + ); + // The readback copies 8-bit RGBA or BGRA rows; a window surface may use another format. + let format = self.target.format(); + anyhow::ensure!( + matches!( + format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm + ), + "render_to_image cannot read back a {format:?} target" ); let target = self.create_offscreen_target(); let submission = self