From 4cbac9be682cb17a2018c255f500ba9749e5705b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 22:57:51 -0700 Subject: [PATCH 1/2] feat(scene): admit selected activation fields with exact evidence Automation can supersede a scene's context, layout, and brightness independently. Add an explicit selected-field operation that preserves omitted state and validates the observed scene at each owning admission boundary without implicitly waking output. Carry authored scene fences through renderer layout publication and retain exact precommit and rollback payload outcomes. Brightness returns its original settings persistence result under the shared output and scene guards. Partial failures preserve earlier admission evidence. Cover all field combinations, stale definitions, edits after prewrite, rollback failure, brightness durability failures, and media admission. Keep existing scene activation and connected-display behavior covered. Co-Authored-By: Nova (GPT-6) --- .../hypercolor-daemon/src/domain/context.rs | 47 ++ crates/hypercolor-daemon/src/domain/layout.rs | 12 + .../src/domain/layout/publication.rs | 71 +++ crates/hypercolor-daemon/src/domain/mod.rs | 1 + crates/hypercolor-daemon/src/domain/output.rs | 32 ++ crates/hypercolor-daemon/src/domain/scene.rs | 50 +- .../src/domain/scene_activation.rs | 293 ++++++++++++ crates/hypercolor-daemon/src/output_power.rs | 39 +- .../src/render_thread/frame_executor.rs | 5 +- .../src/render_thread/pipeline_runtime.rs | 1 + .../src/scene_transactions.rs | 37 +- .../tests/domain_scene_service_tests.rs | 100 ++-- .../tests/selected_scene_activation_tests.rs | 444 ++++++++++++++++++ 13 files changed, 1078 insertions(+), 54 deletions(-) create mode 100644 crates/hypercolor-daemon/src/domain/scene_activation.rs create mode 100644 crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs 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..5a5532c95 --- /dev/null +++ b/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs @@ -0,0 +1,444 @@ +#![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()); +} + +#[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 + ); +} From 5377da6e751bbe271cf11b2eaefc1b4972293fc8 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 10:09:22 -0700 Subject: [PATCH 2/2] test(daemon): gate directory-sync brightness receipt test to unix The post-replacement directory-sync failure hook on AtomicFileWriter only exists on unix, so the Windows nextest build of this integration test failed to compile. Gate the test the same way the sibling persistence tests in output_power_tests.rs and library.rs already do. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014466GJcZeEz2om6JDZ3Chm --- .../hypercolor-daemon/tests/selected_scene_activation_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs b/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs index 5a5532c95..e2ac22de6 100644 --- a/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs +++ b/crates/hypercolor-daemon/tests/selected_scene_activation_tests.rs @@ -265,6 +265,7 @@ async fn definition_edit_after_prewrite_rejects_publication_and_persists_exact_r 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;