frust-gpu 0.5.2

wgpu adapter, device and surface foundation for Frust: capability probing, surface lifecycle and pooled GPU resources.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! Surface lifecycle state machine.
//!
//! Android destroys and recreates the GPU surface on rotation and
//! backgrounding — `wgpu` surfaces raise `ERROR_SURFACE_LOST_KHR` and panic on
//! `configure`-after-resume if this is handled ad hoc. Frust therefore makes
//! surface state a first-class machine, driven by the platform shells'
//! `surfaceCreated`/`surfaceChanged`/`surfaceDestroyed` callbacks (Android) and
//! `resumed`/`Resized`/`suspended` events (desktop).
//!
//! This module holds only the *decisions* and the two raw-pointer surface
//! constructors; the renderer that owns the swapchain texture and drives them
//! lives a layer above, so everything here is host-testable with no GPU and no
//! window server in the loop.
//!
//! # States
//!
//! ```text
//!            on_surface_created                 acquire == Lost
//!   NoSurface ───────────────▶ SurfaceReady ──────────────────▶ SurfaceLost
//!       ▲   on_surface_destroyed  │  ▲  on_surface_changed          │
//!       └───────────────────────┘  └── (resize, stays Ready)       │
//!       ▲                                on_surface_destroyed       │
//!       └──────────────────────────────────────────────────────────┘
//!                              on_surface_created (recreate)
//! ```
//!
//! # Invariants the driving renderer must uphold
//!
//! - No `SurfaceTexture` outlives a surface transition: a render call acquires
//!   and presents within a single call and stores nothing across one.
//! - `Surface::configure` runs only from [`SurfacePhase::SurfaceReady`] (on
//!   entry via `on_surface_created`/`on_surface_changed`, or on an `Outdated`
//!   acquire).
//! - Frame requests in [`SurfacePhase::NoSurface`]/[`SurfacePhase::SurfaceLost`]
//!   are dropped — the render call reports [`FrameOutcome::Skipped`], never
//!   panicking and never queueing.
//!
//! The pure decision logic ([`next_phase`], [`SurfacePhase::can_render`],
//! [`decide_acquire`], [`next_invalid_streak`]) is separated from the `wgpu`
//! calls so it is unit-testable without a GPU.

use core::ffi::c_void;
use core::ptr::NonNull;

use anyhow::{Result, anyhow};

/// Which lifecycle state the surface is in.
///
/// Rendering only happens in [`SurfacePhase::SurfaceReady`]; the other two
/// phases mean there is no usable swapchain and frames are skipped.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfacePhase {
    /// No surface has been created yet, or the surface was explicitly destroyed.
    NoSurface,
    /// A configured surface is available and frames can be rendered.
    SurfaceReady,
    /// The surface was lost mid-render (e.g. `ERROR_SURFACE_LOST_KHR`); it has
    /// been dropped and the shell must recreate it via `on_surface_created`.
    SurfaceLost,
}

impl SurfacePhase {
    /// Whether a frame may be rendered in this phase.
    ///
    /// Only [`SurfacePhase::SurfaceReady`] can render; the machine drops frames
    /// in every other phase rather than queueing them.
    pub fn can_render(self) -> bool {
        matches!(self, SurfacePhase::SurfaceReady)
    }
}

/// A platform lifecycle event that drives a phase transition.
///
/// Kept separate from the `wgpu` side so the transition table is pure and
/// unit-testable ([`next_phase`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfaceEvent {
    /// `surfaceCreated` (Android) / `resumed` (desktop): a surface is available.
    Created,
    /// `surfaceDestroyed` (Android) / `suspended` (desktop): the surface is gone.
    Destroyed,
    /// The swapchain reported `Lost` on acquire, mid-render.
    Lost,
}

/// Pure phase-transition table.
///
/// Transitions are total by design: `Created` always lands in `SurfaceReady`
/// (creating or recreating), `Destroyed` always in `NoSurface`, and `Lost`
/// always in `SurfaceLost`. `Lost` is only ever emitted from `SurfaceReady`
/// (it originates in the render call), so no illegal edge is reachable in
/// practice.
pub fn next_phase(_current: SurfacePhase, event: SurfaceEvent) -> SurfacePhase {
    match event {
        SurfaceEvent::Created => SurfacePhase::SurfaceReady,
        SurfaceEvent::Destroyed => SurfacePhase::NoSurface,
        SurfaceEvent::Lost => SurfacePhase::SurfaceLost,
    }
}

