teksilo_render/test_support.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::sync::{OnceLock, mpsc};
5
6use crate::Renderer;
7
8/// Why an offscreen readback could not produce pixels.
9///
10/// Every variant means the same thing to a caller — no image this time — but
11/// they are kept apart because they point at different causes: a lost device
12/// is a driver / compositor event, a failed map is usually memory pressure.
13#[derive(Debug, thiserror::Error)]
14pub enum ReadbackError {
15 /// `map_async`'s callback was dropped without firing — the device was lost
16 /// before the mapping completed.
17 #[error("readback failed: the GPU device was lost while mapping")]
18 DeviceLost,
19 /// The buffer mapping itself failed.
20 #[error("readback failed: buffer mapping was refused ({0})")]
21 MapFailed(String),
22 /// `poll` reported a failure before the mapping could be observed.
23 #[error("readback failed: polling the device failed ({0})")]
24 PollFailed(String),
25}
26
27/// The process-wide GPU device every offscreen renderer shares.
28///
29/// One device per process, not one per caller. Two D3D12 **WARP** devices
30/// rasterizing at the same time fault inside `d3d10warp.dll` — Microsoft's
31/// software rasterizer, which is exactly what a GPU-less Windows host and the
32/// CI runners use — so a device per caller turned any two concurrent offscreen
33/// renders into a crash the faulting-module log pins on WARP itself, not on
34/// wgpu or on us. It is not something we can fix downstream; the only remedy is
35/// to stop creating the second device.
36///
37/// Sharing is also simply right: a GPU device is a process-level resource, and
38/// nothing here ever wanted a private one. Callers still get their **own**
39/// [`Renderer`] — that is where the glyph and path atlases live, so no caller
40/// can see another's cached glyphs.
41///
42/// `None` means this host can open no usable device at all; it is cached too,
43/// so a GPU-less machine pays the failed search once rather than per call.
44static SHARED_DEVICE: OnceLock<Option<(wgpu::Device, wgpu::Queue)>> = OnceLock::new();
45
46/// Open the one device, searching for an adapter that actually yields one.
47///
48/// Adapter selection is a *search*, not a single request. A host can enumerate
49/// an adapter it cannot actually open — a VM's OpenGL driver is the common
50/// case — while a perfectly good software device sits behind
51/// `force_fallback_adapter`. Treating the first `request_device` failure as
52/// fatal reports "no GPU" on a machine that has one, which is what made
53/// screenshots unavailable on GPU-less Windows hosts and CI runners (where
54/// DX12 WARP is present and works). So: try the preferred adapter, then an
55/// explicit software fallback, and only give up when neither yields a device.
56async fn open_shared_device(label: &'static str) -> Option<(wgpu::Device, wgpu::Queue)> {
57 #[cfg(test)]
58 DEVICE_OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
59 // Same flags the window path uses — see `crate::instance::instance_flags`.
60 // Not a tidiness point: without it this instance keeps
61 // `VALIDATION_INDIRECT_CALL`, and on a driver that cannot build wgpu's
62 // indirect-validation pipelines `request_device` panics rather than
63 // returning the error the search below is written to survive.
64 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
65 flags: crate::instance_flags(),
66 ..wgpu::InstanceDescriptor::new_without_display_handle()
67 });
68
69 for force_fallback_adapter in [false, true] {
70 let Ok(adapter) = instance
71 .request_adapter(&wgpu::RequestAdapterOptions {
72 power_preference: wgpu::PowerPreference::LowPower,
73 compatible_surface: None,
74 force_fallback_adapter,
75 ..Default::default()
76 })
77 .await
78 else {
79 continue;
80 };
81
82 // `downlevel_defaults` caps `max_texture_dimension_2d` at 2048, but the
83 // path atlas grows past that — so a path-heavy frame would fail here
84 // while rendering fine anywhere the cap is lifted. `using_resolution`
85 // lifts exactly the resolution limits to whatever this adapter really
86 // supports, keeping every other downlevel bound. The live window path
87 // asks for the same set, so a frame that renders in a test renders in
88 // a window too.
89 let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits());
90
91 if let Ok((device, queue)) = adapter
92 .request_device(&wgpu::DeviceDescriptor {
93 label: Some(label),
94 required_features: wgpu::Features::empty(),
95 required_limits: limits,
96 ..Default::default()
97 })
98 .await
99 {
100 return Some((device, queue));
101 }
102 }
103 None
104}
105
106/// Build an offscreen renderer on the shared device, or `None` if this host can
107/// open no usable GPU device at all.
108///
109/// The [`Renderer`] is fresh per call; the device and queue behind it are
110/// shared process-wide, which is load-bearing rather than an optimisation: two
111/// D3D12 **WARP** devices rasterizing at once fault inside Microsoft's software
112/// rasterizer — exactly what a GPU-less Windows host and the CI runners use —
113/// so a device per caller turns any two concurrent offscreen renders into a
114/// crash. Atlases still live on the per-call `Renderer`, so no caller can see
115/// another's cached glyphs.
116///
117/// `label` names the device, so it only takes effect on the call that actually
118/// opens it; later callers join a device someone else already named.
119pub async fn create_test_renderer(
120 label: &'static str,
121) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
122 let (device, queue) = shared_device(label)?;
123 let renderer = Renderer::new(
124 device.clone(),
125 queue.clone(),
126 wgpu::TextureFormat::Rgba8UnormSrgb,
127 );
128 Some((renderer, device.clone(), queue.clone()))
129}
130
131/// The shared device, opening it on the first call.
132///
133/// Synchronous on purpose. `OnceLock::get_or_init` gives "exactly one caller
134/// runs the initialiser, the rest wait" for free, and opening a GPU device is
135/// blocking work whichever way it is spelled — every caller already reaches
136/// this through `pollster::block_on`. The alternative, holding a lock across
137/// the `await` inside an async fn, is the shape `clippy::await_holding_lock`
138/// warns about, and it would deadlock the first caller that ever drove this
139/// from a single-threaded executor.
140fn shared_device(label: &'static str) -> Option<&'static (wgpu::Device, wgpu::Queue)> {
141 SHARED_DEVICE
142 .get_or_init(|| pollster::block_on(open_shared_device(label)))
143 .as_ref()
144}
145
146/// Read a texture back as tightly-packed RGBA, panicking on GPU failure.
147///
148/// Kept for tests, where a lost device is a test failure and a panic is the
149/// clearest report. Anything user-facing — a screenshot tool that must survive
150/// a driver restart — should call [`try_read_texture_rgba`] instead.
151pub fn read_texture_rgba(
152 device: &wgpu::Device,
153 queue: &wgpu::Queue,
154 texture: &wgpu::Texture,
155 width: u32,
156 height: u32,
157) -> Vec<u8> {
158 try_read_texture_rgba(device, queue, texture, width, height).expect("texture readback failed")
159}
160
161/// Read a texture back as tightly-packed RGBA.
162///
163/// The GPU copy needs each row aligned to
164/// [`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`]; the padding is added for the copy
165/// and stripped back out here, so the returned buffer is exactly
166/// `width * height * 4` bytes with no stride.
167pub fn try_read_texture_rgba(
168 device: &wgpu::Device,
169 queue: &wgpu::Queue,
170 texture: &wgpu::Texture,
171 width: u32,
172 height: u32,
173) -> Result<Vec<u8>, ReadbackError> {
174 let bytes_per_pixel = 4u32;
175 let unpadded_bytes_per_row = width * bytes_per_pixel;
176 let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
177 * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
178 let buffer_size = padded_bytes_per_row as u64 * height as u64;
179
180 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
181 label: Some("teksilo_render_test_readback"),
182 size: buffer_size,
183 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
184 mapped_at_creation: false,
185 });
186
187 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
188 label: Some("teksilo_render_test_copy"),
189 });
190 encoder.copy_texture_to_buffer(
191 wgpu::TexelCopyTextureInfo {
192 texture,
193 mip_level: 0,
194 origin: wgpu::Origin3d::ZERO,
195 aspect: wgpu::TextureAspect::All,
196 },
197 wgpu::TexelCopyBufferInfo {
198 buffer: &buffer,
199 layout: wgpu::TexelCopyBufferLayout {
200 offset: 0,
201 bytes_per_row: Some(padded_bytes_per_row),
202 rows_per_image: Some(height),
203 },
204 },
205 wgpu::Extent3d {
206 width,
207 height,
208 depth_or_array_layers: 1,
209 },
210 );
211 queue.submit(std::iter::once(encoder.finish()));
212
213 let slice = buffer.slice(..);
214 let (tx, rx) = mpsc::channel();
215 slice.map_async(wgpu::MapMode::Read, move |result| {
216 let _ = tx.send(result);
217 });
218 device
219 .poll(wgpu::PollType::Wait {
220 submission_index: None,
221 timeout: None,
222 })
223 .map_err(|e| ReadbackError::PollFailed(e.to_string()))?;
224 rx.recv()
225 .map_err(|_| ReadbackError::DeviceLost)?
226 .map_err(|e| ReadbackError::MapFailed(e.to_string()))?;
227
228 let mapped = slice
229 .get_mapped_range()
230 .map_err(|e| ReadbackError::MapFailed(e.to_string()))?;
231 let mut pixels = vec![0u8; (width * height * bytes_per_pixel) as usize];
232 for row in 0..height as usize {
233 let src_offset = row * padded_bytes_per_row as usize;
234 let dst_offset = row * unpadded_bytes_per_row as usize;
235 pixels[dst_offset..dst_offset + unpadded_bytes_per_row as usize]
236 .copy_from_slice(&mapped[src_offset..src_offset + unpadded_bytes_per_row as usize]);
237 }
238 drop(mapped);
239 buffer.unmap();
240 Ok(pixels)
241}
242
243/// How many times a GPU device has actually been opened in this process.
244///
245/// Exists only so [`exactly_one_device_is_opened_per_process`] can assert the
246/// invariant the WARP crash depends on.
247#[cfg(test)]
248static DEVICE_OPENS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
249
250#[cfg(test)]
251mod shared_device_tests {
252 use super::*;
253
254 /// Every caller must land on the SAME device, even under contention.
255 ///
256 /// This is the invariant that keeps the offscreen renderer alive on a
257 /// GPU-less Windows host. Two D3D12 WARP devices rasterizing concurrently
258 /// fault inside `d3d10warp.dll`, which no amount of care on our side can
259 /// catch — it is a wild access violation in Microsoft's software
260 /// rasterizer, so the process dies mid-test. The only defence is to never
261 /// open the second device, and that is what this pins.
262 ///
263 /// Asserted through a counter rather than by comparing handles because
264 /// `wgpu::Device` exposes no identity: cloning is the supported way to
265 /// share one, so two clones are indistinguishable from two devices at the
266 /// type level — exactly the confusion that let a second device appear.
267 #[test]
268 fn exactly_one_device_is_opened_per_process() {
269 use std::sync::atomic::Ordering;
270
271 // Race several threads at the initialiser; `OnceLock` plus the init
272 // lock must let exactly one of them reach `open_shared_device`.
273 let barrier = std::sync::Arc::new(std::sync::Barrier::new(4));
274 let handles: Vec<_> = (0..4)
275 .map(|_| {
276 let b = barrier.clone();
277 std::thread::spawn(move || {
278 b.wait();
279 pollster::block_on(create_test_renderer("shared-device-test")).is_some()
280 })
281 })
282 .collect();
283 let got: Vec<bool> = handles.into_iter().map(|h| h.join().unwrap()).collect();
284
285 // Either this host has a device and every caller got one, or it has
286 // none and nobody did — never a mix.
287 assert!(
288 got.iter().all(|g| *g) || got.iter().all(|g| !*g),
289 "callers disagreed about whether a GPU exists: {got:?}"
290 );
291
292 let opens = DEVICE_OPENS.load(Ordering::Relaxed);
293 assert_eq!(
294 opens, 1,
295 "the device must be opened exactly once per process, not {opens} times - a second \
296 concurrent WARP device is an access violation inside d3d10warp.dll, not a slowdown"
297 );
298 }
299}