//! Shared compare-and-swap load/readiness state machine. //! //! The worker and the main-thread viewer each own an instance of [`LoadReducer`]. //! The main thread allocates identities; reducers only accept an identity that was //! supplied by the caller and reject late or cross-namespace events. use serde::{Deserialize, Serialize}; /// Renderer state namespace. Preview work is deliberately not interactive work. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RendererNamespace { Interactive, Preview, } /// Monotonic identity allocated by the main thread for one renderer namespace. #[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)] pub struct LoadIdentity { pub load_epoch: u32, pub scene_generation: u32, } impl LoadIdentity { pub const fn new(load_epoch: u32, scene_generation: u32) -> Self { Self { load_epoch, scene_generation, } } } /// An opaque resource handle used only to communicate disposal obligations. /// The resource-owning side chooses the token and maps it to its real resource. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ResourceToken(pub u64); /// Stable machine-readable failure classes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LoadErrorCode { Fetch, Parse, Install, Cancelled, Renderer, Unknown, } /// A stable error code with a human-facing detail string. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LoadError { pub code: LoadErrorCode, pub detail: String, } /// Externally observable reducer states. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum LoadState { Idle, Loading(LoadIdentity), Installing(LoadIdentity), Ready(LoadIdentity), Failed(u32, LoadErrorCode), Cancelled(u32), Superseded(u32), Disposed, } impl LoadState { pub fn identity(&self) -> Option { match self { Self::Loading(id) | Self::Installing(id) | Self::Ready(id) => Some(*id), Self::Failed(epoch, _) | Self::Cancelled(epoch) | Self::Superseded(epoch) => { Some(LoadIdentity::new(*epoch, 0)) } Self::Idle | Self::Disposed => None, } } pub fn is_session_state(&self) -> bool { !matches!(self, Self::Disposed) } } /// Reducer input. Resource tokens are candidates owned by the event producer. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadEvent { StartLoad { namespace: RendererNamespace, identity: LoadIdentity, }, LoadSucceeded { namespace: RendererNamespace, identity: LoadIdentity, candidate: Option, }, LoadFailed { namespace: RendererNamespace, identity: LoadIdentity, error: LoadError, candidate: Option, }, InstallSucceeded { namespace: RendererNamespace, identity: LoadIdentity, scene: Option, }, InstallFailed { namespace: RendererNamespace, identity: LoadIdentity, error: LoadError, candidate: Option, }, Cancel { namespace: RendererNamespace, identity: LoadIdentity, candidate: Option, }, Resize { namespace: RendererNamespace, }, Dispose { namespace: RendererNamespace, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransitionOutcome { Committed, IgnoredLateOrStale, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TransitionResult { pub outcome: TransitionOutcome, pub state: LoadState, pub disposed: Vec, } impl TransitionResult { fn committed(state: LoadState, disposed: Vec) -> Self { Self { outcome: TransitionOutcome::Committed, state, disposed, } } fn ignored(state: LoadState, candidate: Option) -> Self { Self { outcome: TransitionOutcome::IgnoredLateOrStale, state, disposed: candidate.into_iter().collect(), } } } /// One namespace-local CAS reducer. Identity allocation is intentionally absent. #[derive(Debug, Clone)] pub struct LoadReducer { namespace: RendererNamespace, state: LoadState, current_identity: Option, candidate: Option, current_scene: Option, } impl LoadReducer { pub fn new(namespace: RendererNamespace) -> Self { Self { namespace, state: LoadState::Idle, current_identity: None, candidate: None, current_scene: None, } } pub fn namespace(&self) -> RendererNamespace { self.namespace } pub fn state(&self) -> &LoadState { &self.state } pub fn current_identity(&self) -> Option { self.current_identity } pub fn current_scene(&self) -> Option { self.current_scene } pub fn reduce(&mut self, event: LoadEvent) -> TransitionResult { let (namespace, identity, candidate) = event_metadata(&event); if namespace != self.namespace { return TransitionResult::ignored(self.state.clone(), candidate); } if matches!(event, LoadEvent::Dispose { .. }) { let disposed = self.take_resources(candidate); self.state = LoadState::Disposed; self.current_identity = None; return TransitionResult::committed(LoadState::Disposed, disposed); } if matches!(self.state, LoadState::Disposed) { return TransitionResult::ignored(self.state.clone(), candidate); } if let LoadEvent::StartLoad { identity, .. } = event { if self .current_identity .is_none_or(|current| identity > current) { let mut disposed = self.take_resources(None); self.state = LoadState::Loading(identity); self.current_identity = Some(identity); disposed.extend(candidate); return TransitionResult::committed(self.state.clone(), disposed); } return TransitionResult::ignored(self.state.clone(), None); } if matches!(event, LoadEvent::Resize { .. }) { return TransitionResult::committed(self.state.clone(), Vec::new()); } let Some(identity) = identity else { return TransitionResult::ignored(self.state.clone(), candidate); }; if self.current_identity != Some(identity) { return TransitionResult::ignored(self.state.clone(), candidate); } match event { LoadEvent::LoadSucceeded { candidate, .. } if matches!(self.state, LoadState::Loading(_)) => { self.candidate = candidate; self.state = LoadState::Installing(identity); TransitionResult::committed(self.state.clone(), Vec::new()) } LoadEvent::LoadFailed { error, candidate, .. } if matches!(self.state, LoadState::Loading(_) | LoadState::Installing(_)) => { let disposed = self.take_resources(candidate); self.state = LoadState::Failed(identity.load_epoch, error.code); TransitionResult::committed(self.state.clone(), disposed) } LoadEvent::Cancel { candidate, .. } if matches!( self.state, LoadState::Loading(_) | LoadState::Installing(_) | LoadState::Ready(_) ) => { let disposed = self.take_resources(candidate); self.current_scene = None; self.state = LoadState::Cancelled(identity.load_epoch); TransitionResult::committed(self.state.clone(), disposed) } LoadEvent::InstallSucceeded { scene, .. } if matches!(self.state, LoadState::Installing(_)) => { let mut disposed = self.take_resources(None); if let Some(candidate) = self.candidate.take() { disposed.push(candidate); } self.current_scene = scene; self.state = LoadState::Ready(identity); TransitionResult::committed(self.state.clone(), disposed) } LoadEvent::InstallFailed { error, candidate, .. } if matches!(self.state, LoadState::Installing(_)) => { let disposed = self.take_resources(candidate); self.state = LoadState::Failed(identity.load_epoch, error.code); TransitionResult::committed(self.state.clone(), disposed) } LoadEvent::Resize { .. } => TransitionResult::committed(self.state.clone(), Vec::new()), _ => TransitionResult::ignored(self.state.clone(), candidate), } } fn take_resources(&mut self, extra: Option) -> Vec { let mut disposed = Vec::new(); if let Some(token) = self.candidate.take() { disposed.push(token); } if let Some(token) = self.current_scene.take() { disposed.push(token); } if let Some(token) = extra { disposed.push(token); } disposed.sort_unstable_by_key(|token| token.0); disposed.dedup(); disposed } } fn event_metadata( event: &LoadEvent, ) -> ( RendererNamespace, Option, Option, ) { match event { LoadEvent::StartLoad { namespace, identity, } => (*namespace, Some(*identity), None), LoadEvent::LoadSucceeded { namespace, identity, candidate, } | LoadEvent::LoadFailed { namespace, identity, candidate, .. } | LoadEvent::InstallFailed { namespace, identity, candidate, .. } | LoadEvent::Cancel { namespace, identity, candidate, } => (*namespace, Some(*identity), *candidate), LoadEvent::InstallSucceeded { namespace, identity, scene, } => (*namespace, Some(*identity), *scene), LoadEvent::Resize { namespace } | LoadEvent::Dispose { namespace } => { (*namespace, None, None) } } } #[cfg(test)] mod tests { use super::*; const NS: RendererNamespace = RendererNamespace::Interactive; const A: LoadIdentity = LoadIdentity::new(1, 1); const B: LoadIdentity = LoadIdentity::new(1, 2); fn err(code: LoadErrorCode) -> LoadError { LoadError { code, detail: "detail".into(), } } fn start(r: &mut LoadReducer, id: LoadIdentity) { assert_eq!( r.reduce(LoadEvent::StartLoad { namespace: NS, identity: id }) .outcome, TransitionOutcome::Committed ); } #[test] fn full_install_is_the_only_ready_publication() { let mut r = LoadReducer::new(NS); start(&mut r, A); assert!(matches!(r.state(), LoadState::Loading(A))); r.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity: A, candidate: Some(ResourceToken(1)), }); assert!(matches!(r.state(), LoadState::Installing(A))); assert!(!matches!(r.state(), LoadState::Ready(_))); r.reduce(LoadEvent::InstallSucceeded { namespace: NS, identity: A, scene: Some(ResourceToken(2)), }); assert!(matches!(r.state(), LoadState::Ready(A))); assert_eq!(r.current_scene(), Some(ResourceToken(2))); } #[test] fn stale_success_is_rejected_and_disposed_after_supersession() { let mut r = LoadReducer::new(NS); start(&mut r, A); start(&mut r, B); let result = r.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity: A, candidate: Some(ResourceToken(9)), }); assert_eq!(result.outcome, TransitionOutcome::IgnoredLateOrStale); assert_eq!(result.disposed, vec![ResourceToken(9)]); assert!(matches!(r.state(), LoadState::Loading(B))); } #[test] fn cancel_and_dispose_win_against_late_install() { let mut r = LoadReducer::new(NS); start(&mut r, A); r.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity: A, candidate: Some(ResourceToken(1)), }); r.reduce(LoadEvent::Cancel { namespace: NS, identity: A, candidate: None, }); let late = r.reduce(LoadEvent::InstallSucceeded { namespace: NS, identity: A, scene: Some(ResourceToken(2)), }); assert_eq!(late.outcome, TransitionOutcome::IgnoredLateOrStale); assert_eq!(late.disposed, vec![ResourceToken(2)]); let disposed = r.reduce(LoadEvent::Dispose { namespace: NS }); assert_eq!(disposed.state, LoadState::Disposed); assert_eq!( r.reduce(LoadEvent::StartLoad { namespace: NS, identity: B }) .outcome, TransitionOutcome::IgnoredLateOrStale ); } #[test] fn preview_and_interactive_reducers_are_isolated() { let interactive = RendererNamespace::Interactive; let preview = RendererNamespace::Preview; let identity = LoadIdentity::new(3, 1); let mut interactive_reducer = LoadReducer::new(interactive); let mut preview_reducer = LoadReducer::new(preview); assert_eq!( interactive_reducer .reduce(LoadEvent::StartLoad { namespace: interactive, identity, }) .outcome, TransitionOutcome::Committed ); assert_eq!( preview_reducer .reduce(LoadEvent::StartLoad { namespace: preview, identity, }) .outcome, TransitionOutcome::Committed ); assert_eq!( preview_reducer .reduce(LoadEvent::LoadSucceeded { namespace: interactive, identity, candidate: Some(ResourceToken(8)), }) .outcome, TransitionOutcome::IgnoredLateOrStale ); assert_eq!(preview_reducer.state(), &LoadState::Loading(identity)); preview_reducer.reduce(LoadEvent::LoadSucceeded { namespace: preview, identity, candidate: None, }); // Preview acquisition reaches the handoff boundary without installing // a renderable scene in either reducer. assert_eq!(preview_reducer.state(), &LoadState::Installing(identity)); assert_eq!(preview_reducer.current_scene(), None); assert_eq!(interactive_reducer.state(), &LoadState::Loading(identity)); assert_eq!(interactive_reducer.current_scene(), None); } #[test] fn failure_and_wrong_namespace_are_cas_guarded() { let mut r = LoadReducer::new(NS); start(&mut r, A); r.reduce(LoadEvent::LoadFailed { namespace: NS, identity: A, error: err(LoadErrorCode::Parse), candidate: Some(ResourceToken(3)), }); assert!(matches!( r.state(), LoadState::Failed(1, LoadErrorCode::Parse) )); let result = r.reduce(LoadEvent::StartLoad { namespace: RendererNamespace::Preview, identity: B, }); assert_eq!(result.outcome, TransitionOutcome::IgnoredLateOrStale); } #[test] fn install_failure_returns_to_failed_state_and_releases_candidate() { let mut r = LoadReducer::new(NS); start(&mut r, A); r.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity: A, candidate: Some(ResourceToken(7)), }); let result = r.reduce(LoadEvent::LoadFailed { namespace: NS, identity: A, error: err(LoadErrorCode::Install), candidate: None, }); assert_eq!(result.outcome, TransitionOutcome::Committed); assert_eq!(result.disposed, vec![ResourceToken(7)]); assert!(matches!( r.state(), LoadState::Failed(1, LoadErrorCode::Install) )); } fn installing_reducer() -> LoadReducer { let mut r = LoadReducer::new(NS); start(&mut r, A); r.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity: A, candidate: Some(ResourceToken(1)), }); r } #[test] fn install_first_then_cancel_is_terminal_and_late_install_is_disposed() { let mut r = installing_reducer(); assert!(matches!(r.state(), LoadState::Installing(A))); assert_eq!( r.reduce(LoadEvent::Cancel { namespace: NS, identity: A, candidate: None, }) .state, LoadState::Cancelled(1) ); let late = r.reduce(LoadEvent::InstallSucceeded { namespace: NS, identity: A, scene: Some(ResourceToken(2)), }); assert_eq!(late.outcome, TransitionOutcome::IgnoredLateOrStale); assert_eq!(late.disposed, vec![ResourceToken(2)]); } #[test] fn install_first_then_supersede_rejects_late_result_for_old_identity() { let mut r = installing_reducer(); start(&mut r, B); assert!(matches!(r.state(), LoadState::Loading(B))); let late = r.reduce(LoadEvent::InstallSucceeded { namespace: NS, identity: A, scene: Some(ResourceToken(3)), }); assert_eq!(late.outcome, TransitionOutcome::IgnoredLateOrStale); assert_eq!(late.disposed, vec![ResourceToken(3)]); } #[test] fn install_first_then_dispose_rejects_late_result() { let mut r = installing_reducer(); assert_eq!( r.reduce(LoadEvent::Dispose { namespace: NS }).state, LoadState::Disposed ); let late = r.reduce(LoadEvent::InstallSucceeded { namespace: NS, identity: A, scene: Some(ResourceToken(4)), }); assert_eq!(late.outcome, TransitionOutcome::IgnoredLateOrStale); assert_eq!(late.disposed, vec![ResourceToken(4)]); } #[test] fn ready_then_cancel_clears_current_scene() { let mut r = installing_reducer(); r.reduce(LoadEvent::InstallSucceeded { namespace: NS, identity: A, scene: Some(ResourceToken(2)), }); assert!(matches!(r.state(), LoadState::Ready(A))); let result = r.reduce(LoadEvent::Cancel { namespace: NS, identity: A, candidate: None, }); assert_eq!(result.disposed, vec![ResourceToken(2)]); assert_eq!(r.state(), &LoadState::Cancelled(1)); assert_eq!(r.current_scene(), None); } #[test] fn resize_during_installing_preserves_state_and_identity() { let mut r = installing_reducer(); let result = r.reduce(LoadEvent::Resize { namespace: NS }); assert_eq!(result.outcome, TransitionOutcome::Committed); assert_eq!(r.current_identity(), Some(A)); assert!(matches!(r.state(), LoadState::Installing(A))); } #[test] fn resize_never_changes_identity_or_state() { let mut r = LoadReducer::new(NS); start(&mut r, A); let result = r.reduce(LoadEvent::Resize { namespace: NS }); assert_eq!(result.outcome, TransitionOutcome::Committed); assert_eq!(r.current_identity(), Some(A)); assert!(matches!(r.state(), LoadState::Loading(A))); } }