/// `wgpu`-independent classification of a swapchain-acquire attempt.
///
/// The renderer maps `wgpu::SurfaceError` onto this so the acquire policy
/// ([`decide_acquire`]) stays pure and testable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AcquireStatus {
    /// `Success`/`Suboptimal`: a texture is available to present.
    Usable,
    /// `Outdated`: the swapchain is stale and must be reconfigured.
    Outdated,
    /// `Lost`: the surface is gone; drop it and move to `SurfaceLost`.
    Lost,
    /// `Timeout`/`Occluded`: transient; skip this frame.
    Transient,
    /// `Validation`: the swapchain raised a validation error on acquire (observed
    /// under the Android emulator's SwiftShader driver as a one-off hiccup).
    /// Treated as recoverable — reconfigure the surface and retry — rather than
    /// a hard failure that would wedge the app, since the uncaptured-error
    /// handler the device is created with already prevents the process-killing
    /// panic wgpu would otherwise raise.
    Invalid,
}

/// What the renderer should do after an acquire attempt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AcquireAction {
    /// Present the acquired texture; report [`FrameOutcome::Rendered`].
    Present,
    /// Reconfigure the surface and ask the shell to redraw
    /// ([`FrameOutcome::Redraw`]) — an `Outdated` acquire must still request a
    /// redraw after reconfiguring, not silently reconfigure and stall.
    Reconfigure,
    /// Drop the surface, transition to `SurfaceLost`, report
    /// [`FrameOutcome::SurfaceLost`] so the shell can recreate it.
    Lose,
    /// Skip this frame; report [`FrameOutcome::Skipped`].
    Skip,
}

/// Cap on consecutive `Invalid`-acquire reconfigure retries before the machine
/// gives up and transitions to [`SurfacePhase::SurfaceLost`].
///
/// Mirrors `frust-shell-ios::ffi_support::MAX_RECREATE_ATTEMPTS`'s style and
/// rationale, applied one step earlier in the lifecycle: without a cap, a
/// persistently-`Invalid` swapchain would retry a reconfigure every frame,
/// forever. At the cap the existing per-shell `SurfaceLost` recovery paths take
/// over instead — desktop recreates on the next resize/redraw, iOS's own
/// `recover_surface` (capped separately), Android on the next `surfaceChanged`
/// — so no shell code needs to change for this to be honoured.
pub const MAX_INVALID_RECONFIGURES: u8 = 3;

/// Pure acquire-error policy.
///
/// `consecutive_invalid` is the number of `Invalid` acquires already retried
/// (via `Reconfigure`) since the last successful acquire; only the `Invalid`
/// arm consults it. Host-testable: no `wgpu` state is touched here, only the
/// counter the renderer tracks and resets on a successful acquire.
pub fn decide_acquire(status: AcquireStatus, consecutive_invalid: u8) -> AcquireAction {
    match status {
        AcquireStatus::Usable => AcquireAction::Present,
        AcquireStatus::Outdated => AcquireAction::Reconfigure,
        AcquireStatus::Lost => AcquireAction::Lose,
        AcquireStatus::Transient => AcquireAction::Skip,
        // A validation error on acquire is treated like `Outdated`: rebuild the
        // swapchain and ask for a redraw. Recovering (rather than failing)
        // keeps a transient driver hiccup from permanently freezing the surface.
        // But only up to `MAX_INVALID_RECONFIGURES` consecutive attempts — past
        // that this is no longer a one-off hiccup, and retrying forever would
        // spin the render loop on a permanently-broken swapchain. At the cap,
        // give up and drop to `SurfaceLost` like a genuine `Lost` acquire, so
        // the shell's existing recovery paths take over.
        AcquireStatus::Invalid => {
            if consecutive_invalid < MAX_INVALID_RECONFIGURES {
                AcquireAction::Reconfigure
            } else {
                AcquireAction::Lose
            }
        }
    }
}

/// Given the just-observed acquire `status` and the [`AcquireAction`]
/// [`decide_acquire`] chose for it, returns the next consecutive-`Invalid`
/// streak count the renderer should store.
///
/// Pure and host-testable, split out of the render call so the counter's
/// reset-on-success / increment-on-retry / reset-on-give-up discipline is
/// unit-tested without a GPU:
/// - a successful acquire (`Usable`) always resets the streak to zero — the
///   next `Invalid`, if any, starts a fresh episode with a full retry budget;
/// - a retried `Invalid` (`Reconfigure`) increments the streak;
/// - a given-up `Invalid` (`Lose`, i.e. the cap was hit) resets to zero — the
///   giving-up transition itself ends the episode;
/// - every other status/action pairing leaves the streak untouched.
pub fn next_invalid_streak(status: AcquireStatus, action: AcquireAction, current: u8) -> u8 {
    match (status, action) {
        (AcquireStatus::Usable, _) => 0,
        (AcquireStatus::Invalid, AcquireAction::Reconfigure) => current.saturating_add(1),
        (AcquireStatus::Invalid, AcquireAction::Lose) => 0,
        _ => current,
    }
}

