dear-app 0.16.0-alpha.1

Generation-aware Dear ImGui application runtime for dear-imgui-rs on Winit and WGPU
Documentation
use crate::{GpuGeneration, RunError};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SurfaceEvent {
    Success,
    Suboptimal,
    Lost,
    Outdated,
    Timeout,
    Occluded,
    Validation,
    OutOfMemory,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LifecycleAction {
    Render,
    RenderAndReconfigure,
    ReconfigureSurface,
    RecreateSurface,
    SkipFrame,
    RecoverGpu,
    Exit,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RuntimeState {
    Running(GpuGeneration),
    Recovering(GpuGeneration),
    Failed,
    Shutdown,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TransitionError {
    RecoveryNotActive { state: RuntimeState },
    GenerationExhausted,
}

pub(crate) struct LifecycleMachine {
    state: RuntimeState,
    terminal_error: Option<RunError>,
}

impl LifecycleMachine {
    pub(crate) fn new() -> Self {
        Self {
            state: RuntimeState::Running(GpuGeneration::INITIAL),
            terminal_error: None,
        }
    }

    #[cfg(test)]
    const fn new_at(generation: GpuGeneration) -> Self {
        Self {
            state: RuntimeState::Running(generation),
            terminal_error: None,
        }
    }

    #[cfg(test)]
    pub(crate) const fn state(&self) -> RuntimeState {
        self.state
    }

    pub(crate) fn surface_event(&mut self, event: SurfaceEvent) -> LifecycleAction {
        if !matches!(self.state, RuntimeState::Running(_)) {
            return LifecycleAction::SkipFrame;
        }

        match event {
            SurfaceEvent::Success => LifecycleAction::Render,
            SurfaceEvent::Suboptimal => LifecycleAction::RenderAndReconfigure,
            SurfaceEvent::Lost => LifecycleAction::RecreateSurface,
            SurfaceEvent::Outdated => LifecycleAction::ReconfigureSurface,
            SurfaceEvent::Timeout | SurfaceEvent::Occluded => LifecycleAction::SkipFrame,
            SurfaceEvent::Validation | SurfaceEvent::OutOfMemory => {
                self.state = RuntimeState::Failed;
                LifecycleAction::Exit
            }
        }
    }

    pub(crate) fn device_lost(&mut self, signal_generation: GpuGeneration) -> LifecycleAction {
        let RuntimeState::Running(current_generation) = self.state else {
            return LifecycleAction::SkipFrame;
        };
        if signal_generation != current_generation {
            return LifecycleAction::SkipFrame;
        }

        self.state = RuntimeState::Recovering(current_generation);
        LifecycleAction::RecoverGpu
    }

    pub(crate) fn pending_generation(&self) -> Result<GpuGeneration, TransitionError> {
        let RuntimeState::Recovering(previous) = self.state else {
            return Err(TransitionError::RecoveryNotActive { state: self.state });
        };
        previous
            .checked_next()
            .ok_or(TransitionError::GenerationExhausted)
    }

    pub(crate) fn recovery_succeeded(&mut self) -> Result<GpuGeneration, TransitionError> {
        let next = match self.pending_generation() {
            Ok(next) => next,
            Err(error) => {
                if error == TransitionError::GenerationExhausted {
                    self.state = RuntimeState::Failed;
                }
                return Err(error);
            }
        };
        self.state = RuntimeState::Running(next);
        Ok(next)
    }

    pub(crate) fn mark_failed(&mut self) {
        self.state = RuntimeState::Failed;
    }

    pub(crate) fn shutdown(&mut self) {
        self.state = RuntimeState::Shutdown;
    }

    pub(crate) fn fail(&mut self, error: RunError) {
        self.mark_failed();
        if self.terminal_error.is_none() {
            self.terminal_error = Some(error);
        }
    }

    pub(crate) fn take_terminal_error(&mut self) -> Option<RunError> {
        self.terminal_error.take()
    }

    pub(crate) fn terminal_error(&self) -> Option<&RunError> {
        self.terminal_error.as_ref()
    }
}

#[cfg(test)]
mod tests {
    use super::{LifecycleAction, LifecycleMachine, RuntimeState, SurfaceEvent, TransitionError};
    use crate::{GpuGeneration, RunError};

    #[test]
    fn surface_events_never_start_gpu_recovery() {
        let mut lifecycle = LifecycleMachine::new();

        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Success),
            LifecycleAction::Render
        );
        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Suboptimal),
            LifecycleAction::RenderAndReconfigure
        );
        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Lost),
            LifecycleAction::RecreateSurface
        );
        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Outdated),
            LifecycleAction::ReconfigureSurface
        );
        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Timeout),
            LifecycleAction::SkipFrame
        );
        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Occluded),
            LifecycleAction::SkipFrame
        );
        assert_eq!(
            lifecycle.surface_event(SurfaceEvent::Validation),
            LifecycleAction::Exit
        );
        assert_eq!(lifecycle.state(), RuntimeState::Failed);

        let mut out_of_memory = LifecycleMachine::new();
        assert_eq!(
            out_of_memory.surface_event(SurfaceEvent::OutOfMemory),
            LifecycleAction::Exit
        );
        assert_eq!(out_of_memory.state(), RuntimeState::Failed);
    }

    #[test]
    fn only_current_device_loss_replaces_the_gpu_generation() {
        let mut lifecycle = LifecycleMachine::new();

        assert_eq!(
            lifecycle.device_lost(GpuGeneration::INITIAL),
            LifecycleAction::RecoverGpu
        );
        assert_eq!(
            lifecycle.state(),
            RuntimeState::Recovering(GpuGeneration::INITIAL)
        );
        let next = GpuGeneration::INITIAL.checked_next().unwrap();
        assert_eq!(lifecycle.pending_generation(), Ok(next));
        assert_eq!(lifecycle.recovery_succeeded(), Ok(next));
        assert_eq!(lifecycle.state(), RuntimeState::Running(next));
    }

    #[test]
    fn late_device_loss_from_an_old_generation_has_no_effect() {
        let mut lifecycle = LifecycleMachine::new();
        assert_eq!(
            lifecycle.device_lost(GpuGeneration::INITIAL),
            LifecycleAction::RecoverGpu
        );
        let current = lifecycle.recovery_succeeded().unwrap();

        assert_eq!(
            lifecycle.device_lost(GpuGeneration::INITIAL),
            LifecycleAction::SkipFrame
        );
        assert_eq!(lifecycle.state(), RuntimeState::Running(current));
    }

    #[test]
    fn a_failed_replacement_becomes_terminal() {
        let mut lifecycle = LifecycleMachine::new();

        assert_eq!(
            lifecycle.device_lost(GpuGeneration::INITIAL),
            LifecycleAction::RecoverGpu
        );
        lifecycle.mark_failed();
        assert_eq!(lifecycle.state(), RuntimeState::Failed);
    }

    #[test]
    fn illegal_recovery_completion_is_explicit() {
        let mut lifecycle = LifecycleMachine::new();

        assert_eq!(
            lifecycle.recovery_succeeded(),
            Err(TransitionError::RecoveryNotActive {
                state: RuntimeState::Running(GpuGeneration::INITIAL),
            })
        );
    }

    #[test]
    fn generation_overflow_is_terminal_instead_of_reusing_an_epoch() {
        let max_generation = GpuGeneration(u64::MAX);
        let mut lifecycle = LifecycleMachine::new_at(max_generation);
        assert_eq!(
            lifecycle.device_lost(max_generation),
            LifecycleAction::RecoverGpu
        );

        assert_eq!(
            lifecycle.recovery_succeeded(),
            Err(TransitionError::GenerationExhausted)
        );
        assert_eq!(lifecycle.state(), RuntimeState::Failed);
    }

    #[test]
    fn shutdown_is_idempotent_and_blocks_future_work() {
        let mut lifecycle = LifecycleMachine::new();

        lifecycle.shutdown();
        lifecycle.shutdown();
        assert_eq!(lifecycle.state(), RuntimeState::Shutdown);
        assert_eq!(
            lifecycle.device_lost(GpuGeneration::INITIAL),
            LifecycleAction::SkipFrame
        );
    }

    #[test]
    fn lifecycle_owns_and_preserves_the_first_terminal_error() {
        let mut lifecycle = LifecycleMachine::new();
        lifecycle.fail(RunError::application("first", "primary failure"));
        lifecycle.fail(RunError::application("second", "secondary failure"));
        lifecycle.shutdown();

        let error = lifecycle
            .take_terminal_error()
            .expect("the first terminal error should be retained");
        assert_eq!(
            error.to_string(),
            "application callback failed during first: primary failure"
        );
        assert!(lifecycle.take_terminal_error().is_none());
    }
}