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}