/// The outcome of a single render call.
///
/// Lets the shell react without knowing the internal state: request another
/// redraw when the surface was reconfigured, recreate the surface when it was
/// lost, or do nothing when a frame was drawn or skipped.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameOutcome {
    /// A frame was encoded and presented to the swapchain.
    Rendered,
    /// No renderable surface (`NoSurface`/`SurfaceLost`) or a transient acquire
    /// failure: nothing was drawn and nothing was queued.
    Skipped,
    /// The surface was stale and has been reconfigured; the shell should request
    /// another redraw to draw against the fresh configuration.
    Redraw,
    /// The surface was lost and has been dropped; the machine is now in
    /// [`SurfacePhase::SurfaceLost`] and the shell must recreate the surface via
    /// `on_surface_created`.
    SurfaceLost,
}

/// The outcome of a single encode call — the first phase of the two-phase
/// render seam (encode + present).
///
/// A shell that times encode and present separately branches on this to decide
/// whether presenting is worthwhile: an [`EncodeOutcome::Skipped`] frame laid
/// no pixels into the intermediate target (no renderable surface), so there is
/// nothing to present.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EncodeOutcome {
    /// The scene was encoded into the intermediate target; the [`FrameOutcome`]
    /// now comes from the matching present call.
    Encoded,
    /// No renderable surface (`NoSurface`/`SurfaceLost`): nothing was encoded
    /// and nothing was queued.
    Skipped,
}

/// The outcome of a single acquire call — the first half of the two-phase
/// present seam (acquire + submit), itself the second phase of the render
/// pipeline after encode.
///
/// A shell that times the swapchain **acquire** (the blocking vsync/present
/// wait) separately from the **submit** (blit + queue-submit + present) branches
/// on this to decide whether submitting is worthwhile. Only [`Self::Acquired`]
/// carries a stashed swapchain texture for the submit call to draw into; every
/// other variant is terminal and maps to a [`FrameOutcome`] with no submit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AcquireOutcome {
    /// The swapchain texture was acquired and stashed; the submit call draws
    /// into it and presents it. Maps to [`FrameOutcome::Rendered`] once
    /// submitted.
    Acquired,
    /// The surface was stale and has been reconfigured internally; no texture was
    /// stashed. Maps to [`FrameOutcome::Redraw`] — the shell should request
    /// another frame against the fresh configuration.
    Reconfigured,
    /// The surface was lost and has been dropped; the machine is now in
    /// [`SurfacePhase::SurfaceLost`]. Maps to [`FrameOutcome::SurfaceLost`] — the
    /// shell must recreate the surface via `on_surface_created`.
    Lost,
    /// No renderable surface (`NoSurface`/`SurfaceLost`) or a transient acquire
    /// failure: nothing was stashed and nothing was queued. Maps to
    /// [`FrameOutcome::Skipped`].
    Skipped,
}

