Skip to main content

agg_gui_wgpu/
gpu.rs

1//! wgpu device + surface bundle for one OS window, and the surface-acquire
2//! recovery policy that goes with it.
3//!
4//! Every native shell needs the same thing: an instance, an adapter, a device
5//! and queue, a non-sRGB surface format, a clamped surface configuration, and a
6//! per-frame `get_current_texture` that recovers from a stale swapchain instead
7//! of painting nothing. That was hand-rolled twice (`demo-wgpu`'s
8//! `native_shell` and `demo-native`'s `gpu`), with the two copies disagreeing
9//! about the `Timeout` case; this module is the single implementation both now
10//! use.
11//!
12//! Frame acquisition, and the retry of a failed `Surface::configure` it
13//! drives, live in the child module `gpu_acquire.rs`; the pure retry policy
14//! is `crate::surface_retry`.
15//!
16//! wasm shells configure their canvas surface through the browser and never
17//! block on an adapter request, so this module is native-only.
18
19use std::sync::{Arc, Mutex};
20use std::time::Duration;
21
22use crate::surface_retry::{ConfigureRetry, DEFAULT_RETRY_BUDGET};
23
24#[path = "gpu_acquire.rs"]
25mod acquire;
26pub use acquire::{FrameAcquire, RetryWake, SurfaceError};
27
28/// How badly the caller needs `COPY_SRC` on the surface texture.
29///
30/// `COPY_SRC` is what lets [`crate::WgpuGfxCtx::read_screenshot`] blit the
31/// rendered surface into a staging buffer. Not every surface supports it, so
32/// the caller states whether a screenshot is optional or the point of the run.
33#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
34#[non_exhaustive]
35pub enum CopySrc {
36    /// Never request it — the app has no read-back path.
37    #[default]
38    Never,
39    /// Request it when the surface supports it; carry on quietly if not.
40    /// The read-back path degrades to returning no pixels.
41    IfSupported,
42    /// Fail surface creation when the surface cannot provide it — for a
43    /// headless capture run, where a missing screenshot is the whole failure.
44    Required,
45}
46
47/// Everything [`Gpu::new`] needs beyond the window handle.
48///
49/// Build one with [`GpuConfig::new`] (or [`Default`]) and the `with_*`
50/// setters rather than a struct literal — the struct is `#[non_exhaustive]`
51/// so new knobs can be added without a breaking release:
52///
53/// ```no_run
54/// use agg_gui_wgpu::{CopySrc, GpuConfig};
55/// let cfg = GpuConfig::new("my-app").with_copy_src(CopySrc::IfSupported);
56/// ```
57#[derive(Clone, Copy, Debug)]
58#[non_exhaustive]
59pub struct GpuConfig {
60    /// `wgpu::DeviceDescriptor` label — shows up in backend validation
61    /// messages and GPU captures, so each shell names its own.
62    pub label: &'static str,
63    /// Surface read-back requirement, see [`CopySrc`].
64    pub copy_src: CopySrc,
65    /// Present mode for the swap chain. `AutoVsync` for normal windows.
66    pub present_mode: wgpu::PresentMode,
67    /// Device features requested **when the adapter offers them** — the set is
68    /// masked against `adapter.features()` before `request_device`, so an
69    /// adapter that lacks one still yields a device (the app degrades instead
70    /// of failing to start). For an app renderer that can use, e.g.,
71    /// `FLOAT32_BLENDABLE` when present and fall back when not.
72    pub optional_features: wgpu::Features,
73    /// How long the swap chain may stay continuously unconfigured — every
74    /// `Surface::configure` retry failing — before [`Gpu::try_acquire_frame`]
75    /// gives up with [`SurfaceError`]. Default 10 s. See
76    /// [`GpuConfig::with_surface_retry_budget`].
77    ///
78    /// Measured from the first failure of the run and judged when a retry
79    /// fails. Giving up also needs a minimum number of failed attempts (12,
80    /// about 6.5 s of continuous retrying), so a caller that paused attempts
81    /// past the budget — agg-gui-shell does while its window is minimized
82    /// mid-run —
83    /// still gets a real retry run afterwards rather than an error at its
84    /// first failed retry.
85    pub surface_retry_budget: Duration,
86}
87
88impl Default for GpuConfig {
89    fn default() -> Self {
90        Self {
91            label: "agg-gui-wgpu",
92            copy_src: CopySrc::Never,
93            present_mode: wgpu::PresentMode::AutoVsync,
94            optional_features: wgpu::Features::empty(),
95            surface_retry_budget: DEFAULT_RETRY_BUDGET,
96        }
97    }
98}
99
100impl GpuConfig {
101    /// Default configuration under a shell-specific device label.
102    pub fn new(label: &'static str) -> Self {
103        Self {
104            label,
105            ..Self::default()
106        }
107    }
108
109    pub fn with_copy_src(mut self, copy_src: CopySrc) -> Self {
110        self.copy_src = copy_src;
111        self
112    }
113
114    /// Override the swap-chain present mode (default `AutoVsync`).
115    pub fn with_present_mode(mut self, present_mode: wgpu::PresentMode) -> Self {
116        self.present_mode = present_mode;
117        self
118    }
119
120    /// Request these device features when — and only when — the adapter
121    /// offers them. See [`GpuConfig::optional_features`].
122    pub fn with_optional_features(mut self, features: wgpu::Features) -> Self {
123        self.optional_features = features;
124        self
125    }
126
127    /// Override how long a failing swap chain is retried before
128    /// [`Gpu::try_acquire_frame`] reports [`SurfaceError`] (default 10 s).
129    /// A minimum number of failed attempts is also required, so a very short
130    /// budget still gets roughly 6.5 s of actual retrying. `Duration::MAX`
131    /// retries forever. See [`GpuConfig::surface_retry_budget`].
132    pub fn with_surface_retry_budget(mut self, budget: Duration) -> Self {
133        self.surface_retry_budget = budget;
134        self
135    }
136}
137
138/// Why [`Gpu::new`] could not produce a usable surface.
139#[derive(Debug)]
140#[non_exhaustive]
141pub enum GpuInitError {
142    CreateSurface(wgpu::CreateSurfaceError),
143    RequestAdapter,
144    RequestDevice,
145    /// [`CopySrc::Required`] was asked for and the surface does not offer it.
146    CopySrcUnsupported,
147    /// The surface reported no supported texture formats — a torn-down or
148    /// otherwise unusable surface.
149    NoSurfaceFormats,
150    /// The surface reported no supported composite alpha modes.
151    NoAlphaModes,
152    /// The initial `Surface::configure` failed validation (or the device was
153    /// lost during it). The first configure creates the swap chain rather
154    /// than resizing one, so unlike a later resize this is not a transient
155    /// surface state; it is reported instead of retried. Carries wgpu's
156    /// error text.
157    ConfigureSurface(String),
158}
159
160impl std::fmt::Display for GpuInitError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            Self::CreateSurface(e) => write!(f, "create wgpu surface: {e}"),
164            Self::RequestAdapter => write!(f, "no suitable wgpu adapter"),
165            Self::RequestDevice => write!(f, "could not request a wgpu device"),
166            Self::CopySrcUnsupported => {
167                write!(f, "surface does not support COPY_SRC read-back")
168            }
169            Self::NoSurfaceFormats => write!(f, "surface reports no supported texture formats"),
170            Self::NoAlphaModes => write!(f, "surface reports no supported alpha modes"),
171            Self::ConfigureSurface(e) => write!(f, "configure wgpu surface: {e}"),
172        }
173    }
174}
175
176impl std::error::Error for GpuInitError {}
177
178/// Pick the surface format to configure the swap chain with.
179///
180/// A non-sRGB format is preferred so the renderer's linear-space colour maths
181/// isn't gamma-corrected a second time by the surface; when the surface only
182/// offers sRGB formats we take its own first preference. Pure so the choice is
183/// testable without a live surface.
184fn pick_surface_format(
185    formats: &[wgpu::TextureFormat],
186) -> Result<wgpu::TextureFormat, GpuInitError> {
187    formats
188        .iter()
189        .copied()
190        .find(|f| !f.is_srgb())
191        .or_else(|| formats.first().copied())
192        .ok_or(GpuInitError::NoSurfaceFormats)
193}
194
195/// Pick the composite alpha mode — the surface's first preference.
196fn pick_alpha_mode(
197    modes: &[wgpu::CompositeAlphaMode],
198) -> Result<wgpu::CompositeAlphaMode, GpuInitError> {
199    modes.first().copied().ok_or(GpuInitError::NoAlphaModes)
200}
201
202/// Pick the present mode to configure the swap chain with.
203///
204/// `wgpu` resolves the `Auto*` modes itself against whatever the surface
205/// supports, so they are always safe to request. An explicit mode that the
206/// surface does not list (`Mailbox` on a driver that lacks it, `Immediate`
207/// under a compositor that forces vsync) is a validation error, so it falls
208/// back to `Fifo` — the one mode the spec guarantees every surface supports.
209///
210/// Pure so the fallback is testable without a live surface.
211pub fn pick_present_mode(
212    supported: &[wgpu::PresentMode],
213    requested: wgpu::PresentMode,
214) -> wgpu::PresentMode {
215    use wgpu::PresentMode as P;
216    match requested {
217        P::AutoVsync | P::AutoNoVsync => requested,
218        explicit if supported.contains(&explicit) => explicit,
219        _ => P::Fifo,
220    }
221}
222
223/// Clamp a surface configuration size to `[1, max_dim]` on both axes.
224///
225/// `max_dim` is the device's `max_texture_dimension_2d`. Applied on every
226/// `Surface::configure` so a stray oversized request — an over-large window, or
227/// a corrupted restored window size that slipped through — degrades to the GPU
228/// limit instead of panicking inside wgpu validation.
229pub fn clamp_surface_size(w: u32, h: u32, max_dim: u32) -> (u32, u32) {
230    let max_dim = max_dim.max(1);
231    (w.clamp(1, max_dim), h.clamp(1, max_dim))
232}
233
234/// wgpu device + surface bundle for one OS window.
235pub struct Gpu {
236    device: Arc<wgpu::Device>,
237    queue: Arc<wgpu::Queue>,
238    surface: wgpu::Surface<'static>,
239    surface_format: wgpu::TextureFormat,
240    config: wgpu::SurfaceConfiguration,
241    device_lost: Arc<std::sync::atomic::AtomicBool>,
242    /// Whether the swap chain is configured, and if not, the retry backoff
243    /// and the latest error. wgpu drops the swap chain on a failed
244    /// configure, and acquiring from an unconfigured surface is itself fatal,
245    /// so no frame is acquired until a configure succeeds again. A `Mutex`
246    /// (not a `Cell`) because acquisition takes `&self` and `Gpu` must stay
247    /// `Sync`; it is never contended.
248    retry: Mutex<acquire::RetryState>,
249    /// [`GpuConfig::surface_retry_budget`].
250    retry_budget: Duration,
251}
252
253impl Gpu {
254    /// Create the surface, adapter, device and queue for `target` (typically an
255    /// `Arc<winit::window::Window>`) and configure the swap chain at
256    /// `size` physical pixels, clamped by [`clamp_surface_size`].
257    ///
258    /// A non-sRGB surface format is preferred so the renderer's colour maths —
259    /// which writes linear-space values — isn't gamma-corrected twice by the
260    /// surface.
261    pub fn new(
262        target: impl Into<wgpu::SurfaceTarget<'static>>,
263        size: (u32, u32),
264        config: GpuConfig,
265    ) -> Result<Self, GpuInitError> {
266        let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
267        instance_desc.backends = wgpu::Backends::PRIMARY;
268        let instance = wgpu::Instance::new(instance_desc);
269        let surface = instance
270            .create_surface(target)
271            .map_err(GpuInitError::CreateSurface)?;
272        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
273            power_preference: wgpu::PowerPreference::HighPerformance,
274            compatible_surface: Some(&surface),
275            force_fallback_adapter: false,
276        }))
277        .map_err(|_| GpuInitError::RequestAdapter)?;
278
279        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
280            label: Some(config.label),
281            // Optional features are masked against what the adapter actually
282            // offers, so asking for an absent one degrades instead of failing
283            // `request_device`.
284            required_features: config.optional_features & adapter.features(),
285            required_limits: wgpu::Limits::default(),
286            memory_hints: wgpu::MemoryHints::Performance,
287            experimental_features: wgpu::ExperimentalFeatures::default(),
288            trace: wgpu::Trace::Off,
289        }))
290        .map_err(|_| GpuInitError::RequestDevice)?;
291
292        let caps = surface.get_capabilities(&adapter);
293        let surface_format = pick_surface_format(&caps.formats)?;
294        let alpha_mode = pick_alpha_mode(&caps.alpha_modes)?;
295
296        let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
297        let has_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
298        match config.copy_src {
299            CopySrc::Never => {}
300            CopySrc::IfSupported => {
301                if has_copy_src {
302                    usage |= wgpu::TextureUsages::COPY_SRC;
303                }
304            }
305            CopySrc::Required => {
306                if !has_copy_src {
307                    return Err(GpuInitError::CopySrcUnsupported);
308                }
309                usage |= wgpu::TextureUsages::COPY_SRC;
310            }
311        }
312
313        // Device loss (TDR, driver update, GPU reset, RDP session change) is
314        // reported out-of-band: nothing in the per-frame API returns an error,
315        // so a shell that does not watch this flag silently renders nothing
316        // forever. `Destroyed` is our own teardown, not a fault.
317        let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
318        {
319            let flag = Arc::clone(&device_lost);
320            device.set_device_lost_callback(move |reason, _message| {
321                if reason != wgpu::DeviceLostReason::Destroyed {
322                    flag.store(true, std::sync::atomic::Ordering::Relaxed);
323                }
324            });
325        }
326
327        let (cfg_w, cfg_h) =
328            clamp_surface_size(size.0, size.1, device.limits().max_texture_dimension_2d);
329        let surface_config = wgpu::SurfaceConfiguration {
330            usage,
331            format: surface_format,
332            width: cfg_w,
333            height: cfg_h,
334            present_mode: pick_present_mode(&caps.present_modes, config.present_mode),
335            desired_maximum_frame_latency: 2,
336            alpha_mode,
337            view_formats: vec![],
338        };
339        // The first configure creates the swap chain (`CreateSwapChainForHwnd`
340        // on DX12), not the `ResizeBuffers` that fails transiently. A
341        // validation error here means an unsupported format / alpha mode /
342        // size — a real bug — so fail loudly instead of retrying forever
343        // behind a blank window.
344        acquire::try_configure(&device, &surface, &surface_config, &device_lost)
345            .map_err(GpuInitError::ConfigureSurface)?;
346
347        Ok(Self {
348            device: Arc::new(device),
349            queue: Arc::new(queue),
350            surface,
351            surface_format,
352            config: surface_config,
353            device_lost,
354            retry: Mutex::new(acquire::RetryState::new(ConfigureRetry::configured())),
355            retry_budget: config.surface_retry_budget,
356        })
357    }
358
359    /// Has this device been lost since it was created?
360    ///
361    /// Set from wgpu's device-lost callback (TDR / driver reset / GPU removal
362    /// / RDP session change); our own `Device::destroy` is not counted. A
363    /// lost device cannot be revived — every resource created from it is dead
364    /// too — so the only recovery is to build a fresh [`Gpu`] for the same
365    /// window, rebuild the renderer on the new device, and drop any GPU
366    /// resources the app cached. Shells should poll this once per frame.
367    ///
368    /// The C# port polls the same flag at the top of a frame and rebuilds from
369    /// it in `PlatformWin32/win32/WebGpuControl.cs::TryRecoverDevice`
370    /// (agg-sharp), which is the closest thing to a reference implementation of
371    /// the recovery this flag is meant to drive.
372    pub fn device_lost(&self) -> bool {
373        self.device_lost.load(std::sync::atomic::Ordering::Relaxed)
374    }
375
376    pub fn device(&self) -> &Arc<wgpu::Device> {
377        &self.device
378    }
379
380    pub fn queue(&self) -> &Arc<wgpu::Queue> {
381        &self.queue
382    }
383
384    pub fn surface(&self) -> &wgpu::Surface<'static> {
385        &self.surface
386    }
387
388    pub fn surface_format(&self) -> wgpu::TextureFormat {
389        self.surface_format
390    }
391
392    /// The live swap-chain configuration — `width` / `height` are the clamped
393    /// physical pixel size the shell should hand to layout and `WgpuGfxCtx`.
394    pub fn config(&self) -> &wgpu::SurfaceConfiguration {
395        &self.config
396    }
397
398    /// Reconfigure the swap chain for a new physical size. A zero-sized
399    /// (minimized) window is ignored — there is no presentable surface then,
400    /// and wgpu rejects a zero extent.
401    ///
402    /// A failed configure (DX12 `ResizeBuffers` rejecting a window Windows
403    /// is still settling) does not panic: it is recorded, logged, and retried
404    /// by [`Self::try_acquire_frame`] with backoff. A new size is new
405    /// information, so it is configured immediately even while an earlier
406    /// failure is backing off.
407    pub fn resize(&mut self, w: u32, h: u32) {
408        if w == 0 || h == 0 {
409            return;
410        }
411        let (w, h) = clamp_surface_size(w, h, self.device.limits().max_texture_dimension_2d);
412        self.config.width = w;
413        self.config.height = h;
414        self.configure_and_record(web_time::Instant::now());
415    }
416}
417
418/// How to handle the result of `Surface::get_current_texture`.
419#[derive(Clone, Copy, PartialEq, Eq, Debug)]
420#[non_exhaustive]
421pub enum SurfaceAcquire {
422    /// Texture is usable — render into it.
423    Present,
424    /// The swapchain is stale or gone (`Outdated`/`Lost`): reconfigure the
425    /// surface and try once more THIS frame.
426    Reconfigure,
427    /// Transient (`Timeout`): skip the frame, but ask for another one.
428    SkipAndRetry,
429    /// Skip the frame with no follow-up (`Occluded`/`Validation`): the window
430    /// is not visible / the app must fix the validation error, and a
431    /// self-requested redraw would just burn the CPU.
432    Skip,
433}
434
435/// Decide how to handle a surface-acquire status. A pure function so the
436/// recovery policy is unit-testable without a live GPU surface (the
437/// no-payload variants are constructible in tests).
438///
439/// `Outdated`/`Lost` fire right after a window resize reconfigures the
440/// swapchain, and after a GPU driver reset (TDR), a display-mode change, or an
441/// RDP reconnect. Treating them as a plain skip leaves a reactive shell
442/// (`ControlFlow::Wait` whenever `wants_draw()` is false) frozen or black until
443/// some unrelated event requests another redraw — the resize-black-screen
444/// regression. wgpu documents both as "reconfigure the surface and try again".
445pub fn surface_acquire_action(status: &wgpu::CurrentSurfaceTexture) -> SurfaceAcquire {
446    use wgpu::CurrentSurfaceTexture as T;
447    match status {
448        T::Success(_) | T::Suboptimal(_) => SurfaceAcquire::Present,
449        T::Outdated | T::Lost => SurfaceAcquire::Reconfigure,
450        T::Timeout => SurfaceAcquire::SkipAndRetry,
451        T::Occluded | T::Validation => SurfaceAcquire::Skip,
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::{
458        clamp_surface_size, pick_alpha_mode, pick_present_mode, pick_surface_format,
459        surface_acquire_action, GpuInitError, SurfaceAcquire,
460    };
461    use wgpu::CurrentSurfaceTexture as T;
462
463    #[test]
464    fn stale_swapchain_reconfigures_instead_of_skipping() {
465        // The resize-black-screen regression, and the frozen-window case after
466        // a driver reset (TDR) / display-mode change / RDP reconnect: both must
467        // drive a reconfigure-and-retry, NOT a silent skip.
468        assert_eq!(
469            surface_acquire_action(&T::Outdated),
470            SurfaceAcquire::Reconfigure
471        );
472        assert_eq!(
473            surface_acquire_action(&T::Lost),
474            SurfaceAcquire::Reconfigure
475        );
476    }
477
478    #[test]
479    fn timeout_skips_the_frame_but_asks_for_another() {
480        // Timeout is transient: the next acquire usually succeeds, so a
481        // reactive loop has to be woken back up or it waits forever.
482        assert_eq!(
483            surface_acquire_action(&T::Timeout),
484            SurfaceAcquire::SkipAndRetry
485        );
486    }
487
488    #[test]
489    fn occluded_and_validation_skip_without_self_requested_redraw() {
490        assert_eq!(surface_acquire_action(&T::Occluded), SurfaceAcquire::Skip);
491        assert_eq!(surface_acquire_action(&T::Validation), SurfaceAcquire::Skip);
492    }
493
494    #[test]
495    fn empty_capability_lists_are_an_error_not_a_panic() {
496        // A surface that reports no formats / no alpha modes is a broken or
497        // torn-down surface (headless RDP session, adapter lost mid-init).
498        // Indexing `[0]` there took the whole app down; callers get an error.
499        assert!(matches!(
500            pick_surface_format(&[]),
501            Err(GpuInitError::NoSurfaceFormats)
502        ));
503        assert!(matches!(
504            pick_alpha_mode(&[]),
505            Err(GpuInitError::NoAlphaModes)
506        ));
507    }
508
509    #[test]
510    fn non_srgb_format_is_preferred_and_first_is_the_fallback() {
511        use wgpu::TextureFormat as F;
512        // The renderer writes linear-space colour, so an sRGB surface would
513        // gamma-correct it twice — prefer any non-sRGB format on offer.
514        assert_eq!(
515            pick_surface_format(&[F::Bgra8UnormSrgb, F::Bgra8Unorm]).unwrap(),
516            F::Bgra8Unorm
517        );
518        // All-sRGB surface: fall back to the surface's own preference (first).
519        assert_eq!(
520            pick_surface_format(&[F::Bgra8UnormSrgb, F::Rgba8UnormSrgb]).unwrap(),
521            F::Bgra8UnormSrgb
522        );
523    }
524
525    #[test]
526    fn alpha_mode_takes_the_surface_preference() {
527        use wgpu::CompositeAlphaMode as A;
528        assert_eq!(
529            pick_alpha_mode(&[A::Opaque, A::PreMultiplied]).unwrap(),
530            A::Opaque
531        );
532    }
533
534    #[test]
535    fn unsupported_present_mode_falls_back_to_fifo() {
536        use wgpu::PresentMode as P;
537        // Only Fifo on offer (the guaranteed-everywhere mode): an explicit
538        // Mailbox/Immediate request would be a validation error.
539        assert_eq!(pick_present_mode(&[P::Fifo], P::Mailbox), P::Fifo);
540        assert_eq!(pick_present_mode(&[P::Fifo], P::Immediate), P::Fifo);
541        // Supported explicit modes pass through.
542        assert_eq!(
543            pick_present_mode(&[P::Fifo, P::Mailbox], P::Mailbox),
544            P::Mailbox
545        );
546        // wgpu resolves the Auto modes itself, so they are never rewritten —
547        // they do not appear in `caps.present_modes`.
548        assert_eq!(pick_present_mode(&[P::Fifo], P::AutoVsync), P::AutoVsync);
549        assert_eq!(
550            pick_present_mode(&[P::Fifo], P::AutoNoVsync),
551            P::AutoNoVsync
552        );
553        // A surface that reports nothing at all still yields a legal mode.
554        assert_eq!(pick_present_mode(&[], P::Immediate), P::Fifo);
555    }
556
557    #[test]
558    fn optional_features_default_empty_and_build() {
559        let cfg = super::GpuConfig::new("t");
560        assert_eq!(cfg.optional_features, wgpu::Features::empty());
561        let cfg = cfg.with_optional_features(wgpu::Features::FLOAT32_BLENDABLE);
562        assert_eq!(cfg.optional_features, wgpu::Features::FLOAT32_BLENDABLE);
563        // The request mask: an adapter without the feature yields an empty
564        // request rather than a failed `request_device`.
565        assert_eq!(
566            cfg.optional_features & wgpu::Features::empty(),
567            wgpu::Features::empty()
568        );
569    }
570
571    #[test]
572    fn surface_retry_budget_defaults_to_ten_seconds_and_builds() {
573        use std::time::Duration;
574        let cfg = super::GpuConfig::new("t");
575        assert_eq!(cfg.surface_retry_budget, Duration::from_secs(10));
576        let cfg = cfg.with_surface_retry_budget(Duration::from_secs(3));
577        assert_eq!(cfg.surface_retry_budget, Duration::from_secs(3));
578    }
579
580    #[test]
581    fn surface_size_clamped_to_max_dim() {
582        assert_eq!(clamp_surface_size(10224, 5925, 8192), (8192, 5925));
583        assert_eq!(clamp_surface_size(0, 0, 8192), (1, 1));
584        assert_eq!(clamp_surface_size(1280, 720, 8192), (1280, 720));
585        // Degenerate zero limit still yields a valid 1x1.
586        assert_eq!(clamp_surface_size(100, 100, 0), (1, 1));
587    }
588}