diff --git a/crates/hypercolor-daemon/src/domain/context.rs b/crates/hypercolor-daemon/src/domain/context.rs index 9a830ac06..ad62830cc 100644 --- a/crates/hypercolor-daemon/src/domain/context.rs +++ b/crates/hypercolor-daemon/src/domain/context.rs @@ -363,6 +363,42 @@ impl RuntimeSessionProjection { }) } + pub(crate) async fn persist_snapshot_observed( + &self, + path: &Path, + before_snapshot: Fut, + update: F, + ) -> super::scene_activation::ProjectionWriteEvidence + where + F: FnOnce(&mut RuntimeSessionSnapshot), + Fut: Future, + { + use super::scene_activation::{ProjectionDurability, ProjectionWriteEvidence}; + let mut save = match self.prepare_save(path, before_snapshot).await { + Ok(save) => save, + Err(error) => { + return ProjectionWriteEvidence { + projection: None, + durability: ProjectionDurability::BeforeAdmission(error.to_string()), + }; + } + }; + update(&mut save.snapshot); + let (projection, result) = save.commit_observed(); + let durability = match result { + Ok(AtomicWriteOutcome::Written) => ProjectionDurability::Written, + Ok(AtomicWriteOutcome::Superseded) => ProjectionDurability::Superseded, + Err(error @ runtime_state::RuntimeSessionError::Persist { .. }) => { + ProjectionDurability::Retrying(error.to_string()) + } + Err(error) => ProjectionDurability::BeforeAdmission(error.to_string()), + }; + ProjectionWriteEvidence { + projection: Some(projection), + durability, + } + } + pub(crate) async fn flush_persistence( &self, path: &Path, @@ -693,6 +729,17 @@ pub struct SceneContext { } impl SceneContext { + pub(crate) async fn set_selected_brightness( + &self, + output: &super::output::OutputContext, + expected: &super::scene_activation::ObservedScene, + brightness: f32, + ) -> Result { + output + .set_selected_brightness(&self.scenes, expected, brightness) + .await + } + pub(crate) fn new( scenes: SceneService, runtime_session: RuntimeSessionService, diff --git a/crates/hypercolor-daemon/src/domain/layout.rs b/crates/hypercolor-daemon/src/domain/layout.rs index 3a73d20f0..22d81537a 100644 --- a/crates/hypercolor-daemon/src/domain/layout.rs +++ b/crates/hypercolor-daemon/src/domain/layout.rs @@ -676,6 +676,18 @@ impl LayoutContext { .await } + pub(crate) async fn apply_selected_under_guard( + &self, + guard: &LayoutUpdateGuard, + layout: SpatialLayout, + expected: super::scene_activation::ObservedScene, + runtime: &LayoutRuntime, + ) -> super::scene_activation::SelectedLayoutOutcome { + self.publication + .apply_selected_under_guard(guard, layout, expected, runtime.driver_host()) + .await + } + pub(crate) async fn converge_persisted_update( &self, runtime: &LayoutRuntime, diff --git a/crates/hypercolor-daemon/src/domain/layout/publication.rs b/crates/hypercolor-daemon/src/domain/layout/publication.rs index 88de23a93..9248a3238 100644 --- a/crates/hypercolor-daemon/src/domain/layout/publication.rs +++ b/crates/hypercolor-daemon/src/domain/layout/publication.rs @@ -46,6 +46,77 @@ impl ActiveLayoutBindingMigration { } impl LayoutPublication { + pub(super) async fn apply_selected_under_guard( + &self, + guard: &LayoutUpdateGuard, + layout: SpatialLayout, + expected: crate::domain::scene_activation::ObservedScene, + driver_host: Arc, + ) -> crate::domain::scene_activation::SelectedLayoutOutcome { + use crate::domain::scene_activation::{ + LayoutSceneFence, ProjectionDurability, SelectedLayoutOutcome, + }; + let fence = LayoutSceneFence::new(expected); + let selected_layout = layout.clone(); + let writes = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = Arc::clone(&writes); + let context = self.persistence_context(driver_host); + let result = self + .transactions + .apply_selected_under_guard(guard, layout, Arc::clone(&fence), move |phase| { + let context = context.clone(); + let recorded = Arc::clone(&recorded); + async move { + let driver_host = Arc::clone(&context.driver_host); + let evidence = context + .runtime_projection + .persist_snapshot_observed( + &context.runtime_state_path, + async move { + driver_host.refresh_driver_inventory().await; + }, + move |snapshot| { + if let LayoutPersistencePhase::Precommit(candidate) = phase { + snapshot.active_layout_id = Some(candidate.layout.id); + if candidate.active_scene_id == Some(SceneId::DEFAULT) { + snapshot.default_scene_zones = + candidate.resolved_zones.to_vec(); + } + } + }, + ) + .await; + let outcome = match &evidence.durability { + ProjectionDurability::Written => LayoutPersistenceOutcome::Written, + ProjectionDurability::Superseded => LayoutPersistenceOutcome::Superseded, + ProjectionDurability::BeforeAdmission(error) => { + LayoutPersistenceOutcome::BeforeAdmission(error.clone()) + } + ProjectionDurability::Retrying(error) => { + LayoutPersistenceOutcome::RetryArmed(error.clone()) + } + }; + recorded + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(evidence); + outcome + } + }) + .await; + let writes = std::mem::take( + &mut *writes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + SelectedLayoutOutcome { + layout: selected_layout, + publication: result.map_err(|error| error.to_string()), + writes, + admitted_scene: fence.admitted(), + } + } + pub(super) fn new( spatial: SpatialService, scenes: SceneService, diff --git a/crates/hypercolor-daemon/src/domain/mod.rs b/crates/hypercolor-daemon/src/domain/mod.rs index e423876b3..d7e66af1a 100644 --- a/crates/hypercolor-daemon/src/domain/mod.rs +++ b/crates/hypercolor-daemon/src/domain/mod.rs @@ -47,6 +47,7 @@ pub mod openrgb_setup; pub mod output; pub mod runtime_zone; pub mod scene; +pub mod scene_activation; pub mod scene_tree; pub mod spatial; pub mod zone; diff --git a/crates/hypercolor-daemon/src/domain/output.rs b/crates/hypercolor-daemon/src/domain/output.rs index a4724a2b2..5c3f1c70e 100644 --- a/crates/hypercolor-daemon/src/domain/output.rs +++ b/crates/hypercolor-daemon/src/domain/output.rs @@ -57,6 +57,38 @@ pub struct OutputContext { } impl OutputContext { + pub(crate) async fn set_selected_brightness( + &self, + scenes: &super::scene::SceneService, + expected: &super::scene_activation::ObservedScene, + brightness: f32, + ) -> Result { + use super::scene_activation::{BrightnessDurability, SelectedBrightnessOutcome}; + if !(0.0..=1.0).contains(&brightness) { + return Err(DomainError::validation_field( + "brightness", + "brightness must be between 0.0 and 1.0", + )); + } + let transition = self.output_power.transition().await; + let _definition = scenes.guard_definition(expected).await?; + let (previous, persistence) = transition + .set_brightness_observed(&self.event_bus, brightness, || {}) + .await + .map_err(DomainError::Internal)?; + let durability = match persistence { + crate::device_settings::BrightnessPersistence::Durable => BrightnessDurability::Written, + crate::device_settings::BrightnessPersistence::Retrying(error) => { + BrightnessDurability::Retrying(error.to_string()) + } + }; + Ok(SelectedBrightnessOutcome { + value: brightness, + previous, + durability, + }) + } + #[expect( clippy::too_many_arguments, reason = "the composition root supplies the complete output ownership boundary" diff --git a/crates/hypercolor-daemon/src/domain/scene.rs b/crates/hypercolor-daemon/src/domain/scene.rs index e6d5a6402..851d7d2bb 100644 --- a/crates/hypercolor-daemon/src/domain/scene.rs +++ b/crates/hypercolor-daemon/src/domain/scene.rs @@ -159,10 +159,10 @@ pub struct ScenePlanReader(Arc); /// Named scene library and activation authority shared by every transport. #[derive(Clone)] pub struct SceneLibraryContext { - scene: SceneContext, + pub(super) scene: SceneContext, effects: crate::domain::effect::EffectContext, - layout: LayoutContext, - output: OutputContext, + pub(super) layout: LayoutContext, + pub(super) output: OutputContext, event_bus: Arc, } @@ -193,6 +193,15 @@ impl ScenePlanReader { } impl SceneService { + pub(crate) async fn guard_definition( + &self, + expected: &super::scene_activation::ObservedScene, + ) -> Result, DomainError> { + let manager = self.0.manager.read().await; + expected.validate(&manager, self.0.commits.revision())?; + Ok(manager) + } + /// Own a non-durable scene manager for isolated consumers. #[must_use] pub fn in_memory(manager: SceneManager, event_bus: Arc) -> Self { @@ -683,6 +692,7 @@ impl SceneService { store.write().await.save_reserved(pending).map(Some) } + #[cfg(test)] pub(crate) async fn publish_layout_activation( &self, spatial_engine: &SpatialService, @@ -692,10 +702,41 @@ impl SceneService { expected_resolved_zones_revision: u64, publish_renderer_state: F, ) -> Result<(), LayoutTransactionRejection> + where + F: FnOnce(SpatialEngine) -> Result<(), LayoutTransactionRejection>, + { + self.publish_guarded_layout_activation( + spatial_engine, + candidate_spatial_engine, + expected_layout, + expected_active_scene_id, + expected_resolved_zones_revision, + None, + publish_renderer_state, + ) + .await + } + + pub(crate) async fn publish_guarded_layout_activation( + &self, + spatial_engine: &SpatialService, + candidate_spatial_engine: SpatialEngine, + expected_layout: &SpatialLayout, + expected_active_scene_id: Option, + expected_resolved_zones_revision: u64, + fence: Option<&super::scene_activation::LayoutSceneFence>, + publish_renderer_state: F, + ) -> Result<(), LayoutTransactionRejection> where F: FnOnce(SpatialEngine) -> Result<(), LayoutTransactionRejection>, { let mut manager = self.0.manager.write().await; + if let Some(fence) = fence { + fence + .expected + .validate(&manager, self.0.commits.revision()) + .map_err(|_| LayoutTransactionRejection::Superseded)?; + } let source_is_current = manager.active_scene_id().copied() == expected_active_scene_id && manager.resolved_zones_revision() == expected_resolved_zones_revision && spatial_engine.has_layout(expected_layout); @@ -710,6 +751,9 @@ impl SceneService { .plan .store(Arc::new(manager.plan_snapshot(ticket.generation()))); spatial_engine.replace(candidate_spatial_engine); + if let Some(fence) = fence { + fence.publish(&manager, ticket.generation()); + } ticket.release(Vec::new()); Ok(()) } diff --git a/crates/hypercolor-daemon/src/domain/scene_activation.rs b/crates/hypercolor-daemon/src/domain/scene_activation.rs new file mode 100644 index 000000000..19522cf6c --- /dev/null +++ b/crates/hypercolor-daemon/src/domain/scene_activation.rs @@ -0,0 +1,293 @@ +//! Explicit scene activation fields and original admission evidence. + +use hypercolor_core::scene::SceneManager; +use hypercolor_types::scene::Scene; + +use super::{DomainError, ResourceKind}; +use crate::runtime_state::RuntimeSessionSnapshot; +use std::sync::{Arc, Mutex}; + +/// Original runtime projection write result, without later flush inference. +#[derive(Debug)] +pub enum ProjectionDurability { + /// The supplied payload was written durably. + Written, + /// Another admitted payload superseded this write. + Superseded, + /// The writer could not admit this payload. + BeforeAdmission(String), + /// Admission occurred but durable completion remains unresolved. + Retrying(String), +} + +/// Exact payload and original outcome at the owning runtime writer. +#[derive(Debug)] +pub struct ProjectionWriteEvidence { + /// None only when preparation failed before a payload was observed. + pub projection: Option, + /// Original outcome, never inferred from a later current snapshot. + pub durability: ProjectionDurability, +} + +/// A layout attempt includes candidate writes even when live publication fails. +#[derive(Debug)] +pub struct SelectedLayoutOutcome { + /// Exact selected named layout, not a later catalog read. + pub layout: hypercolor_types::spatial::SpatialLayout, + /// Live publication result; failure does not erase precommit writes. + pub publication: Result<(), String>, + /// Precommit followed by any rollback writes, in actual execution order. + pub writes: Vec, + /// Scene captured under the successful publication lock. + pub admitted_scene: Option, +} + +/// Surviving authored fields of one immutable scene activation intent. +#[derive(Debug, Clone)] +pub struct SelectedSceneFields { + /// Owned target definition and source revision. + pub expected: ObservedScene, + /// Exact transition; only duration may override the authored transition. + pub context: Option, + /// Complete observed named layout; None preserves current layout. + pub layout: Option, + /// Explicit authored global brightness; None preserves brightness and power. + pub brightness: Option, +} + +/// Independent results retain admission evidence when a later field fails. +#[derive(Debug)] +pub struct SelectedSceneOutcome { + /// Original context commit, absent when context was omitted. + pub context: Option, + /// Layout publication plus escaped precommit and rollback evidence. + pub layout: Option, + /// Brightness admission or its refusal, absent when omitted. + pub brightness: Option>, + /// Exact runtime save after selected context/connectivity changes. + pub runtime_session: Option, +} + +/// Apply only explicitly selected fields of the observed authored scene. +/// +/// The caller owns authorization. Existing activation, layout publication and +/// output guards own engine admission. +/// No implicit wake occurs. Partial results never imply mutation rollback. +/// +/// # Errors +/// Returns preflight or context-CAS refusal before any field was admitted. +/// Later layout/brightness failures are retained in the returned field results. +pub async fn activate_selected_fields( + ctx: &super::scene::SceneLibraryContext, + command: SelectedSceneFields, +) -> Result { + use hypercolor_types::event::SceneChangeReason; + if command.context.is_none() && command.layout.is_none() && command.brightness.is_none() { + return Err(DomainError::validation("Select at least one scene field")); + } + if let Some(brightness) = command.brightness + && (!(0.0..=1.0).contains(&brightness) + || command.expected.scene.activation_brightness != Some(brightness)) + { + return Err(DomainError::validation( + "Selected brightness must match the observed scene", + )); + } + if let Some(transition) = &command.context { + let mut authored = command.expected.scene.transition.clone(); + authored.duration_ms = transition.duration_ms; + if &authored != transition { + return Err(DomainError::validation( + "Selected transition must preserve the authored transition policy", + )); + } + } + let media = ctx.scene.media_admission_context().await; + let display = ctx + .layout + .connected_display_surface_layouts(ctx.scene.layout_runtime()) + .await; + let _activation = if command.context.is_some() { + Some(ctx.layout.acquire_scene_activation_guard().await) + } else { + None + }; + let layout_guard = if command.context.is_some() || command.layout.is_some() { + Some(ctx.layout.acquire_update_guard().await) + } else { + None + }; + let mut mutation = ctx.scene.begin_mutation().await; + command + .expected + .validate(mutation.scenes(), mutation.base_revision())?; + if let Some(layout) = &command.layout { + let layout_id = hypercolor_types::identity::LayoutId::new(layout.id.clone()) + .map_err(|error| DomainError::validation(error.to_string()))?; + if command.expected.scene.layout_id.as_ref() != Some(&layout_id) + || ctx.layout.get(&layout_id).await.as_ref() != Some(layout) + { + return Err(DomainError::conflict( + "The observed scene layout changed or is unavailable", + )); + } + } + let mut expected = command.expected; + let mut context = None; + if let Some(transition) = command.context { + let admission = media.evaluate(&expected.scene); + if let Some(message) = admission.rejection_message() { + return Err(DomainError::validation(message.to_owned())); + } + mutation.hydrate_existing_display_surfaces(expected.scene.id, &display)?; + mutation.activate( + expected.scene.id, + Some(transition), + SceneChangeReason::UserActivate, + )?; + mutation.sync_active_display_surfaces(&display); + let admitted = mutation + .scenes() + .get(&expected.scene.id) + .ok_or_else(|| DomainError::not_found(ResourceKind::Scene, expected.scene.id))? + .clone(); + let commit = ctx.scene.commit(mutation).await?; + expected = ObservedScene { + scene: admitted, + revision: commit.revision(), + }; + ctx.scene + .apply_media_soft_admission( + expected.scene.id, + &expected.scene.name, + admission.estimated_cost_us, + ) + .await; + context = Some(commit); + } + let layout = if let Some(layout) = command.layout { + let result = ctx + .layout + .apply_selected_under_guard( + layout_guard + .as_ref() + .expect("selected layout owns its update guard"), + layout, + expected.clone(), + ctx.scene.layout_runtime(), + ) + .await; + if let Some(admitted) = &result.admitted_scene { + expected = admitted.clone(); + } + Some(result) + } else { + None + }; + drop(layout_guard); + let brightness = if let Some(brightness) = command.brightness { + Some( + ctx.scene + .set_selected_brightness(&ctx.output, &expected, brightness) + .await, + ) + } else { + None + }; + let changed_context = context.is_some() + || layout + .as_ref() + .is_some_and(|result| result.publication.is_ok()); + let runtime_session = if changed_context { + ctx.layout + .sync_runtime_connectivity(ctx.scene.layout_runtime()) + .await; + Some(ctx.scene.save_runtime_session_with_outcome().await) + } else { + None + }; + Ok(SelectedSceneOutcome { + context, + layout, + brightness, + runtime_session, + }) +} + +#[derive(Debug)] +pub(crate) struct LayoutSceneFence { + pub(crate) expected: ObservedScene, + admitted: Mutex>, +} + +impl LayoutSceneFence { + pub(crate) fn new(expected: ObservedScene) -> Arc { + Arc::new(Self { + expected, + admitted: Mutex::new(None), + }) + } + + pub(crate) fn publish(&self, manager: &SceneManager, revision: u64) { + let scene = manager.get(&self.expected.scene.id).cloned(); + *self + .admitted + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + scene.map(|scene| ObservedScene { scene, revision }); + } + + pub(crate) fn admitted(&self) -> Option { + self.admitted + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +/// An owned authored definition and the manager revision observed with it. +#[derive(Debug, Clone, PartialEq)] +pub struct ObservedScene { + /// Complete expected scene, including its behavior and authored metadata. + pub scene: Scene, + /// Revision of the manager that supplied the definition. + pub revision: u64, +} + +impl ObservedScene { + pub(crate) fn validate( + &self, + manager: &SceneManager, + revision: u64, + ) -> Result<(), DomainError> { + let current = manager + .get(&self.scene.id) + .ok_or_else(|| DomainError::not_found(ResourceKind::Scene, self.scene.id))?; + if revision != self.revision || current != &self.scene { + return Err(DomainError::conflict( + "The observed scene definition changed", + )); + } + Ok(()) + } +} + +/// Stage-aware persistence of the original selected brightness value. +#[derive(Debug)] +pub enum BrightnessDurability { + /// The exact selected settings payload was durably written. + Written, + /// Replacement was admitted but durable completion remains unresolved. + Retrying(String), +} + +/// Brightness admission never changes the output pause or sleep state. +#[derive(Debug)] +pub struct SelectedBrightnessOutcome { + /// The value admitted while the scene definition guard was held. + pub value: f32, + /// Brightness immediately before this admission. + pub previous: f32, + /// Actual owning settings writer outcome. + pub durability: BrightnessDurability, +} diff --git a/crates/hypercolor-daemon/src/output_power.rs b/crates/hypercolor-daemon/src/output_power.rs index 31617a574..377f9d6a2 100644 --- a/crates/hypercolor-daemon/src/output_power.rs +++ b/crates/hypercolor-daemon/src/output_power.rs @@ -197,19 +197,10 @@ impl OutputPower { before_events: impl FnOnce(), ) -> anyhow::Result { let brightness = brightness.clamp(0.0, 1.0); - let _transition = self.inner.transition.lock().await; - let previous_live = self.global_brightness(); - let mut settings = self.inner.settings.write().await; - let persistence = - settings.persist_global_brightness(&self.inner.brightness_authority, brightness)?; - drop(settings); - self.update_state(|state| state.global_brightness = brightness); - before_events(); - event_bus.publish(HypercolorEvent::DeviceSettingsChanged { key: None }); - event_bus.publish(HypercolorEvent::BrightnessChanged { - old: brightness_percent(previous_live), - new_value: brightness_percent(brightness), - }); + let transition = self.transition().await; + let (previous_live, persistence) = transition + .set_brightness_observed(event_bus, brightness, before_events) + .await?; if let BrightnessPersistence::Retrying(error) = persistence { warn!(%error, "Global brightness persistence will retry"); } @@ -350,6 +341,28 @@ impl OutputPower { } impl OutputPowerGuard<'_> { + pub(crate) async fn set_brightness_observed( + &self, + event_bus: &HypercolorBus, + brightness: f32, + before_events: impl FnOnce(), + ) -> anyhow::Result<(f32, BrightnessPersistence)> { + let previous_live = self.power.global_brightness(); + let mut settings = self.power.inner.settings.write().await; + let persistence = settings + .persist_global_brightness(&self.power.inner.brightness_authority, brightness)?; + drop(settings); + self.power + .update_state(|state| state.global_brightness = brightness); + before_events(); + event_bus.publish(HypercolorEvent::DeviceSettingsChanged { key: None }); + event_bus.publish(HypercolorEvent::BrightnessChanged { + old: brightness_percent(previous_live), + new_value: brightness_percent(brightness), + }); + Ok((previous_live, persistence)) + } + #[must_use] pub(crate) fn snapshot(&self) -> OutputPowerState { self.power.snapshot() diff --git a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs index 22481b6ab..f0be6b7c2 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs @@ -64,6 +64,7 @@ pub(crate) async fn service_scene_transactions( } LayoutActivationDecision::Commit => { let PreparedLayoutActivation { + scene_fence, spatial_engine, expected_layout, active_scene_id, @@ -103,12 +104,13 @@ pub(crate) async fn service_scene_transactions( LayoutPublicationMode::AuthorityAndRenderer => { state .scene_manager - .publish_layout_activation( + .publish_guarded_layout_activation( &state.spatial_engine, spatial_engine, &expected_layout, active_scene_id, source_resolved_zones_revision, + scene_fence.as_deref(), publish_renderer_state, ) .await @@ -281,6 +283,7 @@ pub(crate) async fn service_scene_transactions( } }; render.pending_layout_activation = Some(PreparedLayoutActivation { + scene_fence: transaction.scene_fence(), spatial_engine, expected_layout, active_scene_id, diff --git a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs index cdded1bb1..f1a9277a8 100644 --- a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs +++ b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs @@ -1340,6 +1340,7 @@ impl PreparedCanvasResize { } pub(crate) struct PreparedLayoutActivation { + pub(crate) scene_fence: Option>, pub(crate) spatial_engine: SpatialEngine, pub(crate) expected_layout: SpatialLayout, pub(crate) active_scene_id: Option, diff --git a/crates/hypercolor-daemon/src/scene_transactions.rs b/crates/hypercolor-daemon/src/scene_transactions.rs index 6875136ac..be81d9ddd 100644 --- a/crates/hypercolor-daemon/src/scene_transactions.rs +++ b/crates/hypercolor-daemon/src/scene_transactions.rs @@ -11,6 +11,7 @@ use hypercolor_types::scene::{SceneId, UnassignedBehavior, Zone}; use hypercolor_types::spatial::SpatialLayout; use crate::domain::scene::SceneService; +use crate::domain::scene_activation::LayoutSceneFence; use crate::domain::spatial::SpatialService; #[derive(Debug, Clone, Error, PartialEq, Eq)] @@ -80,6 +81,7 @@ impl PreparedLayoutUpdate { #[derive(Debug)] pub(crate) struct PrepareLayoutTransaction { + scene_fence: Option>, spatial_engine: SpatialEngine, expected_layout: SpatialLayout, active_scene_id: Option, @@ -93,6 +95,9 @@ pub(crate) struct PrepareLayoutTransaction { } impl PrepareLayoutTransaction { + pub(crate) fn scene_fence(&self) -> Option> { + self.scene_fence.clone() + } #[must_use] pub(crate) fn spatial_engine(&self) -> &SpatialEngine { &self.spatial_engine @@ -162,6 +167,7 @@ impl PrepareLayoutTransaction { let expected_active_scene_id = self.active_scene_id; let expected_resolved_zones_revision = self.source_resolved_zones_revision; let publication_mode = self.publication_mode; + let scene_fence = self.scene_fence.clone(); self.accept(); while activation.decision() == LayoutActivationDecision::Pending { tokio::task::yield_now().await; @@ -175,12 +181,13 @@ impl PrepareLayoutTransaction { let result = match publication_mode { LayoutPublicationMode::AuthorityAndRenderer => { scene_manager - .publish_layout_activation( + .publish_guarded_layout_activation( spatial_engine, candidate_spatial_engine, &expected_layout, expected_active_scene_id, expected_resolved_zones_revision, + scene_fence.as_deref(), |_| Ok(()), ) .await @@ -387,6 +394,7 @@ impl LayoutTransactionAuthority { guard, layout, LayoutPublicationMode::AuthorityAndRenderer, + None, |_| async { LayoutPersistenceOutcome::Written }, ) .await @@ -401,6 +409,7 @@ impl LayoutTransactionAuthority { guard, layout, LayoutPublicationMode::RendererOnly, + None, |_| async { LayoutPersistenceOutcome::Written }, ) .await @@ -420,6 +429,28 @@ impl LayoutTransactionAuthority { guard, layout, LayoutPublicationMode::AuthorityAndRenderer, + None, + persist, + ) + .await + } + + pub(crate) async fn apply_selected_under_guard( + &self, + guard: &LayoutUpdateGuard, + layout: SpatialLayout, + fence: Arc, + persist: F, + ) -> Result<(), LayoutUpdateError> + where + F: FnMut(LayoutPersistencePhase) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.apply_under_guard_with_mode( + guard, + layout, + LayoutPublicationMode::AuthorityAndRenderer, + Some(fence), persist, ) .await @@ -430,6 +461,7 @@ impl LayoutTransactionAuthority { guard: &LayoutUpdateGuard, layout: SpatialLayout, publication_mode: LayoutPublicationMode, + scene_fence: Option>, mut persist: F, ) -> Result<(), LayoutUpdateError> where @@ -477,6 +509,7 @@ impl LayoutTransactionAuthority { resolved_zones_revision, unassigned_behavior, publication_mode, + scene_fence, )?; submission.preparation.wait().await?; let commit_state = LayoutPersistenceState { @@ -572,10 +605,12 @@ impl SceneTransactionQueue { resolved_zones_revision: u64, unassigned_behavior: UnassignedBehavior, publication_mode: LayoutPublicationMode, + scene_fence: Option>, ) -> Result { let (acknowledgment, receipt) = oneshot::channel(); let (activation, completion) = LayoutActivationControl::new(); self.push(SceneTransaction::PrepareLayout(PrepareLayoutTransaction { + scene_fence, spatial_engine, expected_layout, active_scene_id, diff --git a/crates/hypercolor-daemon/tests/domain_scene_service_tests.rs b/crates/hypercolor-daemon/tests/domain_scene_service_tests.rs index 87859bad4..cf8d48f20 100644 --- a/crates/hypercolor-daemon/tests/domain_scene_service_tests.rs +++ b/crates/hypercolor-daemon/tests/domain_scene_service_tests.rs @@ -738,46 +738,74 @@ async fn activation_persists_zones_after_auto_layout_convergence() { #[tokio::test] async fn activation_hydrates_only_existing_connected_display_zones() { - let (state, _tempdir) = isolated_state(); - let assigned_device = DeviceId::new(); - let unassigned_device = DeviceId::new(); - for device_id in [assigned_device, unassigned_device] { - state - .device_registry - .add(display_device_info(device_id, 320, 200)) - .await; - assert!( + for selected in [false, true] { + let (state, _tempdir) = isolated_state(); + let assigned_device = DeviceId::new(); + let unassigned_device = DeviceId::new(); + for device_id in [assigned_device, unassigned_device] { state .device_registry - .set_state(&device_id, DeviceState::Connected) - .await - ); - } - - let mut scene = named_scene("imported"); - scene.mutation_mode = SceneMutationMode::Snapshot; - scene.zones.push(imported_display_zone(assigned_device)); - let scene_id = scene.id; - seed_scene(&state, scene).await; + .add(display_device_info(device_id, 320, 200)) + .await; + assert!( + state + .device_registry + .set_state(&device_id, DeviceState::Connected) + .await + ); + } - activate_scene( - &state.domains.scene_library, - ActivateScene { - scene_id, - transition_ms: None, - }, - ) - .await - .expect("snapshot activation should hydrate derived geometry"); + let mut scene = named_scene("imported"); + scene.mutation_mode = SceneMutationMode::Snapshot; + scene.zones.push(imported_display_zone(assigned_device)); + let scene_id = scene.id; + seed_scene(&state, scene).await; + + if selected { + use hypercolor_daemon::domain::scene_activation::{ + ObservedScene, SelectedSceneFields, activate_selected_fields, + }; + let observed = state.scene_manager.begin_mutation().await; + let scene = observed + .scenes() + .get(&scene_id) + .expect("authored snapshot") + .clone(); + activate_selected_fields( + &state.domains.scene_library, + SelectedSceneFields { + context: Some(scene.transition.clone()), + expected: ObservedScene { + scene, + revision: observed.base_revision(), + }, + layout: None, + brightness: None, + }, + ) + .await + .expect("selected context hydrates connected display geometry"); + } else { + activate_scene( + &state.domains.scene_library, + ActivateScene { + scene_id, + transition_ms: None, + }, + ) + .await + .expect("snapshot activation should hydrate derived geometry"); + } - let manager = state.scene_manager.snapshot().await; - let active = manager.active_scene().expect("scene should be active"); - let assigned = active - .display_zone_for(assigned_device) - .expect("assigned display zone should remain"); - assert_eq!(assigned.layout.canvas_width, 320); - assert_eq!(assigned.layout.canvas_height, 200); - assert!(active.display_zone_for(unassigned_device).is_none()); + let manager = state.scene_manager.snapshot().await; + let active = manager.active_scene().expect("scene should be active"); + let assigned = active + .display_zone_for(assigned_device) + .expect("assigned display zone should remain"); + assert_eq!(assigned.layout.canvas_width, 320); + assert_eq!(assigned.layout.canvas_height, 200); + assert!(active.display_zone_for(unassigned_device).is_none()); + } } #[tokio::test] diff --git a/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs b/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs new file mode 100644 index 000000000..e2ac22de6 --- /dev/null +++ b/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs @@ -0,0 +1,445 @@ +#![cfg(feature = "persistence-test-hooks")] + +use hypercolor_daemon::app_state::AppState; +use hypercolor_daemon::domain::scene::commit_scene; +use hypercolor_daemon::domain::scene_activation::{ + BrightnessDurability, ObservedScene, ProjectionDurability, SelectedSceneFields, + SelectedSceneOutcome, activate_selected_fields, +}; +use hypercolor_types::api::layouts::CreateLayoutRequest; +use hypercolor_types::identity::LayoutId; +use hypercolor_types::scene::{SceneId, SceneKind}; +use hypercolor_types::spatial::SpatialLayout; +use std::sync::Arc; +use std::time::Duration; + +async fn fixture() -> ( + Arc, + tempfile::TempDir, + ObservedScene, + SpatialLayout, +) { + let dir = tempfile::tempdir().expect("fixture directory"); + let state = Arc::new(AppState::new_with_data_dir(dir.path().join("data"))); + let created = state + .domains + .layout + .create(CreateLayoutRequest { + name: "Selected layout".into(), + canvas_width: Some(800), + canvas_height: Some(450), + ..Default::default() + }) + .await + .expect("layout fixture"); + let layout = hypercolor_daemon::layout_store::load(&dir.path().join("data/layouts.json")) + .expect("persisted catalog") + .get(&created.id) + .expect("created layout") + .clone(); + let mut scene = state + .scene_manager + .snapshot() + .await + .active_scene() + .expect("default scene") + .clone(); + scene.id = SceneId::new(); + scene.name = "Selected scene".into(); + scene.kind = SceneKind::Named; + scene.layout_id = Some(LayoutId::new(layout.id.clone()).expect("layout identity")); + scene.activation_brightness = Some(0.31); + let mut mutation = state.scene_manager.begin_mutation().await; + mutation.create_scene(scene.clone()).expect("scene fixture"); + let commit = commit_scene(&state.domains.scene, mutation) + .await + .expect("scene commit"); + ( + state, + dir, + ObservedScene { + scene, + revision: commit.revision(), + }, + layout, + ) +} + +fn command(expected: &ObservedScene, layout: &SpatialLayout, mask: u8) -> SelectedSceneFields { + SelectedSceneFields { + expected: expected.clone(), + context: (mask & 1 != 0).then(|| expected.scene.transition.clone()), + layout: (mask & 2 != 0).then(|| layout.clone()), + brightness: (mask & 4 != 0).then_some(0.31), + } +} + +async fn execute(state: &AppState, command: SelectedSceneFields) -> SelectedSceneOutcome { + let workflow = activate_selected_fields(&state.domains.scene_library, command); + tokio::pin!(workflow); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + tokio::select! { + result = &mut workflow => break result.expect("selected activation"), + () = tokio::task::yield_now() => { + state.layout_publication_test_executor().execute_next_layout_publication().await.expect("renderer publication"); + } + } + } + }).await.expect("shared locks must not deadlock") +} + +#[tokio::test] +async fn all_seven_field_selections_preserve_omitted_fields_and_output_power() { + for mask in 1..8 { + let (state, _dir, expected, layout) = fixture().await; + let old_scene = state + .scene_manager + .snapshot() + .await + .active_scene_id() + .copied(); + let old_layout = state.spatial_engine.snapshot().layout().as_ref().clone(); + let old_output = hypercolor_daemon::domain::output::get_output(&state.domains.output); + let result = execute(&state, command(&expected, &layout, mask)).await; + assert_eq!(result.context.is_some(), mask & 1 != 0); + assert_eq!( + state + .scene_manager + .snapshot() + .await + .active_scene_id() + .copied(), + if mask & 1 != 0 { + Some(expected.scene.id) + } else { + old_scene + } + ); + assert_eq!( + state.spatial_engine.snapshot().layout().as_ref(), + if mask & 2 != 0 { &layout } else { &old_layout } + ); + let output = hypercolor_daemon::domain::output::get_output(&state.domains.output); + assert_eq!( + output.brightness, + if mask & 4 != 0 { + 0.31 + } else { + old_output.brightness + } + ); + assert_eq!(output.power, old_output.power); + if let Some(brightness) = result.brightness { + assert!(matches!( + brightness.expect("brightness admission").durability, + BrightnessDurability::Written + )); + } + if let Some(layout) = result.layout { + assert!(layout.publication.is_ok(), "{layout:?}"); + assert!(matches!( + layout.writes[0].durability, + ProjectionDurability::Written + )); + assert!(layout.admitted_scene.is_some()); + } + } +} + +#[tokio::test] +async fn stale_definition_invalid_fields_and_missing_layout_refuse_before_mutation() { + for case in 0..7 { + let (state, _dir, expected, layout) = fixture().await; + let old_scene = state.scene_manager.snapshot().await; + let old_layout = state.spatial_engine.snapshot().layout().as_ref().clone(); + let old_output = hypercolor_daemon::domain::output::get_output(&state.domains.output); + let mut request = command(&expected, &layout, 7); + match case { + 0 => request.expected.revision += 1, + 1 => request.expected.scene.description = Some("unobserved".into()), + 2 => request.brightness = Some(f32::NAN), + 3 => request.layout.as_mut().expect("layout").name = "changed".into(), + 4 => request.expected.scene.id = SceneId::new(), + 5 => { + request.context = None; + request.layout = None; + request.brightness = None; + } + _ => { + request.expected.scene.transition.duration_ms = request + .expected + .scene + .transition + .duration_ms + .saturating_add(1); + } + } + assert!( + activate_selected_fields(&state.domains.scene_library, request) + .await + .is_err() + ); + assert_eq!( + state.scene_manager.snapshot().await.active_scene_id(), + old_scene.active_scene_id() + ); + assert_eq!( + state.spatial_engine.snapshot().layout().as_ref(), + &old_layout + ); + assert_eq!( + hypercolor_daemon::domain::output::get_output(&state.domains.output).brightness, + old_output.brightness + ); + } +} + +#[tokio::test] +async fn definition_edit_after_prewrite_rejects_publication_and_persists_exact_rollback() { + let (state, _dir, expected, layout) = fixture().await; + let original_layout = state.spatial_engine.snapshot().layout().id.clone(); + let workflow = + activate_selected_fields(&state.domains.scene_library, command(&expected, &layout, 6)); + tokio::pin!(workflow); + let mut changed = false; + let result = tokio::time::timeout(Duration::from_secs(5), async { + loop { + tokio::select! { + result = &mut workflow => break result.expect("partial result"), + () = tokio::task::yield_now(), if !changed => { + let executor = state.layout_publication_test_executor(); + if executor.pending_layout_publications() == 0 { continue; } + let publication = executor.execute_next_layout_publication_with_hook(|| async { + let on_disk = hypercolor_daemon::runtime_state::load(&state.runtime_state_path).expect("prewrite read").expect("prewrite"); + assert_eq!(on_disk.active_layout_id.as_deref(), Some(layout.id.as_str())); + let source_zones_revision = state.scene_manager.snapshot().await.resolved_zones_revision(); + let mut mutation = state.scene_manager.begin_mutation().await; + let mut replacement = expected.scene.clone(); + replacement.description = Some("concurrent API edit".into()); + mutation.update_scene(replacement).expect("authored edit"); + commit_scene(&state.domains.scene, mutation).await.expect("concurrent commit"); + assert_eq!(state.scene_manager.snapshot().await.resolved_zones_revision(), source_zones_revision, "authored fence catches an edit invisible to the old layout guard"); + }).await; + assert!(publication.is_err()); + changed = true; + } + } + } + }).await.expect("publication and rollback cannot deadlock"); + let layout_result = result.layout.expect("selected layout"); + assert!(layout_result.publication.is_err()); + assert!(layout_result.admitted_scene.is_none()); + assert_eq!(layout_result.writes.len(), 2); + assert!( + layout_result + .writes + .iter() + .all(|write| matches!(write.durability, ProjectionDurability::Written)) + ); + assert_eq!( + layout_result.writes[0] + .projection + .as_ref() + .expect("prewrite") + .active_layout_id + .as_deref(), + Some(layout.id.as_str()) + ); + assert_eq!( + layout_result.writes[1] + .projection + .as_ref() + .expect("rollback") + .active_layout_id + .as_deref(), + Some(original_layout.as_str()) + ); + let restarted = hypercolor_daemon::runtime_state::load(&state.runtime_state_path) + .expect("restart read") + .expect("rollback snapshot"); + assert_eq!( + restarted.active_layout_id.as_deref(), + Some(original_layout.as_str()) + ); + assert!(result.brightness.expect("selected brightness").is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn brightness_retry_and_refusal_preserve_original_context_receipt() { + use hypercolor_daemon::persistence::AtomicFileWriter; + for after_replacement in [false, true] { + let (state, _dir, expected, layout) = fixture().await; + let path = state + .runtime_state_path + .parent() + .expect("state directory") + .join("device-settings.json"); + let writer = AtomicFileWriter::new(&path).expect("settings writer"); + if after_replacement { + writer.set_injected_directory_sync_failures(usize::MAX); + } else { + writer.set_injected_replace_failures(usize::MAX); + } + let result = execute(&state, command(&expected, &layout, 5)).await; + writer.set_injected_replace_failures(0); + writer.set_injected_directory_sync_failures(0); + assert!( + result.context.is_some(), + "later brightness failure cannot erase context" + ); + let brightness = result.brightness.expect("selected brightness"); + if after_replacement { + assert!(matches!( + brightness.expect("admitted settings").durability, + BrightnessDurability::Retrying(_) + )); + assert_eq!( + hypercolor_daemon::domain::output::get_output(&state.domains.output).brightness, + 0.31 + ); + } else { + assert!(brightness.is_err()); + assert_eq!( + hypercolor_daemon::domain::output::get_output(&state.domains.output).brightness, + 1.0 + ); + } + } +} + +#[tokio::test] +async fn rollback_failure_retains_escaped_candidate_write_without_an_applied_claim() { + use hypercolor_daemon::persistence::AtomicFileWriter; + let (state, _dir, expected, layout) = fixture().await; + let writer = AtomicFileWriter::new(&state.runtime_state_path).expect("runtime writer"); + let workflow = + activate_selected_fields(&state.domains.scene_library, command(&expected, &layout, 2)); + tokio::pin!(workflow); + let mut changed = false; + let result = tokio::time::timeout(Duration::from_secs(5), async { + loop { + tokio::select! { + result = &mut workflow => break result.expect("partial result"), + () = tokio::task::yield_now(), if !changed => { + let executor = state.layout_publication_test_executor(); + if executor.pending_layout_publications() == 0 { continue; } + let publication = executor.execute_next_layout_publication_with_hook(|| async { + writer.set_injected_replace_failures(usize::MAX); + let mut mutation = state.scene_manager.begin_mutation().await; + let mut replacement = expected.scene.clone(); + replacement.description = Some("concurrent edit before renderer publication".into()); + mutation.update_scene(replacement).expect("authored edit"); + commit_scene(&state.domains.scene, mutation).await.expect("concurrent commit"); + }).await; + assert!(publication.is_err()); + changed = true; + } + } + } + }).await.expect("failed rollback returns evidence"); + writer.set_injected_replace_failures(0); + let result = result.layout.expect("layout result"); + assert!(result.publication.is_err()); + assert!(result.admitted_scene.is_none()); + assert_eq!(result.writes.len(), 2); + assert!(matches!( + result.writes[0].durability, + ProjectionDurability::Written + )); + assert!(matches!( + result.writes[1].durability, + ProjectionDurability::Retrying(_) + )); + assert_eq!( + result.writes[0] + .projection + .as_ref() + .expect("escaped payload") + .active_layout_id + .as_deref(), + Some(layout.id.as_str()) + ); +} + +#[tokio::test] +async fn selected_context_enforces_media_caps_but_brightness_does_not_activate_media() { + use hypercolor_core::asset::{AssetTypeHint, AssetUploadOptions}; + use hypercolor_types::layer::{ + BlendMode, LayerAdjust, LayerSource, LayerTransform, SceneLayer, SceneLayerId, + }; + let (state, _dir, mut expected, layout) = fixture().await; + let mut zone = hypercolor_core::scene::default_primary_zone(layout.clone()); + for (name, url) in [ + ("one.stream", "https://1.1.1.1/one.m3u8"), + ("two.stream", "https://8.8.8.8/two.m3u8"), + ] { + let mut options = AssetUploadOptions::new(name); + options.type_hint = Some(AssetTypeHint::Stream); + let asset_id = state + .asset_library + .write() + .await + .add_bytes(url.as_bytes(), options) + .expect("stream descriptor only") + .record + .id; + zone.layers.push(SceneLayer { + id: SceneLayerId::new(), + name: None, + source: LayerSource::Media { + asset_id, + playback: hypercolor_types::layer::MediaPlayback::default(), + }, + blend: BlendMode::default(), + opacity: 1.0, + transform: LayerTransform::default(), + adjust: LayerAdjust::default(), + bindings: Vec::new(), + enabled: true, + }); + } + expected.scene.zones = vec![zone]; + let mut mutation = state.scene_manager.begin_mutation().await; + mutation + .update_scene(expected.scene.clone()) + .expect("authored scene"); + expected.revision = commit_scene(&state.domains.scene, mutation) + .await + .expect("fixture commit") + .revision(); + let old_scene = state + .scene_manager + .snapshot() + .await + .active_scene_id() + .copied(); + assert!( + activate_selected_fields(&state.domains.scene_library, command(&expected, &layout, 1)) + .await + .is_err() + ); + assert_eq!( + state + .scene_manager + .snapshot() + .await + .active_scene_id() + .copied(), + old_scene + ); + let output = execute(&state, command(&expected, &layout, 4)).await; + assert!(output.context.is_none()); + assert!(output.runtime_session.is_none()); + assert!(output.brightness.expect("brightness only").is_ok()); + assert_eq!( + state + .scene_manager + .snapshot() + .await + .active_scene_id() + .copied(), + old_scene + ); +}