/// Builds a `wgpu::Surface` from a raw `ANativeWindow` pointer.
///
/// This is one of the framework's sanctioned `unsafe` boundaries: turning a
/// caller-owned raw pointer into a GPU surface. It is deliberately isolated in
/// one function so the safety contract lives in exactly one place. See also
/// [`create_metal_surface`] (the iOS/macOS counterpart) and
/// `frust-shell-android`'s `jni_glue` module.
///
/// # Safety
///
/// `window_ptr` must be a valid, non-null `ANativeWindow*` that the caller has
/// **acquired** (e.g. via `ndk::NativeWindow` / `ANativeWindow_acquire`, which
/// the Android shell owns) and that **outlives** the returned [`wgpu::Surface`]
/// and every `SurfaceTexture` acquired from it. The caller must drop the
/// surface (and any outstanding textures) before releasing the window, i.e.
/// before returning from the `surfaceDestroyed` callback.
///
/// Compiled unconditionally (the raw-handle types are host-available) so a host
/// `cargo check --target aarch64-linux-android` exercises it.
pub unsafe fn create_android_surface(
    instance: &wgpu::Instance,
    window_ptr: *mut c_void,
) -> Result<wgpu::Surface<'static>> {
    use wgpu::rwh::{
        AndroidDisplayHandle, AndroidNdkWindowHandle, RawDisplayHandle, RawWindowHandle,
    };

    let window =
        NonNull::new(window_ptr).ok_or_else(|| anyhow!("frust-gpu: null ANativeWindow pointer"))?;
    let target = wgpu::SurfaceTargetUnsafe::RawHandle {
        raw_display_handle: Some(RawDisplayHandle::Android(AndroidDisplayHandle::new())),
        raw_window_handle: RawWindowHandle::AndroidNdk(AndroidNdkWindowHandle::new(window)),
    };

    // SAFETY: upheld by this function's own safety contract — `window_ptr` is a
    // valid, acquired `ANativeWindow*` that outlives the returned surface.
    let surface = unsafe { instance.create_surface_unsafe(target) }
        .map_err(|e| anyhow!("frust-gpu: failed to create Android surface: {e}"))?;
    Ok(surface)
}

