Skip to main content

agg_gui_wgpu/
gpu_acquire.rs

1//! Frame acquisition for [`Gpu`], and recovery from a failed
2//! `Surface::configure`.
3//!
4//! Pulled in via `#[path]` as the `acquire` child module of `gpu.rs` so it
5//! can reach `Gpu`'s private fields while keeping that file well under the
6//! line limit.
7//!
8//! wgpu's default handler panics on a configure validation error, and DX12's
9//! `ResizeBuffers` can raise one transiently (`DXGI_ERROR_INVALID_CALL`
10//! while Windows is still settling a borderless-fullscreen window). Every
11//! native configure therefore goes through [`try_configure`], which captures
12//! the error instead. A failed configure leaves the swap chain unusable, so
13//! [`Gpu::try_acquire_frame`] retries it, paced by the pure policy in
14//! `crate::surface_retry`, and reports [`SurfaceError`] once the retry budget
15//! ([`crate::GpuConfig::surface_retry_budget`]) is spent. The *caller*
16//! decides how to wake for a retry — [`RetryWake`] — so this crate never
17//! reaches into the event loop's draw scheduling on the modern path.
18
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::MutexGuard;
21use std::time::Duration;
22
23use web_time::Instant;
24
25use super::{surface_acquire_action, Gpu, SurfaceAcquire};
26use crate::surface_retry::{ConfigureRetry, RetryAction, RetryLog};
27
28/// Result of [`Gpu::try_acquire_frame`].
29#[derive(Debug)]
30#[non_exhaustive]
31pub enum FrameAcquire {
32    /// A texture to render into and present.
33    Frame(wgpu::SurfaceTexture),
34    /// No texture this time — skip the frame, and arrange the wake the
35    /// [`RetryWake`] asks for so the frame is retried.
36    Skip(RetryWake),
37}
38
39/// When the caller should try [`Gpu::try_acquire_frame`] again after a
40/// [`FrameAcquire::Skip`].
41///
42/// The caller owns the event loop, so it turns this into its own wake-up —
43/// e.g. `Window::request_redraw` for [`RetryWake::Now`], a
44/// `ControlFlow::WaitUntil` deadline for [`RetryWake::After`], nothing for
45/// [`RetryWake::OnEvent`]. None of them should keep a reactive loop polling.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47#[non_exhaustive]
48pub enum RetryWake {
49    /// Retry on the next frame (a `Timeout`, a surface that is still stale
50    /// right after a reconfigure, or the immediate first retry of a failed
51    /// configure).
52    Now,
53    /// A failed configure is backing off: retry no sooner than this.
54    After(Duration),
55    /// Do not self-schedule a retry: wait for the next window event. The
56    /// window is `Occluded` (a redraw request when it becomes visible again
57    /// brings the frame back) or the acquire hit a validation error that a
58    /// redraw loop would only repeat.
59    OnEvent,
60}
61
62/// Why [`Gpu::try_acquire_frame`] could not produce frames any more.
63#[derive(Clone, PartialEq, Eq, Debug)]
64#[non_exhaustive]
65pub enum SurfaceError {
66    /// The swap chain stayed unconfigured — every `Surface::configure`
67    /// retry failing — for longer than
68    /// [`crate::GpuConfig::surface_retry_budget`]. Returned again on every
69    /// call until a configure succeeds (e.g. via [`Gpu::resize`]). The usual
70    /// response is to exit with this error rather than keep a black window.
71    #[non_exhaustive]
72    ConfigureRetriesExhausted {
73        /// wgpu's text for the most recent failure.
74        last_error: String,
75        /// From the first failure of the run to the most recent one.
76        unconfigured_for: Duration,
77        /// Consecutive failed configure attempts.
78        attempts: u32,
79    },
80}
81
82impl std::fmt::Display for SurfaceError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::ConfigureRetriesExhausted {
86                last_error,
87                unconfigured_for,
88                attempts,
89            } => write!(
90                f,
91                "wgpu surface could not be configured for {:.1} s \
92                 ({attempts} failed attempts); last error: {last_error}",
93                unconfigured_for.as_secs_f64()
94            ),
95        }
96    }
97}
98
99impl std::error::Error for SurfaceError {}
100
101/// Configure state behind [`Gpu`]'s mutex: the retry policy, plus the text
102/// of the latest failure for logging and [`SurfaceError`].
103#[derive(Debug)]
104pub(super) struct RetryState {
105    policy: ConfigureRetry,
106    last_error: String,
107}
108
109impl RetryState {
110    pub(super) fn new(policy: ConfigureRetry) -> Self {
111        Self {
112            policy,
113            last_error: String::new(),
114        }
115    }
116}
117
118/// `Surface::configure` with its validation and internal errors captured
119/// instead of handed to wgpu's default handler, which panics.
120///
121/// A device lost before or during the configure also counts as a failure:
122/// wgpu-core reports that as a `DeviceLost` error, which the `Validation`
123/// scope does not capture, so the surface could look configured when it is
124/// not — and acquiring from it would be fatal. Not unit-tested: it needs a
125/// live device that can be made to fail on demand; the policy that consumes
126/// the result is tested in `surface_retry`.
127pub(super) fn try_configure(
128    device: &wgpu::Device,
129    surface: &wgpu::Surface<'_>,
130    config: &wgpu::SurfaceConfiguration,
131    device_lost: &AtomicBool,
132) -> Result<(), String> {
133    // Scopes nest: pop in reverse push order. A backend failure inside the
134    // swap-chain call (e.g. DXGI refusing the resize) can surface as either
135    // kind, so both are captured and either is a failed configure.
136    let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
137    let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
138    surface.configure(device, config);
139    let internal_err = pollster::block_on(internal.pop());
140    let validation_err = pollster::block_on(validation.pop());
141    if let Some(err) = validation_err.or(internal_err) {
142        return Err(err.to_string());
143    }
144    if device_lost.load(Ordering::Relaxed) {
145        return Err("device lost".to_owned());
146    }
147    Ok(())
148}
149
150/// The wake for a surface-acquire status that yields no frame and needs no
151/// reconfigure; `None` for the statuses that do (`Present`, `Reconfigure`).
152/// Preserves the long-standing semantics: `Timeout` asks for another frame,
153/// `Occluded`/`Validation` wait for an event.
154fn status_wake(action: SurfaceAcquire) -> Option<RetryWake> {
155    match action {
156        SurfaceAcquire::SkipAndRetry => Some(RetryWake::Now),
157        SurfaceAcquire::Skip => Some(RetryWake::OnEvent),
158        SurfaceAcquire::Present | SurfaceAcquire::Reconfigure => None,
159    }
160}
161
162/// What an unconfigured surface's retry decision means for the caller:
163/// skip with a wake, or give up. `Configured` cannot reach here (the caller
164/// acquires instead); it maps to an immediate retry to stay total.
165fn unconfigured_outcome(
166    action: RetryAction,
167    state: &RetryState,
168) -> Result<FrameAcquire, SurfaceError> {
169    let wake = match action {
170        RetryAction::Configured | RetryAction::TryNow => RetryWake::Now,
171        RetryAction::WaitFor(d) => RetryWake::After(d),
172        RetryAction::GiveUp { unconfigured_for } => {
173            return Err(SurfaceError::ConfigureRetriesExhausted {
174                last_error: state.last_error.clone(),
175                unconfigured_for,
176                attempts: state.policy.failures(),
177            })
178        }
179    };
180    Ok(FrameAcquire::Skip(wake))
181}
182
183impl Gpu {
184    fn retry_state(&self) -> MutexGuard<'_, RetryState> {
185        // Poisoning would need a panic inside the few lines that hold the
186        // lock; the state is plain data either way, so keep using it.
187        self.retry.lock().unwrap_or_else(|p| p.into_inner())
188    }
189
190    /// Configure the surface with the current config, fold the outcome into
191    /// the retry state and log it. Returns whether the surface is now
192    /// configured.
193    ///
194    /// Logging: `warn` on the first failure of a run (with the size, since
195    /// that is what the backend rejected), `debug` on repeats so a
196    /// persistently failing surface does not flood the log, `info` on
197    /// recovery.
198    pub(super) fn configure_and_record(&self, now: Instant) -> bool {
199        let result = try_configure(&self.device, &self.surface, &self.config, &self.device_lost);
200        let ok = result.is_ok();
201        let mut state = self.retry_state();
202        let log = state.policy.record(ok, now);
203        state.last_error = result.err().unwrap_or_default();
204        let (w, h) = (self.config.width, self.config.height);
205        let err = &state.last_error;
206        match log {
207            RetryLog::None => {}
208            RetryLog::FirstFailure => log::warn!(
209                "agg-gui-wgpu: surface configure at {w}x{h} failed, retrying with backoff \
210                 (budget {:?}): {err}",
211                self.retry_budget
212            ),
213            RetryLog::RepeatFailure => log::debug!(
214                "agg-gui-wgpu: surface configure at {w}x{h} failed again ({} in a row): {err}",
215                state.policy.failures()
216            ),
217            RetryLog::Recovered { failures } => log::info!(
218                "agg-gui-wgpu: surface configured at {w}x{h} after {failures} failed attempt(s)"
219            ),
220        }
221        ok
222    }
223
224    /// Is the swap chain currently configured?
225    ///
226    /// `false` while a configure-failure run is in progress: a
227    /// `Surface::configure` (in [`Self::resize`] or [`Self::try_acquire_frame`])
228    /// failed and no retry has succeeded yet, so [`Self::try_acquire_frame`]
229    /// will skip frames until one does. A shell can use this to hold off
230    /// attempts that cannot succeed — agg-gui-shell stops painting a
231    /// minimized window only while this is `false`. Read-only; `true` again
232    /// as soon as a configure succeeds.
233    pub fn surface_configured(&self) -> bool {
234        self.retry_state().policy.is_configured()
235    }
236
237    /// The skip-or-give-up outcome for a surface that is not configured.
238    fn skip_unconfigured(&self, now: Instant) -> Result<FrameAcquire, SurfaceError> {
239        let state = self.retry_state();
240        unconfigured_outcome(state.policy.attempt(now, self.retry_budget), &state)
241    }
242
243    /// Acquire the next surface texture.
244    ///
245    /// - A stale swap chain (`Outdated`/`Lost`) is reconfigured and the
246    ///   acquire retried once, this call.
247    /// - A swap chain left unconfigured by a failed configure (here or in
248    ///   [`Self::resize`]) is configured again first, paced by a backoff: the
249    ///   first retry is immediate, later ones wait 50 ms doubling to 1 s, and
250    ///   a call made before the next attempt is due skips without calling
251    ///   `configure` (each attempt costs a full GPU wait-idle).
252    /// - Otherwise the frame is skipped with the [`RetryWake`] the caller
253    ///   should arrange; see [`FrameAcquire`].
254    ///
255    /// # Errors
256    ///
257    /// [`SurfaceError::ConfigureRetriesExhausted`] once a configure retry
258    /// fails after the surface has been unconfigured for longer than
259    /// [`crate::GpuConfig::surface_retry_budget`] *and* at least 12 attempts
260    /// in the run have failed.
261    ///
262    /// While the window is minimized and [`Self::surface_configured`] is
263    /// `false`, don't call this: every configure retry against a minimized
264    /// window fails and counts against the budget (agg-gui-shell skips the
265    /// paint in exactly that state). The budget clock still runs from the
266    /// run's first failure while attempts are paused, so it is the attempt
267    /// minimum that gives a restored window a real retry run (about 6.5 s)
268    /// instead of an exit at its first failed retry.
269    ///
270    /// A lost device also fails configure; shells should check
271    /// [`Self::device_lost`] before each frame and rebuild the `Gpu`, which
272    /// starts with a fresh retry state, rather than let the budget run out.
273    pub fn try_acquire_frame(&self) -> Result<FrameAcquire, SurfaceError> {
274        use wgpu::CurrentSurfaceTexture as T;
275        let now = Instant::now();
276        // Bound first: a guard temporary in the `match` scrutinee would be
277        // held across `configure_and_record`, which locks again.
278        let action = self.retry_state().policy.attempt(now, self.retry_budget);
279        match action {
280            RetryAction::Configured => {}
281            RetryAction::TryNow => {
282                if !self.configure_and_record(now) {
283                    return self.skip_unconfigured(now);
284                }
285            }
286            RetryAction::WaitFor(_) | RetryAction::GiveUp { .. } => {
287                return self.skip_unconfigured(now);
288            }
289        }
290        let first = self.surface.get_current_texture();
291        let action = surface_acquire_action(&first);
292        if let Some(wake) = status_wake(action) {
293            return Ok(FrameAcquire::Skip(wake));
294        }
295        match action {
296            SurfaceAcquire::Reconfigure => {
297                // The config already carries the current (nonzero,
298                // `resize`-clamped) size, so this just re-binds. A failure
299                // starts a retry run like any other failed configure.
300                if !self.configure_and_record(now) {
301                    return self.skip_unconfigured(now);
302                }
303                match self.surface.get_current_texture() {
304                    T::Success(f) | T::Suboptimal(f) => Ok(FrameAcquire::Frame(f)),
305                    // Still nothing after the reconfigure: come back next
306                    // frame rather than wait for an unrelated event.
307                    _ => Ok(FrameAcquire::Skip(RetryWake::Now)),
308                }
309            }
310            _ => match first {
311                T::Success(f) | T::Suboptimal(f) => Ok(FrameAcquire::Frame(f)),
312                _ => Ok(FrameAcquire::Skip(RetryWake::Now)),
313            },
314        }
315    }
316
317    /// Legacy form of [`Self::try_acquire_frame`], kept for existing callers.
318    /// Prefer `try_acquire_frame`; this will be deprecated in 0.6.
319    ///
320    /// Returns `None` when the frame must be skipped, and arranges the retry
321    /// itself: [`RetryWake::Now`] calls `request_redraw` (pass a closure
322    /// that calls `Window::request_redraw`), [`RetryWake::After`] schedules
323    /// [`agg_gui::animation::request_draw_after`] — agg_gui's thread-local
324    /// draw state, so call this on the UI thread — and
325    /// [`RetryWake::OnEvent`] does nothing.
326    ///
327    /// # Panics
328    ///
329    /// When [`Self::try_acquire_frame`] returns [`SurfaceError`] — the
330    /// surface could not be configured for the whole retry budget. Before the
331    /// retry existed the first failed configure panicked inside wgpu, so this
332    /// keeps crash-and-restart supervision working; handle the error instead
333    /// by moving to `try_acquire_frame`.
334    pub fn acquire_frame(&self, request_redraw: impl Fn()) -> Option<wgpu::SurfaceTexture> {
335        match self.try_acquire_frame() {
336            Ok(FrameAcquire::Frame(frame)) => Some(frame),
337            Ok(FrameAcquire::Skip(wake)) => {
338                match wake {
339                    RetryWake::Now => request_redraw(),
340                    RetryWake::After(d) => agg_gui::animation::request_draw_after(d),
341                    RetryWake::OnEvent => {}
342                }
343                None
344            }
345            Err(e) => panic!("agg-gui-wgpu: {e}"),
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn gpu_stays_send_and_sync() {
356        // The retry state lives behind `&self`; a `Cell` there would silently
357        // make `Gpu` `!Sync` — a breaking change for apps that share it.
358        fn assert_send_sync<T: Send + Sync>() {}
359        assert_send_sync::<Gpu>();
360    }
361
362    #[test]
363    fn skip_statuses_keep_their_wake_semantics() {
364        // Timeout is transient: ask for another frame. Occluded/Validation:
365        // a self-requested redraw would just burn the CPU.
366        assert_eq!(
367            status_wake(SurfaceAcquire::SkipAndRetry),
368            Some(RetryWake::Now)
369        );
370        assert_eq!(status_wake(SurfaceAcquire::Skip), Some(RetryWake::OnEvent));
371        assert_eq!(status_wake(SurfaceAcquire::Present), None);
372        assert_eq!(status_wake(SurfaceAcquire::Reconfigure), None);
373    }
374
375    fn state(failures: u32, last_error: &str) -> RetryState {
376        let mut policy = ConfigureRetry::default();
377        for _ in 0..failures {
378            policy.record(false, Instant::now());
379        }
380        RetryState {
381            policy,
382            last_error: last_error.to_owned(),
383        }
384    }
385
386    #[test]
387    fn backing_off_surface_skips_with_the_matching_wake() {
388        let s = state(1, "");
389        assert!(matches!(
390            unconfigured_outcome(RetryAction::TryNow, &s),
391            Ok(FrameAcquire::Skip(RetryWake::Now))
392        ));
393        let d = Duration::from_millis(200);
394        assert!(matches!(
395            unconfigured_outcome(RetryAction::WaitFor(d), &s),
396            Ok(FrameAcquire::Skip(RetryWake::After(got))) if got == d
397        ));
398    }
399
400    #[test]
401    fn exhausted_budget_is_an_error_carrying_the_last_wgpu_text() {
402        let s = state(7, "DXGI_ERROR_INVALID_CALL");
403        let span = Duration::from_millis(10_500);
404        let err = match unconfigured_outcome(
405            RetryAction::GiveUp {
406                unconfigured_for: span,
407            },
408            &s,
409        ) {
410            Err(e) => e,
411            Ok(other) => panic!("expected an error, got {other:?}"),
412        };
413        assert_eq!(
414            err,
415            SurfaceError::ConfigureRetriesExhausted {
416                last_error: "DXGI_ERROR_INVALID_CALL".to_owned(),
417                unconfigured_for: span,
418                attempts: 7,
419            }
420        );
421        let text = err.to_string();
422        assert!(text.contains("10.5 s"), "{text}");
423        assert!(text.contains("7 failed attempts"), "{text}");
424        assert!(text.contains("DXGI_ERROR_INVALID_CALL"), "{text}");
425    }
426}