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}