/// Builds a `wgpu::Surface` from a raw `CAMetalLayer*` pointer.
///
/// This is one of the framework's sanctioned `unsafe` boundaries (see
/// [`create_android_surface`] for the sibling Android path): turning a
/// caller-owned raw pointer into a GPU surface. It is deliberately isolated in
/// one function so the safety contract lives in exactly one place.
///
/// # Safety
///
/// `layer_ptr` must be a valid, live `CAMetalLayer*` that the caller (the
/// Swift shell) owns and that **outlives** the returned [`wgpu::Surface`] and
/// every `SurfaceTexture` acquired from it — enforced by the
/// `frust_destroy`-before-view-teardown ordering in the generated app. The
/// caller must drop the surface (and any outstanding textures) before the
/// layer/view is torn down.
///
/// Only compiled on Apple targets: `wgpu::SurfaceTargetUnsafe::CoreAnimationLayer`
/// is itself Metal-feature-gated in `wgpu` (available whenever `target_vendor
/// = "apple"`), so this function is gated the same way rather than being
/// compiled unconditionally like [`create_android_surface`] (whose raw-handle
/// types are host-available on every platform).
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub unsafe fn create_metal_surface(
    instance: &wgpu::Instance,
    layer_ptr: *mut c_void,
) -> Result<wgpu::Surface<'static>> {
    if layer_ptr.is_null() {
        return Err(anyhow!("frust-gpu: null CAMetalLayer pointer"));
    }
    let target = wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer_ptr);

    // SAFETY: upheld by this function's own safety contract — `layer_ptr` is a
    // valid, live `CAMetalLayer*` that outlives the returned surface.
    let surface = unsafe { instance.create_surface_unsafe(target) }
        .map_err(|e| anyhow!("frust-gpu: failed to create Metal surface: {e}"))?;
    Ok(surface)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn created_always_lands_in_surface_ready() {
        for phase in [
            SurfacePhase::NoSurface,
            SurfacePhase::SurfaceReady,
            SurfacePhase::SurfaceLost,
        ] {
            assert_eq!(
                next_phase(phase, SurfaceEvent::Created),
                SurfacePhase::SurfaceReady,
                "creating (or recreating) a surface must reach SurfaceReady from {phase:?}"
            );
        }
    }

    #[test]
    fn destroyed_always_lands_in_no_surface() {
        for phase in [
            SurfacePhase::NoSurface,
            SurfacePhase::SurfaceReady,
            SurfacePhase::SurfaceLost,
        ] {
            assert_eq!(
                next_phase(phase, SurfaceEvent::Destroyed),
                SurfacePhase::NoSurface,
                "destroying a surface must reach NoSurface from {phase:?}"
            );
        }
    }

    #[test]
    fn lost_transitions_ready_to_surface_lost() {
        assert_eq!(
            next_phase(SurfacePhase::SurfaceReady, SurfaceEvent::Lost),
            SurfacePhase::SurfaceLost
        );
    }

    #[test]
    fn only_surface_ready_can_render() {
        assert!(SurfacePhase::SurfaceReady.can_render());
        assert!(!SurfacePhase::NoSurface.can_render());
        assert!(!SurfacePhase::SurfaceLost.can_render());
    }

    #[test]
    fn acquire_policy_maps_each_status_to_its_action() {
        assert_eq!(
            decide_acquire(AcquireStatus::Usable, 0),
            AcquireAction::Present
        );
        assert_eq!(
            decide_acquire(AcquireStatus::Outdated, 0),
            AcquireAction::Reconfigure
        );
        assert_eq!(decide_acquire(AcquireStatus::Lost, 0), AcquireAction::Lose);
        assert_eq!(
            decide_acquire(AcquireStatus::Transient, 0),
            AcquireAction::Skip
        );
        // A validation error on acquire recovers by reconfiguring the surface,
        // not by failing — a transient SwiftShader hiccup must not wedge the app.
        assert_eq!(
            decide_acquire(AcquireStatus::Invalid, 0),
            AcquireAction::Reconfigure
        );
    }

    #[test]
    fn invalid_reconfigures_under_the_cap() {
        // Every count below the cap still reconfigures — mirrors the iOS
        // `MAX_RECREATE_ATTEMPTS` "under cap" behavior at the analogous phase.
        for consecutive_invalid in 0..MAX_INVALID_RECONFIGURES {
            assert_eq!(
                decide_acquire(AcquireStatus::Invalid, consecutive_invalid),
                AcquireAction::Reconfigure,
                "expected Reconfigure at consecutive_invalid={consecutive_invalid}"
            );
        }
    }

    #[test]
    fn invalid_gives_up_at_the_cap() {
        assert_eq!(
            decide_acquire(AcquireStatus::Invalid, MAX_INVALID_RECONFIGURES),
            AcquireAction::Lose,
            "at the cap the machine must give up and drop to SurfaceLost"
        );
        // Saturating past the cap stays given-up (never re-enters Reconfigure).
        assert_eq!(
            decide_acquire(AcquireStatus::Invalid, u8::MAX),
            AcquireAction::Lose
        );
    }

    #[test]
    fn invalid_streak_resets_on_successful_acquire() {
        assert_eq!(
            next_invalid_streak(AcquireStatus::Usable, AcquireAction::Present, 2),
            0
        );
        // Even a streak already at (or past) the cap resets on success.
        assert_eq!(
            next_invalid_streak(AcquireStatus::Usable, AcquireAction::Present, u8::MAX),
            0
        );
    }

    #[test]
    fn invalid_streak_increments_while_reconfiguring() {
        assert_eq!(
            next_invalid_streak(AcquireStatus::Invalid, AcquireAction::Reconfigure, 0),
            1
        );
        assert_eq!(
            next_invalid_streak(
                AcquireStatus::Invalid,
                AcquireAction::Reconfigure,
                MAX_INVALID_RECONFIGURES - 1
            ),
            MAX_INVALID_RECONFIGURES
        );
        // Saturates rather than overflowing.
        assert_eq!(
            next_invalid_streak(AcquireStatus::Invalid, AcquireAction::Reconfigure, u8::MAX),
            u8::MAX
        );
    }

    #[test]
    fn invalid_streak_resets_on_giving_up() {
        assert_eq!(
            next_invalid_streak(
                AcquireStatus::Invalid,
                AcquireAction::Lose,
                MAX_INVALID_RECONFIGURES
            ),
            0
        );
    }

    #[test]
    fn invalid_streak_untouched_by_unrelated_status_action_pairs() {
        assert_eq!(
            next_invalid_streak(AcquireStatus::Outdated, AcquireAction::Reconfigure, 2),
            2
        );
        assert_eq!(
            next_invalid_streak(AcquireStatus::Lost, AcquireAction::Lose, 2),
            2
        );
        assert_eq!(
            next_invalid_streak(AcquireStatus::Transient, AcquireAction::Skip, 2),
            2
        );
    }

    #[test]
    fn non_invalid_statuses_are_unaffected_by_the_counter() {
        // The counter only matters for `Invalid`; every other status ignores it.
        for consecutive_invalid in [0, 1, MAX_INVALID_RECONFIGURES, u8::MAX] {
            assert_eq!(
                decide_acquire(AcquireStatus::Usable, consecutive_invalid),
                AcquireAction::Present
            );
            assert_eq!(
                decide_acquire(AcquireStatus::Outdated, consecutive_invalid),
                AcquireAction::Reconfigure
            );
            assert_eq!(
                decide_acquire(AcquireStatus::Lost, consecutive_invalid),
                AcquireAction::Lose
            );
            assert_eq!(
                decide_acquire(AcquireStatus::Transient, consecutive_invalid),
                AcquireAction::Skip
            );
        }
    }
}