Skip to main content

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, crate::DeviceHealth)>> =
45    OnceLock::new();
46
47/// Open the one device, searching for an adapter that actually yields one.
48///
49/// Adapter selection is a *search*, not a single request. A host can enumerate
50/// an adapter it cannot actually open — a VM's OpenGL driver is the common
51/// case — while a perfectly good software device sits behind
52/// `force_fallback_adapter`. Treating the first `request_device` failure as
53/// fatal reports "no GPU" on a machine that has one, which is what made
54/// screenshots unavailable on GPU-less Windows hosts and CI runners (where
55/// DX12 WARP is present and works). So: try the preferred adapter, then an
56/// explicit software fallback, and only give up when neither yields a device.
57async fn open_shared_device(label: &'static str) -> Option<(wgpu::Device, wgpu::Queue)> {
58    #[cfg(test)]
59    DEVICE_OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
60    // Built `_from_env`, as the window path's instance is, so wgpu's own
61    // variables apply here too: `WGPU_BACKEND=vulkan` then yields a Vulkan
62    // adapter (lavapipe on a GPU-less CI runner) or none, never a silent fall
63    // back to GL. With it set, the `force_fallback_adapter` retry below can
64    // only find a software adapter of that backend.
65    //
66    // Same flags the window path uses — see `crate::instance::instance_flags`.
67    // Not a tidiness point: without it this instance keeps
68    // `VALIDATION_INDIRECT_CALL`, and on a driver that cannot build wgpu's
69    // indirect-validation pipelines `request_device` panics rather than
70    // returning the error the search below is written to survive.
71    let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
72    descriptor.flags = crate::instance_flags();
73    let instance = wgpu::Instance::new(descriptor);
74
75    for force_fallback_adapter in [false, true] {
76        let Ok(adapter) = instance
77            .request_adapter(&wgpu::RequestAdapterOptions {
78                // `WGPU_POWER_PREF`, as the window path reads it, so a
79                // hybrid-GPU machine renders offscreen on the GPU its windows
80                // use.
81                power_preference: wgpu::PowerPreference::from_env()
82                    .unwrap_or(wgpu::PowerPreference::LowPower),
83                compatible_surface: None,
84                force_fallback_adapter,
85                ..Default::default()
86            })
87            .await
88        else {
89            continue;
90        };
91
92        // `downlevel_defaults` caps `max_texture_dimension_2d` at 2048, but the
93        // path atlas grows past that — so a path-heavy frame would fail here
94        // while rendering fine anywhere the cap is lifted. `using_resolution`
95        // lifts exactly the resolution limits to whatever this adapter really
96        // supports, keeping every other downlevel bound. The live window path
97        // asks for the same set, so a frame that renders in a test renders in
98        // a window too.
99        let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits());
100
101        if let Ok((device, queue)) = adapter
102            .request_device(&wgpu::DeviceDescriptor {
103                label: Some(label),
104                required_features: wgpu::Features::empty(),
105                required_limits: limits,
106                ..Default::default()
107            })
108            .await
109        {
110            return Some((device, queue));
111        }
112    }
113    None
114}
115
116/// Build an offscreen renderer on the shared device, or `None` if this host can
117/// open no usable GPU device at all.
118///
119/// The [`Renderer`] is fresh per call; the device and queue behind it are
120/// shared process-wide, which is load-bearing rather than an optimisation: two
121/// D3D12 **WARP** devices rasterizing at once fault inside Microsoft's software
122/// rasterizer — exactly what a GPU-less Windows host and the CI runners use —
123/// so a device per caller turns any two concurrent offscreen renders into a
124/// crash. Atlases still live on the per-call `Renderer`, so no caller can see
125/// another's cached glyphs.
126///
127/// `label` names the device, so it only takes effect on the call that actually
128/// opens it; later callers join a device someone else already named.
129///
130/// This is the entry shipped code uses (the headless automation server's
131/// screenshots, the previewer's PNG export). It never panics: a host without a
132/// GPU gets `None`, whatever the environment says. Tests call
133/// [`require_test_renderer`], which can insist on an adapter.
134pub async fn create_offscreen_renderer(
135    label: &'static str,
136) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
137    let (device, queue, health) = shared_device(label)?;
138    let renderer = Renderer::with_device_health(
139        device.clone(),
140        queue.clone(),
141        wgpu::TextureFormat::Rgba8UnormSrgb,
142        health.clone(),
143    );
144    Some((renderer, device.clone(), queue.clone()))
145}
146
147/// [`create_offscreen_renderer`], under its earlier name.
148pub async fn create_test_renderer(
149    label: &'static str,
150) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
151    create_offscreen_renderer(label).await
152}
153
154/// The variable a test run sets to insist on a GPU adapter.
155///
156/// - unset: [`require_test_renderer`] behaves like
157///   [`create_offscreen_renderer`], so a GPU test returns early on a host
158///   without an adapter;
159/// - `any`: a missing adapter fails the test;
160/// - `lavapipe`: a missing adapter, or one that is not Mesa's lavapipe
161///   ([`is_lavapipe`]), fails the test.
162///
163/// CI sets it so that an adapter that stopped loading fails the job instead of
164/// turning every GPU test into an early return counted as a pass.
165pub const REQUIRE_ADAPTER_VAR: &str = "TEKSILO_TEST_REQUIRE_ADAPTER";
166
167/// `true` when [`REQUIRE_ADAPTER_VAR`] asks for an adapter.
168///
169/// For a test that reaches the GPU through a path that must stay panic-free
170/// (the headless automation server) and accepts a "no GPU" reply: under the
171/// requirement, that reply is a failure, not a pass.
172pub fn adapter_required() -> bool {
173    std::env::var_os(REQUIRE_ADAPTER_VAR).is_some_and(|v| !v.is_empty())
174}
175
176/// An offscreen renderer for a GPU test, honouring [`REQUIRE_ADAPTER_VAR`].
177///
178/// Returns `None` only when the variable is unset and the host has no
179/// adapter; a test then returns early. Panics, naming the adapter it found,
180/// when the variable asks for an adapter this host does not provide.
181pub async fn require_test_renderer(
182    label: &'static str,
183) -> Option<(Renderer, wgpu::Device, wgpu::Queue)> {
184    let requirement = std::env::var(REQUIRE_ADAPTER_VAR).ok();
185    let renderer = create_offscreen_renderer(label).await;
186    let found = renderer
187        .as_ref()
188        .map(|(_, device, _)| device.adapter_info());
189    if let Err(message) = check_adapter_requirement(requirement.as_deref(), found.as_ref()) {
190        panic!("{message}");
191    }
192    renderer
193}
194
195/// The decision behind [`require_test_renderer`], separated so it can be
196/// tested without the variable or a device.
197fn check_adapter_requirement(
198    requirement: Option<&str>,
199    found: Option<&wgpu::AdapterInfo>,
200) -> Result<(), String> {
201    match (requirement, found) {
202        (None, _) => Ok(()),
203        (Some("any"), Some(_)) => Ok(()),
204        (Some("lavapipe"), Some(info)) if is_lavapipe(info) => Ok(()),
205        (Some(wanted @ ("any" | "lavapipe")), None) => Err(format!(
206            "{REQUIRE_ADAPTER_VAR}={wanted}, but no GPU adapter could be opened"
207        )),
208        (Some("lavapipe"), Some(info)) => Err(format!(
209            "{REQUIRE_ADAPTER_VAR}=lavapipe, but the adapter opened is not lavapipe: {info:?}"
210        )),
211        (Some(other), _) => Err(format!(
212            "{REQUIRE_ADAPTER_VAR}={other:?} is not one of \"any\" or \"lavapipe\""
213        )),
214    }
215}
216
217/// `true` for Mesa's lavapipe, the Vulkan rasteriser that runs on the CPU.
218///
219/// `device_type == Cpu` alone means any software rasteriser, which also
220/// matches GL llvmpipe and D3D12 WARP; the backend and the driver name tell
221/// lavapipe apart.
222pub fn is_lavapipe(info: &wgpu::AdapterInfo) -> bool {
223    info.backend == wgpu::Backend::Vulkan
224        && info.device_type == wgpu::DeviceType::Cpu
225        && info.driver == "llvmpipe"
226}
227
228/// The shared device, opening it on the first call.
229///
230/// Synchronous on purpose. `OnceLock::get_or_init` gives "exactly one caller
231/// runs the initialiser, the rest wait" for free, and opening a GPU device is
232/// blocking work whichever way it is spelled — every caller already reaches
233/// this through `pollster::block_on`. The alternative, holding a lock across
234/// the `await` inside an async fn, is the shape `clippy::await_holding_lock`
235/// warns about, and it would deadlock the first caller that ever drove this
236/// from a single-threaded executor.
237/// The shared device, its queue and its lost-device latch, installed once
238/// when it is opened and shared by every renderer on it.
239fn shared_device(
240    label: &'static str,
241) -> Option<&'static (wgpu::Device, wgpu::Queue, crate::DeviceHealth)> {
242    SHARED_DEVICE
243        .get_or_init(|| {
244            pollster::block_on(open_shared_device(label)).map(|(device, queue)| {
245                let health = crate::DeviceHealth::install(&device);
246                (device, queue, health)
247            })
248        })
249        .as_ref()
250}
251
252/// Read a texture back as tightly-packed RGBA, panicking on GPU failure.
253///
254/// Kept for tests, where a lost device is a test failure and a panic is the
255/// clearest report. Anything user-facing — a screenshot tool that must survive
256/// a driver restart — should call [`try_read_texture_rgba`] instead.
257pub fn read_texture_rgba(
258    device: &wgpu::Device,
259    queue: &wgpu::Queue,
260    texture: &wgpu::Texture,
261    width: u32,
262    height: u32,
263) -> Vec<u8> {
264    try_read_texture_rgba(device, queue, texture, width, height).expect("texture readback failed")
265}
266
267/// Read a texture back as tightly-packed RGBA.
268///
269/// The GPU copy needs each row aligned to
270/// [`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`]; the padding is added for the copy
271/// and stripped back out here, so the returned buffer is exactly
272/// `width * height * 4` bytes with no stride.
273pub fn try_read_texture_rgba(
274    device: &wgpu::Device,
275    queue: &wgpu::Queue,
276    texture: &wgpu::Texture,
277    width: u32,
278    height: u32,
279) -> Result<Vec<u8>, ReadbackError> {
280    try_read_texture_level_rgba(device, queue, texture, 0, width, height)
281}
282
283/// [`try_read_texture_rgba`] of mip level `level`, which is `width ×
284/// height`.
285pub fn try_read_texture_level_rgba(
286    device: &wgpu::Device,
287    queue: &wgpu::Queue,
288    texture: &wgpu::Texture,
289    level: u32,
290    width: u32,
291    height: u32,
292) -> Result<Vec<u8>, ReadbackError> {
293    let bytes_per_pixel = 4u32;
294    let unpadded_bytes_per_row = width * bytes_per_pixel;
295    let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
296        * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
297    let buffer_size = padded_bytes_per_row as u64 * height as u64;
298
299    let buffer = device.create_buffer(&wgpu::BufferDescriptor {
300        label: Some("teksilo_render_test_readback"),
301        size: buffer_size,
302        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
303        mapped_at_creation: false,
304    });
305
306    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
307        label: Some("teksilo_render_test_copy"),
308    });
309    encoder.copy_texture_to_buffer(
310        wgpu::TexelCopyTextureInfo {
311            texture,
312            mip_level: level,
313            origin: wgpu::Origin3d::ZERO,
314            aspect: wgpu::TextureAspect::All,
315        },
316        wgpu::TexelCopyBufferInfo {
317            buffer: &buffer,
318            layout: wgpu::TexelCopyBufferLayout {
319                offset: 0,
320                bytes_per_row: Some(padded_bytes_per_row),
321                rows_per_image: Some(height),
322            },
323        },
324        wgpu::Extent3d {
325            width,
326            height,
327            depth_or_array_layers: 1,
328        },
329    );
330    queue.submit(std::iter::once(encoder.finish()));
331
332    let slice = buffer.slice(..);
333    let (tx, rx) = mpsc::channel();
334    slice.map_async(wgpu::MapMode::Read, move |result| {
335        let _ = tx.send(result);
336    });
337    device
338        .poll(wgpu::PollType::Wait {
339            submission_index: None,
340            timeout: None,
341        })
342        .map_err(|e| ReadbackError::PollFailed(e.to_string()))?;
343    rx.recv()
344        .map_err(|_| ReadbackError::DeviceLost)?
345        .map_err(|e| ReadbackError::MapFailed(e.to_string()))?;
346
347    let mapped = slice
348        .get_mapped_range()
349        .map_err(|e| ReadbackError::MapFailed(e.to_string()))?;
350    let mut pixels = vec![0u8; (width * height * bytes_per_pixel) as usize];
351    for row in 0..height as usize {
352        let src_offset = row * padded_bytes_per_row as usize;
353        let dst_offset = row * unpadded_bytes_per_row as usize;
354        pixels[dst_offset..dst_offset + unpadded_bytes_per_row as usize]
355            .copy_from_slice(&mapped[src_offset..src_offset + unpadded_bytes_per_row as usize]);
356    }
357    drop(mapped);
358    buffer.unmap();
359    Ok(pixels)
360}
361
362/// How many times a GPU device has actually been opened in this process.
363///
364/// Exists only so [`exactly_one_device_is_opened_per_process`] can assert the
365/// invariant the WARP crash depends on.
366#[cfg(test)]
367static DEVICE_OPENS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
368
369#[cfg(test)]
370mod shared_device_tests {
371    use super::*;
372
373    /// Every caller must land on the SAME device, even under contention.
374    ///
375    /// This is the invariant that keeps the offscreen renderer alive on a
376    /// GPU-less Windows host. Two D3D12 WARP devices rasterizing concurrently
377    /// fault inside `d3d10warp.dll`, which no amount of care on our side can
378    /// catch — it is a wild access violation in Microsoft's software
379    /// rasterizer, so the process dies mid-test. The only defence is to never
380    /// open the second device, and that is what this pins.
381    ///
382    /// Asserted through a counter rather than by comparing handles because
383    /// `wgpu::Device` exposes no identity: cloning is the supported way to
384    /// share one, so two clones are indistinguishable from two devices at the
385    /// type level — exactly the confusion that let a second device appear.
386    #[test]
387    fn exactly_one_device_is_opened_per_process() {
388        use std::sync::atomic::Ordering;
389
390        // Race several threads at the initialiser; `OnceLock` plus the init
391        // lock must let exactly one of them reach `open_shared_device`.
392        let barrier = std::sync::Arc::new(std::sync::Barrier::new(4));
393        let handles: Vec<_> = (0..4)
394            .map(|_| {
395                let b = barrier.clone();
396                std::thread::spawn(move || {
397                    b.wait();
398                    pollster::block_on(create_test_renderer("shared-device-test")).is_some()
399                })
400            })
401            .collect();
402        let got: Vec<bool> = handles.into_iter().map(|h| h.join().unwrap()).collect();
403
404        // Either this host has a device and every caller got one, or it has
405        // none and nobody did — never a mix.
406        assert!(
407            got.iter().all(|g| *g) || got.iter().all(|g| !*g),
408            "callers disagreed about whether a GPU exists: {got:?}"
409        );
410
411        let opens = DEVICE_OPENS.load(Ordering::Relaxed);
412        assert_eq!(
413            opens, 1,
414            "the device must be opened exactly once per process, not {opens} times - a second \
415             concurrent WARP device is an access violation inside d3d10warp.dll, not a slowdown"
416        );
417    }
418
419    fn adapter(
420        backend: wgpu::Backend,
421        device_type: wgpu::DeviceType,
422        driver: &str,
423    ) -> wgpu::AdapterInfo {
424        let mut info = wgpu::AdapterInfo::new(device_type, backend);
425        info.driver = driver.into();
426        info
427    }
428
429    #[test]
430    fn lavapipe_is_told_apart_from_other_software_rasterisers() {
431        use wgpu::{Backend, DeviceType};
432        assert!(is_lavapipe(&adapter(
433            Backend::Vulkan,
434            DeviceType::Cpu,
435            "llvmpipe"
436        )));
437        // GL llvmpipe reports no driver name; WARP is D3D12.
438        assert!(!is_lavapipe(&adapter(Backend::Gl, DeviceType::Cpu, "")));
439        assert!(!is_lavapipe(&adapter(Backend::Dx12, DeviceType::Cpu, "")));
440        assert!(!is_lavapipe(&adapter(
441            Backend::Vulkan,
442            DeviceType::IntegratedGpu,
443            "radv"
444        )));
445    }
446
447    #[test]
448    fn the_adapter_requirement_fails_only_when_it_is_unmet() {
449        use wgpu::{Backend, DeviceType};
450        let lavapipe = adapter(Backend::Vulkan, DeviceType::Cpu, "llvmpipe");
451        let radv = adapter(Backend::Vulkan, DeviceType::IntegratedGpu, "radv");
452
453        assert!(check_adapter_requirement(None, None).is_ok());
454        assert!(check_adapter_requirement(None, Some(&radv)).is_ok());
455        assert!(check_adapter_requirement(Some("any"), Some(&radv)).is_ok());
456        assert!(check_adapter_requirement(Some("lavapipe"), Some(&lavapipe)).is_ok());
457
458        assert!(check_adapter_requirement(Some("any"), None).is_err());
459        assert!(check_adapter_requirement(Some("lavapipe"), None).is_err());
460        let wrong = check_adapter_requirement(Some("lavapipe"), Some(&radv)).unwrap_err();
461        assert!(
462            wrong.contains("radv"),
463            "the message names the adapter found: {wrong}"
464        );
465        assert!(check_adapter_requirement(Some("vulkan"), Some(&lavapipe)).is_err());
466    }
467}