Skip to main content

frust_gpu/
context.rs

1//! Instance, adapter and lazy logical-device creation: [`RenderContext`] and
2//! [`DeviceHandle`].
3//!
4//! This is the crate's entry point — everything else in `frust-gpu` consumes a
5//! [`DeviceHandle`] rather than reaching for a `wgpu::Adapter` itself. A
6//! [`RenderContext`] owns one `wgpu::Instance` and at most one logical device,
7//! created lazily on the first surface (or the first [`RenderContext::device`]
8//! call) and then reused: a logical device is display-independent, so surface
9//! loss and recreation (rotation, backgrounding) must not rebuild it.
10//!
11//! This is also the type `frust-render` re-exports as its own
12//! `frust_render::RenderContext`, which is what every platform shell holds: the
13//! device/surface foundation lives here, and the renderer above adds only the
14//! render-path decisions specific to how a frame is drawn.
15//!
16//! Surface creation itself lives in [`crate::surface`] and the raw-pointer
17//! constructors in [`crate::lifecycle`]; [`RenderContext::create_render_surface`]
18//! is the one entry point that ties them to a device.
19//!
20//! # Pure decision vs. platform lookup
21//!
22//! Every environment-sensitive choice here is split into a pure function taking
23//! the environment as an argument (`effective_instance_flags`,
24//! [`effective_limits`], `device_features`, [`decide_log_action`]) plus a
25//! separate lookup that answers what the environment actually is
26//! (`is_android_emulator`, [`is_ios_simulator`]). Only the lookups are
27//! platform-gated, so the policies stay unit-testable on any host with no GPU
28//! and no mobile target in the loop — the same split
29//! [`crate::caps::TierCaps::probe`]/[`crate::caps::TierCaps::fake`] gives
30//! adapter capabilities.
31//!
32//! # Two mitigations worth knowing about
33//!
34//! - The Android **emulator** cannot survive `wgpu::InstanceFlags::DEBUG`, so
35//!   the instance is built with those flags stripped there and nowhere else
36//!   (`effective_instance_flags`).
37//! - The iOS **Simulator** misreports its uniform-buffer alignment, so a device
38//!   request made there is forced back up to 256 bytes ([`effective_limits`]).
39
40use std::sync::atomic::{AtomicU32, Ordering};
41use std::sync::{Arc, OnceLock};
42
43use anyhow::{Result, anyhow};
44
45use crate::caps::{DownlevelProfile, TierCaps};
46use crate::surface::{
47    ConfiguredSurface, SurfaceAlphaRequest, SurfaceFactory, resolve_alpha_mode,
48    select_surface_format,
49};
50
51/// Default `wgpu::Device` debug label, used when a caller supplies no
52/// [`ContextOptions::device_label`] of its own.
53const DEFAULT_DEVICE_LABEL: &str = "frust-gpu device";
54
55/// How a [`RenderContext`] should build its instance and request its device.
56///
57/// Every field has a working default, so `ContextOptions::default()` is the
58/// ordinary construction — a field exists here only where a host genuinely has
59/// a choice to make, and [`RenderContext::new`] takes the defaults.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct ContextOptions {
62    /// Debug label attached to the created `wgpu::Device`, surfaced by graphics
63    /// debuggers and in validation messages.
64    pub device_label: String,
65    /// Backends the `wgpu::Instance` is restricted to. `None` — the default —
66    /// takes `wgpu::Backends::from_env()` (the `WGPU_BACKEND` knob) and falls
67    /// back to every backend compiled in, which is what a shell wants. A
68    /// headless caller that must pin one backend regardless of the ambient
69    /// environment sets it explicitly.
70    pub backends: Option<wgpu::Backends>,
71}
72
73impl Default for ContextOptions {
74    fn default() -> Self {
75        Self {
76            device_label: DEFAULT_DEVICE_LABEL.to_string(),
77            backends: None,
78        }
79    }
80}
81
82/// A logical device, the adapter it was created from, the queue that executes
83/// its command buffers, and the capabilities that adapter reported.
84///
85/// Cheap to clone: `wgpu`'s `Adapter`/`Device`/`Queue` are all `Arc`-backed
86/// handles to one underlying object, so a clone is another handle to the *same*
87/// device rather than a second device. Clone it freely to hand a subsystem the
88/// device it needs instead of threading a `&RenderContext` borrow through it.
89#[derive(Clone, Debug)]
90pub struct DeviceHandle {
91    /// The adapter the device was created from.
92    pub adapter: wgpu::Adapter,
93    /// The logical device.
94    pub device: wgpu::Device,
95    /// The queue that executes this device's command buffers.
96    pub queue: wgpu::Queue,
97    /// What [`Self::adapter`] reported at device-creation time. Captured once
98    /// so downstream pipeline/atlas decisions read plain data instead of
99    /// re-probing the adapter.
100    pub caps: TierCaps,
101    /// The first uncaptured error this device raised, latched by the handler
102    /// installed in [`create_device`]. See [`Self::first_uncaptured_error`].
103    first_uncaptured_error: Arc<OnceLock<String>>,
104}
105
106impl DeviceHandle {
107    /// The **first** uncaptured `wgpu` error this device ever raised, or `None`
108    /// if it has raised none.
109    ///
110    /// Latched, never overwritten: a frame loop polling this wants the error
111    /// that started the trouble, not the last one in a storm the first one
112    /// caused. It is also the only programmatic view of an uncaptured error a
113    /// caller gets — the handler otherwise only logs (see
114    /// [`decide_log_action`]) — so a host can degrade or report instead of
115    /// silently rendering nothing every frame.
116    ///
117    /// Deliberately not clearable: "this device has seen an uncaptured error"
118    /// is a property of the device, and a device that has raised one is not
119    /// reliably recoverable by forgetting about it.
120    pub fn first_uncaptured_error(&self) -> Option<&str> {
121        self.first_uncaptured_error.get().map(String::as_str)
122    }
123}
124
125/// Owns the `wgpu::Instance` and the single logical device this crate's
126/// consumers render with.
127///
128/// A single `RenderContext` is shared across every surface a shell creates
129/// (frust is single-window); the device is created lazily on the first surface
130/// and reused across surface loss/recreation (rotation, backgrounding) since a
131/// logical device is display-independent. [`RenderContext::new`] performs no
132/// adapter enumeration at all, so a host may build one early (before it has a
133/// window, or on a thread that will never render) and pay for the device only
134/// at the first surface — or at an explicit
135/// [`ensure_device_headless`](Self::ensure_device_headless) pre-init.
136pub struct RenderContext {
137    instance: wgpu::Instance,
138    options: ContextOptions,
139    /// `None` until a surface (or an explicit pre-init) creates the device.
140    device: Option<DeviceHandle>,
141}
142
143impl Default for RenderContext {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl RenderContext {
150    /// Creates a context with a fresh wgpu `Instance` and no device yet
151    /// (the device is created lazily on first surface creation).
152    ///
153    /// The instance flags come from the build configuration
154    /// (`InstanceFlags::from_build_config`, which turns `DEBUG`/`VALIDATION` on
155    /// in debug builds) plus the standard `WGPU_*` environment overrides. When
156    /// actually running on an Android *emulator* they are then run through
157    /// `effective_instance_flags`, which strips `DEBUG`/`VALIDATION`: the
158    /// `DEBUG` flag makes wgpu enable `VK_EXT_debug_utils` and set object-name
159    /// labels via `vkSetDebugUtilsObjectNameEXT`, and the emulator's gfxstream
160    /// Vulkan HAL (`vulkan.ranchu.so`) segfaults inside that entry point during
161    /// adapter enumeration (observed crash: `#00 vulkan.ranchu.so
162    /// vk_common_SetDebugUtilsObjectNameEXT`) — the same class of debug-utils
163    /// fragility a MoltenVK Vulkan backend is also known to have. Debug object
164    /// labels are only a developer convenience, so dropping them on the
165    /// emulator is a safe way to keep GPU bring-up alive there while leaving
166    /// physical devices' validation safety net — and desktop behavior —
167    /// untouched.
168    pub fn new() -> Self {
169        Self::with_options(ContextOptions::default())
170    }
171
172    /// [`Self::new`] with an explicit [`ContextOptions`] — a headless harness
173    /// that must label its device or pin one backend regardless of the ambient
174    /// environment. Every shell takes `new()`'s defaults instead.
175    pub fn with_options(options: ContextOptions) -> Self {
176        let backends = options
177            .backends
178            .unwrap_or_else(|| wgpu::Backends::from_env().unwrap_or_default());
179        let build_flags = wgpu::InstanceFlags::from_build_config().with_env();
180        #[cfg(target_os = "android")]
181        let flags = effective_instance_flags(build_flags, is_android_emulator());
182        #[cfg(not(target_os = "android"))]
183        let flags = build_flags;
184        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
185            display: None,
186            backends,
187            flags,
188            memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
189            backend_options: wgpu::BackendOptions::from_env_or_default(),
190        });
191        Self {
192            instance,
193            options,
194            device: None,
195        }
196    }
197
198    /// A cloneable [`SurfaceFactory`] sharing this context's wgpu `Instance`,
199    /// for creating a [`DetachedSurface`](crate::surface::DetachedSurface) on
200    /// the windowing/main thread when the context itself lives on the render
201    /// thread (the render-thread split). The surface a clone produces stays
202    /// compatible with the device this context creates, since both share one
203    /// Arc-backed instance.
204    pub fn surface_factory(&self) -> SurfaceFactory {
205        SurfaceFactory::new(&self.instance)
206    }
207
208    /// This context's wgpu `Instance`, for the two raw-pointer surface
209    /// constructors in [`crate::lifecycle`] (the mobile shells' path, which
210    /// receives an `ANativeWindow*`/`CAMetalLayer*` rather than a window
211    /// handle a [`SurfaceFactory`] could take).
212    ///
213    /// Hidden from the rendered docs rather than made private, on the same
214    /// grounds as
215    /// [`DetachedSurface::into_surface`](crate::surface::DetachedSurface::into_surface):
216    /// the renderer crate above this one is the intended (and only) caller,
217    /// and no layer above *it* may re-export this accessor.
218    #[doc(hidden)]
219    pub fn instance(&self) -> &wgpu::Instance {
220        &self.instance
221    }
222
223    /// The single logical device, panicking if no surface has created it yet.
224    ///
225    /// Only called from the renderer's install/resize/render paths, all of
226    /// which run strictly after a `create_*_surface`, so the device is always
227    /// present. Use [`Self::device`] for the fallible, creating form.
228    ///
229    /// Hidden from the rendered docs for the same reason as [`Self::instance`].
230    #[doc(hidden)]
231    pub fn device_handle(&self) -> &DeviceHandle {
232        self.device
233            .as_ref()
234            .expect("device must be created before it is used (surface creation creates it)")
235    }
236
237    /// The logical device, creating it on first call and returning the same one
238    /// afterwards.
239    ///
240    /// Adapter selection goes through
241    /// `wgpu::util::initialize_adapter_from_env_or_default`, so
242    /// `WGPU_ADAPTER_NAME`/`WGPU_POWER_PREF` pick the adapter on a host with
243    /// more than one — the only way to pin a specific GPU on a multi-adapter
244    /// machine.
245    ///
246    /// # Errors
247    ///
248    /// When no adapter is available at all, or when the device request the
249    /// adapter's own limits were computed for is nonetheless refused. Both are
250    /// terminal for GPU rendering; neither is retryable by calling again.
251    pub async fn device(&mut self) -> Result<&DeviceHandle> {
252        self.ensure_device_headless().await?;
253        Ok(self
254            .device
255            .as_ref()
256            .expect("device was just created or already present"))
257    }
258
259    /// What the live device's adapter reported, or `None` while the device is
260    /// still uncreated — capabilities are an adapter's answer, and no adapter
261    /// has been selected before the first device creation.
262    pub fn caps(&self) -> Option<&TierCaps> {
263        self.device.as_ref().map(|handle| &handle.caps)
264    }
265
266    /// Whether the live device was created with `wgpu::Features::PIPELINE_CACHE`.
267    ///
268    /// wgpu only implements the persisted pipeline cache on Vulkan — every
269    /// Vulkan adapter advertises it (Android, Linux, Windows-on-Vulkan);
270    /// Metal and DX12 adapters never do, so it is absent there and
271    /// [`create_pipeline_cache`](Self::create_pipeline_cache) returns `None` —
272    /// the renderer then behaves exactly as it did before this path existed.
273    /// Panics if no surface (and thus no device) has been created yet.
274    pub fn pipeline_cache_supported(&self) -> bool {
275        self.device_handle()
276            .device
277            .features()
278            .contains(wgpu::Features::PIPELINE_CACHE)
279    }
280
281    /// The adapter fingerprint a persisted pipeline-cache blob is tagged with
282    /// (see [`crate::pipeline_cache`]). Panics if no device has been created yet.
283    pub fn adapter_cache_key(&self) -> String {
284        crate::pipeline_cache::adapter_cache_key(&self.device_handle().adapter.get_info())
285    }
286
287    /// Creates a `wgpu::PipelineCache` for the live device, seeded from a
288    /// previously persisted, framed `blob` when it validates for this adapter.
289    ///
290    /// Returns `None` when the device lacks `PIPELINE_CACHE` support (Metal/
291    /// desktop) — the renderer then runs its original, cache-less path. A `blob`
292    /// that fails framing/adapter validation
293    /// ([`crate::pipeline_cache::unframe`]) is discarded and the cache starts
294    /// empty; a `None` `blob` is a cold start.
295    ///
296    /// Hidden from the rendered docs for the same reason as [`Self::instance`].
297    ///
298    /// # Safety
299    ///
300    /// This is the sole sanctioned unsafe site in this module, and the
301    /// obligation is the caller's, not something this method can fully close
302    /// on its own: `unframe`'s magic-tag-plus-adapter-fingerprint check proves
303    /// only that `blob` was framed by `frust-gpu`'s own framing for *this*
304    /// adapter — provenance by convention, not a proof of the actual wgpu
305    /// contract on [`wgpu::Device::create_pipeline_cache`], which requires a
306    /// non-`None` `data` to have come from a prior `PipelineCache::get_data()`
307    /// on a `pipeline_cache_key`-compatible adapter. The caller must ensure
308    /// `blob` is exactly that: a blob previously produced by this driver's own
309    /// pipeline-cache output for this adapter, as persisted by `frust-render`'s
310    /// caching layer (`SurfaceRenderer::pipeline_cache_data`/
311    /// `set_initial_pipeline_cache_data`). A forged blob that nonetheless
312    /// passes the framing/fingerprint check is undefined behaviour per wgpu's
313    /// contract — `fallback: true` only covers a residual *internal* mismatch
314    /// wgpu itself detects, not a blob that misleads it into misbehaving.
315    #[doc(hidden)]
316    pub unsafe fn create_pipeline_cache(&self, blob: Option<&[u8]>) -> Option<wgpu::PipelineCache> {
317        if !self.pipeline_cache_supported() {
318            return None;
319        }
320        let handle = self.device_handle();
321        let key = crate::pipeline_cache::adapter_cache_key(&handle.adapter.get_info());
322        let data = blob.and_then(|b| crate::pipeline_cache::unframe(b, &key));
323        log::debug!(
324            "frust-gpu: creating wgpu PipelineCache (seed: {})",
325            if data.is_some() {
326                "persisted blob"
327            } else {
328                "empty"
329            }
330        );
331        // SAFETY: see this method's `# Safety` section — `data` has already been
332        // validated against this adapter's fingerprint by `unframe`, and
333        // `fallback: true` turns any residual internal mismatch into a
334        // fall-back-to-empty cache rather than UB.
335        let cache = unsafe {
336            handle
337                .device
338                .create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
339                    label: Some("frust-gpu pipeline cache"),
340                    data,
341                    fallback: true,
342                })
343        };
344        Some(cache)
345    }
346
347    /// Lazily create the logical device compatible with `surface`, requesting
348    /// the adapter's own limits (never `Limits::default()`, which the iOS
349    /// Simulator cannot satisfy) plus the #7057 alignment mitigation. Reuses
350    /// an already-created device when it is compatible with `surface`, so
351    /// surface loss/recreation (rotation, backgrounding) never rebuilds it — and
352    /// so a device the [`ensure_device_headless`](Self::ensure_device_headless)
353    /// pre-init created before any surface existed is adopted here rather than
354    /// rebuilt.
355    ///
356    /// Hidden from the rendered docs for the same reason as [`Self::instance`]:
357    /// the renderer above calls it to have a device in hand before it sizes its
358    /// own per-surface attachments; nothing higher may.
359    #[doc(hidden)]
360    pub async fn ensure_device(&mut self, surface: &wgpu::Surface<'static>) -> Result<()> {
361        if let Some(existing) = &self.device
362            && existing.adapter.is_surface_supported(surface)
363        {
364            return Ok(());
365        }
366        self.device = Some(create_device(&self.instance, &self.options, Some(surface)).await?);
367        Ok(())
368    }
369
370    /// Create the logical device **before any surface exists**, so the wgpu
371    /// instance/adapter/device bring-up can run on a
372    /// background thread kicked at native-library load (`JNI_OnLoad`) and be
373    /// joined by `nativeInit` instead of running serially after `surfaceCreated`.
374    /// Idempotent: a no-op when a device already exists.
375    ///
376    /// # Android singular-adapter assumption
377    ///
378    /// Requesting the adapter with `compatible_surface: None` picks wgpu's
379    /// default adapter rather than one filtered to a specific surface. On Android
380    /// the Vulkan backend exposes a single physical device, so the adapter chosen
381    /// here is the same one a later surface-filtered request would pick, and
382    /// [`ensure_device`](Self::ensure_device)'s `is_surface_supported` reuse check
383    /// accepts it — the surface created at `nativeInit` reuses this device with no
384    /// rebuild. On a hypothetical multi-adapter device where the pre-init adapter
385    /// did *not* support the eventual surface, `ensure_device` simply rebuilds the
386    /// device against that surface (still correct, just without the overlap win).
387    /// This is the sole production caller that passes `None` below; desktop/iOS
388    /// create their device through the surface path and never invoke it.
389    pub async fn ensure_device_headless(&mut self) -> Result<()> {
390        if self.device.is_some() {
391            return Ok(());
392        }
393        self.device = Some(create_device(&self.instance, &self.options, None).await?);
394        Ok(())
395    }
396
397    /// Builds a configured [`ConfiguredSurface`] from a raw wgpu `Surface`,
398    /// creating the logical device if needed.
399    ///
400    /// The result is renderer-agnostic: the swapchain, the configuration it was
401    /// brought up with, and the resolved alpha facts. Whatever per-frame render
402    /// path a renderer pairs with it is that renderer's own business — see
403    /// `frust_render::context`'s `EngineSurface`.
404    ///
405    /// Hidden from the rendered docs for the same reason as [`Self::instance`].
406    #[doc(hidden)]
407    pub async fn create_render_surface(
408        &mut self,
409        surface: wgpu::Surface<'static>,
410        width: u32,
411        height: u32,
412        present_mode: wgpu::PresentMode,
413        alpha: SurfaceAlphaRequest,
414    ) -> Result<ConfiguredSurface> {
415        self.ensure_device(&surface).await?;
416        let handle = self.device_handle();
417
418        let capabilities = surface.get_capabilities(&handle.adapter);
419        // Resolved from the request and the surface's reported caps alone; the
420        // renderer above reads it back off the returned configuration to pick
421        // its own per-frame path.
422        let alpha_mode = resolve_alpha_mode(alpha, &capabilities);
423        // Whichever supported format the surface reports FIRST — the surface's
424        // own preference order, not this module's (see `select_surface_format`).
425        let format = select_surface_format(&capabilities)?;
426
427        Ok(ConfiguredSurface::configure(
428            surface,
429            &handle.device,
430            format,
431            alpha_mode,
432            (width, height),
433            present_mode,
434        ))
435    }
436}
437
438/// Selects an adapter, probes it, and requests the logical device — the body
439/// both [`RenderContext::ensure_device`] and
440/// [`RenderContext::ensure_device_headless`] share.
441///
442/// `compatible_surface` filters adapter selection to one that can present to the
443/// given surface; `None` (the pre-init path, and every headless caller) selects
444/// wgpu's default adapter — see the singular-adapter note on
445/// `ensure_device_headless`. The capability probe, limits mitigation, feature
446/// request and uncaptured-error handler are identical either way: the surface
447/// only ever affected adapter selection, never the device it yields.
448///
449/// A free function rather than a method so it borrows the instance and options
450/// separately from the `device` field the callers assign into.
451async fn create_device(
452    instance: &wgpu::Instance,
453    options: &ContextOptions,
454    compatible_surface: Option<&wgpu::Surface<'static>>,
455) -> Result<DeviceHandle> {
456    let adapter = wgpu::util::initialize_adapter_from_env_or_default(instance, compatible_surface)
457        .await
458        .map_err(|e| anyhow!("frust-gpu: no compatible GPU adapter: {e}"))?;
459
460    let caps = TierCaps::probe(&adapter);
461
462    // The device request is built from the resolved downlevel profile, not
463    // unconditionally from the adapter's raw limits — see `base_device_limits`
464    // for the shared derivation, and `effective_limits` for the iOS Simulator
465    // alignment mitigation layered on top of it.
466    let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
467    let required_limits = effective_limits(base_limits, is_ios_simulator());
468    let required_features =
469        device_features(adapter.features(), &caps, cfg!(feature = "perf-trace"));
470
471    let (device, queue) = adapter
472        .request_device(&wgpu::DeviceDescriptor {
473            label: Some(&options.device_label),
474            required_features,
475            required_limits,
476            ..Default::default()
477        })
478        .await
479        .map_err(|e| anyhow!("frust-gpu: failed to create GPU device: {e}"))?;
480
481    // Route wgpu's uncaptured errors to the log and a latch instead of its
482    // default handler, which aborts the process by panicking ("handling wgpu
483    // errors as fatal by default"). A UI framework must survive a driver's
484    // *transient* GPU error and recover on a later frame rather than crash —
485    // e.g. the Android emulator's SwiftShader path can raise a one-off
486    // swapchain-acquire validation error under load, which used to wedge the
487    // app into a per-frame panic loop (the surface stayed ready and every
488    // subsequent render re-hit the fatal handler). Pairing this with the
489    // `Invalid`-acquire → reconfigure recovery (see [`crate::lifecycle`]) lets
490    // the swapchain rebuild and rendering resume. Across a mobile FFI boundary
491    // the default handler's abort means killing the host app outright. Genuine
492    // API misuse is still surfaced — loudly, at error level — just without
493    // killing the process.
494    //
495    // Two pieces of state, both per-device (captured fresh each time this
496    // closure is installed, i.e. once per logical device) and both behind `Arc`
497    // because `on_uncaptured_error`'s handler must be `Fn`, not `FnMut`:
498    //
499    // - `error_count` drives the log latch ([`decide_log_action`]), so a device
500    //   wedged in a genuine per-frame error storm (as opposed to a one-off
501    //   driver hiccup) cannot flood the log forever.
502    // - `first_error` latches the first error's text for the frame loop to
503    //   poll via [`DeviceHandle::first_uncaptured_error`]. `OnceLock` gives
504    //   exactly first-write-wins with no lock held across the handler body.
505    let error_count = Arc::new(AtomicU32::new(0));
506    let first_error: Arc<OnceLock<String>> = Arc::new(OnceLock::new());
507    let latch = Arc::clone(&first_error);
508    device.on_uncaptured_error(Arc::new(move |error| {
509        let count = error_count.fetch_add(1, Ordering::Relaxed) + 1;
510        let _ = latch.set(error.to_string());
511        match decide_log_action(count) {
512            LogAction::Log => {
513                log::error!("frust-gpu: uncaptured wgpu error: {error}");
514            }
515            LogAction::SuppressionNotice => {
516                log::error!(
517                    "frust-gpu: further uncaptured wgpu errors suppressed \
518                     (total so far: {count})"
519                );
520            }
521            LogAction::Silent { debug_bump } => {
522                if debug_bump {
523                    log::debug!(
524                        "frust-gpu: uncaptured wgpu error count now {count} \
525                         (still suppressed)"
526                    );
527                }
528            }
529        }
530    }));
531
532    Ok(DeviceHandle {
533        adapter,
534        device,
535        queue,
536        caps,
537        first_uncaptured_error: first_error,
538    })
539}
540
541/// Given the resolved downlevel profile and the adapter's own reported
542/// limits, the `wgpu::Limits` a device request should ask for — the pure half
543/// of the derivation this module's `create_device` uses, extracted so both it
544/// and [`test_device_limits`] share exactly one implementation rather than two
545/// that could drift apart.
546///
547/// Under `DownlevelProfile::WebGl2` (a real `Gl` backend, or
548/// `FRUST_ENGINE_DOWNLEVEL=1` rehearsing it) this asks for the GLES-3.0/WebGL2
549/// downlevel default shape — or the override would only relabel a full
550/// desktop device rather than actually exercising it — with
551/// `using_resolution` folding in `adapter_limits`' own texture-dimension
552/// limits so the request never asks for a resolution the adapter cannot
553/// satisfy (the swapchain may need more than the downlevel default allows)
554/// while every other WebGL2 default limit is requested as-is. Under
555/// `DownlevelProfile::Full` — every shipping device — `adapter_limits` is
556/// returned unchanged: feeding the *adapter's* limits (rather than
557/// `Limits::default()`) is what keeps the request from over-asking and
558/// failing on a constrained mobile adapter — or on the iOS Simulator, whose
559/// macOS-Metal-backed device refuses `Limits::default()` outright.
560///
561/// Pure decision logic over plain values, mirroring [`effective_limits`]'s
562/// split of pure decision vs. platform lookup: `adapter_limits` is already
563/// `wgpu::Adapter::limits()`'s output by the time this runs, so the function
564/// itself needs no adapter and is unit-testable on any host with no GPU.
565fn base_device_limits(profile: DownlevelProfile, adapter_limits: wgpu::Limits) -> wgpu::Limits {
566    if profile == DownlevelProfile::WebGl2 {
567        wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits)
568    } else {
569        adapter_limits
570    }
571}
572
573/// The `wgpu::Limits` a test fixture's own `request_device` call should ask
574/// for, given `adapter` and its already-probed `caps` — exactly the
575/// derivation [`create_device`] uses for the production device request
576/// (shared via [`base_device_limits`] plus [`effective_limits`]), so a
577/// fixture requesting these limits can never over-ask relative to what the
578/// production path would request for the very same adapter.
579///
580/// This is the fix for the iOS Simulator's constrained Apple2 Metal profile
581/// (15 inter-stage shader variables, where `wgpu::Limits::default()` demands
582/// 16): a fixture that hard-codes `Limits::default()` panics with
583/// `LimitsExceeded` there even though the production path — which always
584/// requests the adapter's own limits — never would. Every fixture already
585/// probes `TierCaps::probe(&adapter)` before requesting its device, so `caps`
586/// is available at the same call site this replaces.
587///
588/// Public (not test-only/`#[cfg(test)]`) because the fixtures that need it
589/// live in other crates' `tests/` integration binaries and
590/// `frust-testing`'s own `EngineOracle`, none of which can reach a
591/// `#[cfg(test)]` item in this crate.
592pub fn test_device_limits(adapter: &wgpu::Adapter, caps: &TierCaps) -> wgpu::Limits {
593    let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
594    effective_limits(base_limits, is_ios_simulator())
595}
596
597/// Given the build-config-derived instance flags and whether the process is
598/// currently running on an Android emulator, decides the flags wgpu's
599/// `Instance` should actually be created with.
600///
601/// The `DEBUG` flag makes wgpu enable `VK_EXT_debug_utils` and set object-name
602/// labels via `vkSetDebugUtilsObjectNameEXT`, and the emulator's gfxstream
603/// Vulkan HAL (`vulkan.ranchu.so`) segfaults inside that entry point during
604/// adapter enumeration (observed crash: `#00 vulkan.ranchu.so
605/// vk_common_SetDebugUtilsObjectNameEXT`) — the same class of debug-utils
606/// fragility a MoltenVK Vulkan backend is also known to have. Debug object
607/// labels are only a developer convenience, so dropping them on the emulator
608/// keeps GPU bring-up alive there while leaving physical devices' validation
609/// safety net — and desktop behaviour — untouched.
610///
611/// Pure decision logic, kept separate from the platform property lookup in
612/// `is_android_emulator` so it is unit-testable on any host without an Android
613/// target.
614#[cfg_attr(not(target_os = "android"), allow(dead_code))]
615fn effective_instance_flags(flags: wgpu::InstanceFlags, is_emulator: bool) -> wgpu::InstanceFlags {
616    if is_emulator {
617        flags - (wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION)
618    } else {
619        flags
620    }
621}
622
623/// Detects whether the current process is running on an Android emulator
624/// (goldfish/ranchu), as opposed to a physical device, via the standard
625/// `ro.kernel.qemu` system property (`"1"` on emulators, unset/absent on real
626/// hardware). A failed property read is treated as "not an emulator" so
627/// physical devices — and any environment where the property cannot be read —
628/// default to keeping validation on.
629#[cfg(target_os = "android")]
630fn is_android_emulator() -> bool {
631    android_system_properties::AndroidSystemProperties::new()
632        .get("ro.kernel.qemu")
633        .as_deref()
634        == Some("1")
635}
636
637/// The uniform-buffer offset alignment the iOS Simulator's Metal validation
638/// actually enforces, regardless of what the adapter reports.
639const IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT: u32 = 256;
640
641/// Given a base `wgpu::Limits` and whether the process is currently running on
642/// an iOS Simulator, decides the `Limits` a device request should actually use.
643///
644/// Mitigates [wgpu #7057](https://github.com/gfx-rs/wgpu/issues/7057): the iOS
645/// Simulator is macOS-Metal-backed and requires 256-byte
646/// `min_uniform_buffer_offset_alignment`, but wgpu's Metal backend reports the
647/// (lower) iOS-device value, which trips Metal API validation on the simulator.
648/// Physical iOS devices are unaffected and pass `base` through unchanged; a
649/// `base` whose alignment is already at or above 256 is left alone, never
650/// lowered.
651///
652/// Upstream [gfx-rs/wgpu PR #10189](https://github.com/gfx-rs/wgpu/pull/10189)
653/// makes this unnecessary — drop it once a pinned wgpu release contains it.
654///
655/// Pure decision logic, mirroring `effective_instance_flags`'s split of pure
656/// decision vs. platform lookup. It is fed the profile-resolved base limits
657/// (see this module's `create_device`) — the adapter's own limits under
658/// `DownlevelProfile::Full`, the WebGL2 downlevel defaults resolution-folded
659/// with the adapter otherwise — so the device request never over-asks.
660pub fn effective_limits(base: wgpu::Limits, is_ios_simulator: bool) -> wgpu::Limits {
661    if is_ios_simulator
662        && base.min_uniform_buffer_offset_alignment
663            < IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT
664    {
665        wgpu::Limits {
666            min_uniform_buffer_offset_alignment: IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT,
667            ..base
668        }
669    } else {
670        base
671    }
672}
673
674/// Whether this binary is running on the iOS Simulator
675/// (`aarch64-apple-ios-sim` / `x86_64-apple-ios` under the simulator), which
676/// sets `target_abi = "sim"`. Compile-time constant: the simulator mitigation
677/// only needs to apply to simulator builds, never physical-device or desktop
678/// ones.
679pub const fn is_ios_simulator() -> bool {
680    cfg!(all(target_os = "ios", target_abi = "sim"))
681}
682
683/// The `wgpu::Features` a device request opportunistically asks for when the
684/// adapter exposes them.
685///
686/// `PIPELINE_CACHE` alone: it is what
687/// [`create_pipeline_cache`](RenderContext::create_pipeline_cache) needs to
688/// seed the renderer's shader-pipeline compilation from a persisted blob. An
689/// optional feature is only ever *added* when the adapter already offers it,
690/// so this can never turn a working adapter into a failed device request.
691pub fn optional_device_features() -> wgpu::Features {
692    wgpu::Features::PIPELINE_CACHE
693}
694
695/// The `wgpu::Features` a device request must genuinely *require*, given what
696/// the adapter reported ([`TierCaps`]) and whether this build compiled the
697/// `perf-trace` feature in.
698///
699/// The policy is deliberately minimal: **empty** by default. A required feature
700/// is a hard device-creation failure on any adapter lacking it, so asking for
701/// something the crate does not actually need converts a working device into no
702/// device at all. `TIMESTAMP_QUERY` is the single exception — it is what the
703/// GPU timing probes ([`crate::diag::TimestampRing`]) are built on, so a
704/// `perf-trace` build asks for it, and even then only when the adapter offers
705/// it. A device that did not get the feature leaves the ring inert (`gpu_q=0`),
706/// exactly as a build without `perf-trace` does.
707///
708/// A plain `bool` parameter rather than reading `cfg!` internally, so both
709/// branches are unit-testable regardless of which features this crate was
710/// compiled with.
711fn required_features(caps: &TierCaps, perf_trace: bool) -> wgpu::Features {
712    if perf_trace && caps.has_timestamp_query {
713        wgpu::Features::TIMESTAMP_QUERY
714    } else {
715        wgpu::Features::empty()
716    }
717}
718
719/// The complete `required_features` set a device request is made with: the
720/// opportunistic set ([`optional_device_features`]) narrowed to what
721/// `adapter_features` actually offers, plus the genuinely required set
722/// ([`required_features`]).
723///
724/// Both halves are adapter-conditioned, so this can never turn a working
725/// adapter into a failed device request — which is why the policy is split out
726/// of [`create_device`] as a pure function over plain values.
727fn device_features(
728    adapter_features: wgpu::Features,
729    caps: &TierCaps,
730    perf_trace: bool,
731) -> wgpu::Features {
732    (adapter_features & optional_device_features()) | required_features(caps, perf_trace)
733}
734
735/// Number of uncaptured `wgpu` errors logged at error level per device before
736/// the handler latches into suppression. A single flaky frame under a driver
737/// hiccup (e.g. the Android emulator's SwiftShader path) is expected to surface
738/// a handful of errors; past this the process is either wedged in a genuine
739/// per-frame error storm or the driver is fundamentally broken, and re-logging
740/// every single one would flood the log without adding information.
741const MAX_LOGGED_UNCAPTURED_ERRORS: u32 = 5;
742
743/// How often (in error count) a latched handler bumps a debug-level "still
744/// happening" line once past [`MAX_LOGGED_UNCAPTURED_ERRORS`] and the one
745/// suppression notice. Debug level (not error) because this is diagnostic noise
746/// for someone actively investigating, not an actionable signal.
747const UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD: u32 = 100;
748
749/// What the uncaptured-error handler should do for the `count`-th uncaptured
750/// error (1-indexed) it has observed on a given device.
751///
752/// Also the latch the renderer above reuses for its own per-frame event that
753/// can reproduce every vsync (an engine frame refusal), so the two report at
754/// the same cadence.
755#[derive(Clone, Copy, Debug, PartialEq, Eq)]
756pub enum LogAction {
757    /// One of the first `MAX_LOGGED_UNCAPTURED_ERRORS`: log the error itself
758    /// at error level.
759    Log,
760    /// The first error past the cap: log one suppression notice (naming the
761    /// running total) instead of the error itself.
762    SuppressionNotice,
763    /// Past the cap and past the suppression notice: stay silent, except a
764    /// periodic debug-level count bump when `debug_bump` is set.
765    Silent { debug_bump: bool },
766}
767
768/// Pure latch policy for the uncaptured-error handler (see [`LogAction`]).
769///
770/// Split out of the handler closure in this module's `create_device` so the
771/// discipline — log the first few, announce the latch once, then go quiet
772/// except an occasional debug bump — is unit-testable without a GPU or a real
773/// `wgpu::Error`.
774pub fn decide_log_action(count: u32) -> LogAction {
775    if count <= MAX_LOGGED_UNCAPTURED_ERRORS {
776        LogAction::Log
777    } else if count == MAX_LOGGED_UNCAPTURED_ERRORS + 1 {
778        LogAction::SuppressionNotice
779    } else {
780        LogAction::Silent {
781            debug_bump: count.is_multiple_of(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
782        }
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::caps::DownlevelProfile;
790
791    #[test]
792    fn emulator_strips_debug_and_validation() {
793        let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
794        let flags = effective_instance_flags(build_flags, true);
795        assert!(!flags.contains(wgpu::InstanceFlags::DEBUG));
796        assert!(!flags.contains(wgpu::InstanceFlags::VALIDATION));
797    }
798
799    #[test]
800    fn physical_device_keeps_debug_and_validation() {
801        let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
802        let flags = effective_instance_flags(build_flags, false);
803        assert!(flags.contains(wgpu::InstanceFlags::DEBUG));
804        assert!(flags.contains(wgpu::InstanceFlags::VALIDATION));
805    }
806
807    #[test]
808    fn emulator_with_no_debug_flags_stays_empty() {
809        let flags = effective_instance_flags(wgpu::InstanceFlags::empty(), true);
810        assert!(flags.is_empty());
811    }
812
813    #[test]
814    fn ios_simulator_bumps_alignment_to_256() {
815        let base = wgpu::Limits::default();
816        let limits = effective_limits(base.clone(), true);
817        assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
818        // Nothing else about the base limits should change.
819        assert_eq!(
820            wgpu::Limits {
821                min_uniform_buffer_offset_alignment: base.min_uniform_buffer_offset_alignment,
822                ..limits.clone()
823            },
824            base
825        );
826    }
827
828    #[test]
829    fn non_simulator_leaves_limits_untouched() {
830        let base = wgpu::Limits::default();
831        let limits = effective_limits(base.clone(), false);
832        assert_eq!(limits, base);
833    }
834
835    #[test]
836    fn base_already_at_or_above_256_is_not_lowered() {
837        let base = wgpu::Limits {
838            min_uniform_buffer_offset_alignment: 512,
839            ..wgpu::Limits::default()
840        };
841        let limits = effective_limits(base.clone(), true);
842        assert_eq!(limits.min_uniform_buffer_offset_alignment, 512);
843        assert_eq!(limits, base);
844    }
845
846    #[test]
847    fn simulator_alignment_uses_real_adapter_limits_not_defaults() {
848        // A low-alignment adapter (the #7057 shape: Metal reports a lower
849        // alignment than the simulator driver actually enforces) is bumped to
850        // 256 while every other adapter-reported limit is preserved — the
851        // whole point of feeding *adapter* limits rather than `Limits::default`.
852        let adapter = wgpu::Limits {
853            min_uniform_buffer_offset_alignment: 64,
854            max_texture_dimension_2d: 4096,
855            ..wgpu::Limits::default()
856        };
857        let limits = effective_limits(adapter.clone(), true);
858        assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
859        assert_eq!(limits.max_texture_dimension_2d, 4096);
860    }
861
862    #[test]
863    fn first_n_uncaptured_errors_log() {
864        for count in 1..=MAX_LOGGED_UNCAPTURED_ERRORS {
865            assert_eq!(
866                decide_log_action(count),
867                LogAction::Log,
868                "expected Log at count={count}"
869            );
870        }
871    }
872
873    #[test]
874    fn nplus1_uncaptured_error_suppresses() {
875        assert_eq!(
876            decide_log_action(MAX_LOGGED_UNCAPTURED_ERRORS + 1),
877            LogAction::SuppressionNotice
878        );
879    }
880
881    #[test]
882    fn further_uncaptured_errors_stay_silent_between_debug_bumps() {
883        let past_notice = MAX_LOGGED_UNCAPTURED_ERRORS + 2;
884        assert_eq!(
885            decide_log_action(past_notice),
886            LogAction::Silent { debug_bump: false }
887        );
888    }
889
890    #[test]
891    fn uncaptured_error_debug_bump_is_periodic() {
892        assert_eq!(
893            decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
894            LogAction::Silent { debug_bump: true }
895        );
896        assert_eq!(
897            decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD * 2),
898            LogAction::Silent { debug_bump: true }
899        );
900        assert_eq!(
901            decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD + 1),
902            LogAction::Silent { debug_bump: false }
903        );
904    }
905
906    #[test]
907    fn default_build_requires_no_device_features() {
908        let caps = TierCaps::fake(DownlevelProfile::Full);
909        assert!(caps.has_timestamp_query, "fixture precondition");
910        assert_eq!(required_features(&caps, false), wgpu::Features::empty());
911    }
912
913    #[test]
914    fn perf_trace_build_requires_timestamp_query_when_offered() {
915        let caps = TierCaps::fake(DownlevelProfile::Full);
916        assert_eq!(
917            required_features(&caps, true),
918            wgpu::Features::TIMESTAMP_QUERY
919        );
920    }
921
922    #[test]
923    fn perf_trace_build_requires_nothing_when_adapter_lacks_timestamp_query() {
924        // An adapter that cannot do timestamp queries must still yield a
925        // device: a required feature it lacks would fail the request outright,
926        // so `perf-trace` degrades to no probes rather than to no GPU.
927        let caps = TierCaps::fake(DownlevelProfile::WebGl2);
928        assert!(!caps.has_timestamp_query, "fixture precondition");
929        assert_eq!(required_features(&caps, true), wgpu::Features::empty());
930    }
931
932    #[test]
933    fn pipeline_cache_is_requested_whenever_the_adapter_offers_it() {
934        // The shipped Android/Vulkan warm-start path: `PIPELINE_CACHE` is
935        // opportunistically added, which is what makes
936        // `RenderContext::pipeline_cache_supported` true there and the
937        // persisted-blob seed possible at all. Dropping it would silently kill
938        // the warm start on every Vulkan adapter.
939        let caps = TierCaps::fake(DownlevelProfile::Full);
940        let adapter = wgpu::Features::PIPELINE_CACHE | wgpu::Features::DEPTH_CLIP_CONTROL;
941        assert_eq!(
942            device_features(adapter, &caps, false),
943            wgpu::Features::PIPELINE_CACHE,
944            "an offered optional feature is taken, and nothing else is"
945        );
946    }
947
948    #[test]
949    fn an_adapter_without_pipeline_cache_is_never_asked_for_it() {
950        // Metal/DX12: the feature is absent, so the request must not name it —
951        // a required feature the adapter lacks is a hard device-creation
952        // failure, i.e. no GPU at all rather than merely no persisted cache.
953        let caps = TierCaps::fake(DownlevelProfile::Full);
954        assert_eq!(
955            device_features(wgpu::Features::empty(), &caps, false),
956            wgpu::Features::empty()
957        );
958    }
959
960    #[test]
961    fn a_perf_trace_build_asks_for_both_halves_when_both_are_offered() {
962        let caps = TierCaps::fake(DownlevelProfile::Full);
963        assert!(caps.has_timestamp_query, "fixture precondition");
964        assert_eq!(
965            device_features(wgpu::Features::PIPELINE_CACHE, &caps, true),
966            wgpu::Features::PIPELINE_CACHE | wgpu::Features::TIMESTAMP_QUERY
967        );
968    }
969
970    #[test]
971    fn base_device_limits_full_profile_passes_adapter_limits_through_unclamped() {
972        // The iOS Simulator shape: an adapter reporting fewer inter-stage
973        // shader variables than `wgpu::Limits::default()` demands (15 vs 16).
974        // Under `Full`, `base_device_limits` must request no more than what
975        // the adapter itself reported, never `Limits::default()`.
976        let adapter_limits = wgpu::Limits {
977            max_inter_stage_shader_variables: 15,
978            ..wgpu::Limits::default()
979        };
980        let limits = base_device_limits(DownlevelProfile::Full, adapter_limits.clone());
981        assert!(limits.max_inter_stage_shader_variables <= 15);
982        assert_eq!(limits, adapter_limits);
983    }
984
985    #[test]
986    fn base_device_limits_webgl2_profile_never_exceeds_the_constrained_adapter() {
987        // The WebGL2 downlevel-default shape already asks for 15 inter-stage
988        // shader variables (lower than `Limits::default()`'s 16), so folding
989        // in a constrained adapter's own limits must still land at or below
990        // what that adapter reports.
991        let adapter_limits = wgpu::Limits {
992            max_inter_stage_shader_variables: 15,
993            ..wgpu::Limits::default()
994        };
995        let limits = base_device_limits(DownlevelProfile::WebGl2, adapter_limits);
996        assert!(limits.max_inter_stage_shader_variables <= 15);
997    }
998
999    #[test]
1000    fn default_options_label_the_device_and_leave_backends_to_the_environment() {
1001        let options = ContextOptions::default();
1002        assert_eq!(options.device_label, DEFAULT_DEVICE_LABEL);
1003        assert_eq!(options.backends, None);
1004    }
1005
1006    #[test]
1007    fn a_fresh_context_has_no_device_and_therefore_no_caps() {
1008        // Construction must not enumerate adapters, so this is a host test, not
1009        // a GPU one: it passes on a machine with no usable GPU at all.
1010        let context = RenderContext::new();
1011        assert!(context.caps().is_none());
1012    }
1013
1014    #[test]
1015    #[ignore = "needs a real GPU adapter; run with `cargo test -p frust-gpu -- --ignored` \
1016                (pin the adapter on a multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
1017    fn gpu_device_creation_reports_adapter_caps() {
1018        pollster::block_on(async {
1019            let mut context = RenderContext::new();
1020            let handle = context.device().await.expect("device creation");
1021
1022            let info = handle.adapter.get_info();
1023            println!(
1024                "frust-gpu adapter: name={:?} backend={:?} device_type={:?} \
1025                 driver={:?} driver_info={:?} vendor={:#06x} device={:#06x}",
1026                info.name,
1027                info.backend,
1028                info.device_type,
1029                info.driver,
1030                info.driver_info,
1031                info.vendor,
1032                info.device
1033            );
1034            println!("frust-gpu caps: {:#?}", handle.caps);
1035            println!(
1036                "frust-gpu device limits: max_texture_dimension_2d={} \
1037                 min_uniform_buffer_offset_alignment={}",
1038                handle.device.limits().max_texture_dimension_2d,
1039                handle.device.limits().min_uniform_buffer_offset_alignment
1040            );
1041
1042            assert_eq!(handle.caps.adapter_name, info.name);
1043            assert_eq!(handle.caps.backend, info.backend);
1044            assert!(!handle.caps.adapter_name.is_empty());
1045            assert!(handle.caps.max_texture_dimension_2d > 0);
1046            assert!(handle.caps.resource_texture_dim > 0);
1047            // Nothing has been submitted, so the latch must still be empty.
1048            assert_eq!(handle.first_uncaptured_error(), None);
1049
1050            // With `FRUST_ENGINE_DOWNLEVEL=1` set for the whole test process
1051            // (the override is read once and cached in a `OnceLock`), both the
1052            // probed caps and the created device must actually report the
1053            // clamped WebGL2 shape rather than a desktop backend merely
1054            // relabelled `WebGl2`. Without it, this rig's real backend
1055            // (Vulkan/Metal/Dx12) must report `Full` unchanged, proving the
1056            // knob rehearses the downlevel shape rather than always forcing
1057            // it.
1058            let downlevel_env_set = std::env::var("FRUST_ENGINE_DOWNLEVEL").is_ok_and(|v| v != "0");
1059            if downlevel_env_set {
1060                assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::WebGl2);
1061                assert!(!handle.caps.has_storage_buffers);
1062                assert!(handle.caps.max_texture_dimension_2d <= 2048);
1063                assert_eq!(handle.caps.min_uniform_buffer_offset_alignment, 256);
1064                assert!(handle.device.limits().max_texture_dimension_2d > 0);
1065            } else {
1066                assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::Full);
1067            }
1068
1069            let caps = context.caps().cloned().expect("caps after device creation");
1070            assert_eq!(caps.adapter_name, info.name);
1071
1072            // The device is created once and reused: a second call must hand
1073            // back the same logical device, not build another one.
1074            let again = context.device().await.expect("device reuse");
1075            assert_eq!(again.caps, caps);
1076        });
1077    }
1078}