diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index ceedc2b..f69ba78 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -528,11 +528,14 @@ fn update_power_policy(state: &mut Ferese) { }; if let Some(next) = super::power::policy_change(backend.active, backend.low_power, battery) { backend.low_power = next; - reload_outputs(state); + reload_outputs(state, false); } } -pub(crate) fn reload_outputs(state: &mut Ferese) { +pub(crate) fn reload_outputs(state: &mut Ferese, layout_dirty: bool) { + if let Some(backend) = state.direct_backend.as_mut() { + backend.topology.layout_dirty |= layout_dirty; + } reconcile_outputs(state, false); } @@ -1088,6 +1091,14 @@ pub(crate) fn render_surface( } fn redraw(state: &mut Ferese, selected: Option<&[Output]>, source: &'static std::panic::Location<'static>) { + if let Some(transition) = state.desktop_transition.as_mut() { + transition.redraw.extend( + selected + .map(|outputs| outputs.to_vec()) + .unwrap_or_else(|| state.space.outputs().cloned().collect()), + ); + return; + } if state .direct_backend .as_ref() @@ -1448,6 +1459,11 @@ fn refresh_connected_info(state: &mut Ferese) { fn remember_applied_configuration(state: &mut Ferese) { let backend = state.direct_backend.as_mut().unwrap(); + if backend.topology.reconciling { + // Applied positions are read only after the coherent logical publish. + backend.topology.remember_applied = true; + return; + } let mut applied = backend.desired_outputs.clone(); for desired in &mut applied.outputs { let monitor = backend.monitors.iter().find(|monitor| monitor.key == desired.key); diff --git a/compositor/src/backends/direct/topology.rs b/compositor/src/backends/direct/topology.rs index ee33771..1eb7391 100644 --- a/compositor/src/backends/direct/topology.rs +++ b/compositor/src/backends/direct/topology.rs @@ -77,6 +77,8 @@ pub(super) struct Topology { pub invalidated: HashSet, pub reconciling: bool, pub dirty: bool, + pub layout_dirty: bool, + pub remember_applied: bool, pub retry_timer: Option, pub settle_timer: Option, pub coalescer: crate::output_policy::Debouncer, @@ -165,6 +167,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { } paths.sort(); let mut ready = Vec::new(); + state.begin_desktop_transition(); for node in invalidated { remove_device(state, node); @@ -227,7 +230,14 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { output.frame_pending = false; output.lock_frame_pending = false; output.power.suspended(); + state.session_lock.output_added(&output.output); state.display_presentation.remove_output(&output.output); + state + .desktop_transition + .as_mut() + .unwrap() + .redraw + .insert(output.output.clone()); if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) { tracing::warn!(%error, ?node, ?crtc, "output reset failed; recreating output"); @@ -327,10 +337,19 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { } transaction::recover_last_output(state); - refresh_connected_info(state); + let mut desktop_changes = publish_runtime_desktop(state); + if std::mem::take(&mut state.direct_backend.as_mut().unwrap().topology.layout_dirty) { + desktop_changes.layout = true; + desktop_changes.shell = true; + desktop_changes.outputs.extend(state.space.outputs().cloned()); + } let backend = state.direct_backend.as_mut().unwrap(); backend.topology.reconciling = false; let retry = backend.topology.retry_deadline(backend.active); + if std::mem::take(&mut backend.topology.remember_applied) { + remember_applied_configuration(state); + } + refresh_connected_info(state); if let Some(deadline) = retry { match state .loop_handle @@ -346,16 +365,81 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { } } - state.refresh_lock_outputs(); - state.refresh_idle_inhibition(); sync_battery_timer(state); - state.restore_output_focus(); - state.relayout(); + state.finish_desktop_transition(desktop_changes); state.notify_monitor_state(); - render_all(state); lid::apply_suspend_policy(state); } +/// Publish only hardware present after application, compensation and recovery. +fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges { + let mut outputs = state + .direct_backend + .as_ref() + .unwrap() + .devices + .values() + .flat_map(|device| device.outputs.values()) + .filter(|output| output.mirror_source.is_none()) + .map(|output| { + ( + output.identity.clone(), + output.output.clone(), + output.mode, + output.settings.clone(), + ) + }) + .collect::>(); + outputs.sort_by(|left, right| left.0.cmp(&right.0)); + let mut right_edge = 0; + let runtime = outputs + .into_iter() + .map(|(identity, output, mode, settings)| { + let position = settings.position.unwrap_or_else(|| { + state + .space + .output_geometry(&output) + .map(|geometry| [geometry.loc.x, geometry.loc.y]) + .unwrap_or([right_edge, 0]) + }); + let mode = OutputMode::from(mode); + let transform = output_transform(settings.transform); + let width = (transform.transform_size(mode.size).w as f64 / settings.scale).ceil() as i32; + right_edge = right_edge.max(position[0].saturating_add(width)); + crate::state::DesktopOutput { + output, + identity, + mode, + transform, + scale: Scale::Fractional(settings.scale), + position: (position[0], position[1]).into(), + } + }) + .collect(); + let changes = state + .publish_desktop(runtime) + .expect("validated usable output inventory"); + for device in state.direct_backend.as_mut().unwrap().devices.values_mut() { + for output in device.outputs.values_mut() { + if output.mirror_source.is_none() { + if output.global.is_none() { + output.global = Some(output.output.create_global::(&state.display_handle)); + } + } else { + // Mirrors are physical scanouts, not workspace-owning outputs. + output.output.change_current_state( + Some(OutputMode::from(output.mode)), + Some(output_transform(output.settings.transform)), + Some(Scale::Fractional(output.settings.scale)), + None, + ); + } + output.surface.set_output_mode_source((&output.output).into()); + } + } + changes +} + const SETTLE: Duration = Duration::from_millis(150); /// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer. diff --git a/compositor/src/backends/direct/transaction.rs b/compositor/src/backends/direct/transaction.rs index 9d81419..7c2642b 100644 --- a/compositor/src/backends/direct/transaction.rs +++ b/compositor/src/backends/direct/transaction.rs @@ -365,6 +365,13 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> { }) .and_then(|()| output.surface.commit_frame().map_err(|error| error.to_string())); self.state.session_lock.output_added(&output.output); + self.state.display_presentation.remove_output(&output.output); + self.state + .desktop_transition + .as_mut() + .unwrap() + .redraw + .insert(output.output.clone()); output.lock_frame_pending = false; output.surface.set_output_mode_source((&output.output).into()); cancel_output_timers(&self.state.loop_handle, output); @@ -373,8 +380,8 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> { } } -/// All allocations/tests precede commits. Logical publication follows every -/// successful commit on this device; other devices are separately rollbackable. +/// All allocations/tests precede commits. Logical publication waits for the +/// final usable inventory; devices are independently applied and restored. pub(super) fn apply_device( state: &mut Ferese, node: DrmNode, @@ -436,8 +443,6 @@ pub(super) fn apply_device( for crtc in &changed { let selection = &selections[crtc]; let mut output = staged.remove(crtc).or_else(|| device.outputs.remove(crtc)).unwrap(); - let old_geometry = state.space.output_geometry(&output.output); - let old_id = state.output_id(&output.output); if output.connector != selection.connector.handle() || output.identity != selection.identity { retire(state, &mut output, node, *crtc); let (handle, _) = create_output( @@ -459,65 +464,18 @@ pub(super) fn apply_device( output.settings = selection.settings.clone(); output.mode = selection.mode; output.mirror_source = selection.mirror_source.clone(); - let position = selection.settings.position.unwrap_or_else(|| { - old_geometry - .map(|geometry| [geometry.loc.x, geometry.loc.y]) - .unwrap_or_else(|| { - [ - state - .space - .outputs() - .filter_map(|output| state.space.output_geometry(output)) - .map(|geometry| geometry.loc.x + geometry.size.w) - .max() - .unwrap_or(0), - 0, - ] - }) - }); - output.output.change_current_state( - Some(OutputMode::from(output.mode)), - Some(output_transform(output.settings.transform)), - Some(Scale::Fractional(output.settings.scale)), - Some((position[0], position[1]).into()), - ); - if output.mirror_source.is_none() { - if output.global.is_none() { - output.global = Some(output.output.create_global::(&state.display_handle)); - } - state.space.map_output(&output.output, (position[0], position[1])); - if state.output_id(&output.output).is_none() { - state.register_output(&output.output, output.identity.clone()); - } - if let Some(id) = state.output_id(&output.output) - && let Some(geometry) = state.space.output_geometry(&output.output) - { - state.output_workspaces.update_geometry( - id, - ferese_core::OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h), - ); - if old_id == Some(id) - && let Some(old) = old_geometry - { - state.reposition_output_floats( - id, - ferese_layout::Rect::new( - old.loc.x as f64, - old.loc.y as f64, - old.size.w as f64, - old.size.h as f64, - ), - ferese_layout::Rect::new( - geometry.loc.x as f64, - geometry.loc.y as f64, - geometry.size.w as f64, - geometry.size.h as f64, - ), - ); - } - } - } + // Resource metadata describes applied hardware. Wayland output state, + // workspace ownership and floating positions publish once after all GPUs. + state + .desktop_transition + .as_mut() + .expect("output reconciliation boundary") + .redraw + .insert(output.output.clone()); state.session_lock.output_added(&output.output); + if hardware_changes.contains(crtc) { + state.display_presentation.remove_output(&output.output); + } output.lock_frame_pending = false; output.surface.set_output_mode_source((&output.output).into()); cancel_output_timers(&state.loop_handle, &mut output); @@ -536,7 +494,7 @@ pub(super) fn apply_device( clock.reset_timing(); device.outputs.insert(*crtc, output); } - // Add/register destinations before removing former owners, reusing evacuation. + // Retire resources now; the final inventory determines evacuation once. for crtc in removals { let mut output = device.outputs.remove(&crtc).unwrap(); retire(state, &mut output, node, crtc); @@ -605,7 +563,7 @@ pub(super) fn recover_last_output(state: &mut Ferese) { .unwrap() .devices .values() - .any(|device| device.outputs.values().any(|output| output.global.is_some())) + .any(|device| device.outputs.values().any(|output| output.mirror_source.is_none())) { return; } @@ -654,7 +612,7 @@ pub(super) fn recover_last_output(state: &mut Ferese) { .unwrap() .devices .values() - .any(|device| device.outputs.values().any(|output| output.global.is_some())) + .any(|device| device.outputs.values().any(|output| output.mirror_source.is_none())) { remember_applied_configuration(state); state.direct_backend.as_mut().unwrap().output_error = original_error; diff --git a/compositor/src/output_transaction.rs b/compositor/src/output_transaction.rs index cb703f7..ffe6c5a 100644 --- a/compositor/src/output_transaction.rs +++ b/compositor/src/output_transaction.rs @@ -146,4 +146,51 @@ mod tests { assert_eq!(failure.unavailable, vec![1]); assert!(!fake.active.contains(&1)); } + #[test] + fn desktop_publication_uses_survivors_after_apply_and_rollback_failures() { + use ferese_core::{OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceId}; + let inventory = |active: &BTreeSet| { + active + .iter() + .map(|id| (OutputId(*id as u64), OutputGeometry::new(*id as i32 * 800, 0, 800, 600))) + .collect::>() + }; + for rollback_fails in [false, true] { + let mut hardware = Fake::new(); + hardware.active.insert(2); + hardware.previous = hardware.active.clone(); + let published = OutputWorkspaceMap::default() + .plan_desktop( + &inventory(&hardware.active), + &[WorkspaceId(1), WorkspaceId(2)], + WorkspaceId(3), + ) + .unwrap() + .outputs; + hardware.fail = Some(("commit", 1)); + hardware.restore_fails = rollback_fails; + let result = apply(&mut hardware, &[1, 3], &[]); + assert!(result.is_err()); + // Neither attempted commits nor recovery mutate the published map. + assert_eq!( + published.connected_outputs().collect::>(), + vec![OutputId(1), OutputId(2)] + ); + let plan = published + .plan_desktop( + &inventory(&hardware.active), + &[WorkspaceId(1), WorkspaceId(2)], + WorkspaceId(3), + ) + .unwrap(); + if rollback_fails { + assert_eq!(plan.outputs.connected_outputs().collect::>(), vec![OutputId(2)]); + assert_eq!(plan.outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2))); + } else { + assert_eq!(plan.outputs, published); + assert!(plan.affected_outputs.is_empty()); + } + assert_eq!(plan.outputs.output_for_workspace(WorkspaceId(3)), None); + } + } } diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index abd34c4..6fd1e7a 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -291,6 +291,10 @@ impl Ferese { } pub(crate) fn send_shell_snapshots(&mut self) { + if let Some(transition) = self.desktop_transition.as_mut() { + transition.shell_pending = true; + return; + } if self .direct_backend .as_ref() @@ -390,6 +394,11 @@ impl Ferese { } fn send_shell_snapshot(&mut self, shell: &FereseShellV1) { + if let Some(transition) = self.desktop_transition.as_mut() { + transition.shell_pending = true; + self.last_shell_snapshot = None; + return; + } // New subscribers always receive a complete snapshot. Do not update the // broadcast cache here: existing subscribers may still need this state. self.shell_snapshot_serial = self.shell_snapshot_serial.wrapping_add(1); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 6e72678..bf0d32a 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -5,6 +5,8 @@ mod hit_testing; mod layout; mod navigation; mod outputs; +mod reconciliation; +pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; mod prediction; pub(crate) use prediction::FrameScene; mod window_registry; @@ -330,6 +332,7 @@ pub struct Ferese { pub space: Space, pub workspaces: WorkspaceSet, pub output_workspaces: OutputWorkspaceMap, + pub(crate) desktop_transition: Option, pub(crate) output_ids: HashMap, outputs_by_id: HashMap, pub(crate) output_names: HashMap, @@ -562,6 +565,7 @@ impl Ferese { config.scrolling_focus_strategy, ), output_workspaces: OutputWorkspaceMap::default(), + desktop_transition: None, output_ids: HashMap::new(), outputs_by_id: HashMap::new(), output_names: HashMap::new(), @@ -688,7 +692,8 @@ impl Ferese { let input_changed = changed(&["input"]); let daemons_changed = changed(&["autostart"]); let lock_changed = changed(&["lock_screen"]); - let scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules", "output_profiles"]); + let non_output_scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules"]); + let scene_changed = non_output_scene_changed || changed(&["output_profiles"]); if self.output_profiles != config.output_profiles { crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?; @@ -789,10 +794,6 @@ impl Ferese { crate::backends::direct::reload_input_devices(self); } - if outputs_changed { - crate::backends::direct::reload_outputs(self); - } - if old_rules != self.window_rules { self.reapply_window_rules(&old_rules); } @@ -802,9 +803,11 @@ impl Ferese { self.wallpaper.reload(config.wallpaper); } - if scene_changed { + if outputs_changed && self.direct_backend.is_some() { + // Output publication also finalizes any accompanying scene changes. + crate::backends::direct::reload_outputs(self, non_output_scene_changed); + } else if scene_changed { self.relayout(); - crate::backends::direct::render_all(self); } Ok(()) diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 3a2eea3..1d95e3d 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -128,7 +128,7 @@ impl Ferese { } // The production caller supplies monotonic wall time; tests supply a fake clock. - fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool { + pub(super) fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool { if !self.animations_need_tick() { self.sync_window_stacking(); return false; diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index c249199..0511b35 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -52,7 +52,7 @@ impl Ferese { } pub fn relayout(&mut self) { - self.relayout_outputs(None); + self.relayout_outputs(None, true); } pub(crate) fn relayout_window(&mut self, id: WindowId) { @@ -66,10 +66,21 @@ impl Ferese { } pub(crate) fn relayout_on(&mut self, outputs: &[Output]) { - self.relayout_outputs(Some(outputs.to_vec())); + self.relayout_outputs(Some(outputs.to_vec()), true); } - fn relayout_outputs(&mut self, mut redraw_outputs: Option>) { + pub(super) fn relayout_desktop(&mut self) { + self.relayout_outputs(None, false); + } + + fn relayout_outputs(&mut self, mut redraw_outputs: Option>, finalize: bool) { + if self + .desktop_transition + .as_ref() + .is_some_and(|transition| !transition.publishing) + { + return; + } if self .direct_backend .as_ref() @@ -416,6 +427,9 @@ impl Ferese { self.sync_window_stacking(); self.retarget_overview(); + if !finalize { + return; + } self.send_shell_snapshots(); if let Some(outputs) = redraw_outputs { diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index 7865f6e..e5f3ffa 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -26,104 +26,82 @@ impl Ferese { } pub fn register_output(&mut self, output: &Output, identity: String) { - if self.output_ids.is_empty() { - self.reset_animation_clock(); - } - - if self.session_lock.active() { - self.session_lock.output_added(output); - self.lock_input_activity(); - } + assert!( + self.desktop_transition.is_none(), + "backend must publish its complete inventory" + ); let Some(geometry) = self.space.output_geometry(output) else { tracing::error!(output = %output.name(), "cannot register an unmapped output"); return; }; - let output_id = self.ensure_output_identity(&identity); - let fallback_workspace = self - .workspaces + let mut runtime = self.current_desktop_outputs(); + runtime.retain(|entry| entry.output != *output); + runtime.push(DesktopOutput { + output: output.clone(), + identity, + mode: output.current_mode().expect("mapped output has a mode"), + transform: output.current_transform(), + scale: output.current_scale(), + position: geometry.loc, + }); + self.begin_desktop_transition(); + let changes = self + .publish_desktop(runtime) + .expect("valid registered output inventory"); + self.finish_desktop_transition(changes); + } + + pub(crate) fn current_desktop_outputs(&self) -> Vec { + self.output_ids .iter() - .filter(|workspace| { - self.output_workspaces.output_for_workspace(workspace.id).is_none() - && self - .output_workspaces - .home_output(workspace.id) - .is_none_or(|home| home == output_id) + .filter_map(|(output, id)| { + Some(DesktopOutput { + output: output.clone(), + identity: self + .output_identity_ids + .iter() + .find(|(_, candidate)| *candidate == id)? + .0 + .clone(), + mode: output.current_mode()?, + transform: output.current_transform(), + scale: output.current_scale(), + position: self.space.output_geometry(output)?.loc, + }) }) - .map(|workspace| workspace.id) - .min_by_key(|id| id.0) - .unwrap_or_else(|| self.workspaces.create_workspace()); - let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); + .collect() + } - let returning_floats = self - .windows - .ids() - .values() - .filter_map(|id| { - let workspace = self.workspaces.workspace_for_window(*id)?; - if self.output_workspaces.home_output(workspace) != Some(output_id) { - return None; - } - let owner = self.output_workspaces.output_for_workspace(workspace)?; - let old = self.output_workspaces.geometry(owner)?; - let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else { - return None; - }; - Some((*id, workspace, rect, old)) - }) - .collect::>(); - - let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace); - - match registration { - Ok(_) => { - self.output_ids.insert(output.clone(), output_id); - self.outputs_by_id.insert(output_id, output.clone()); - self.output_names.insert(output_id, output.name()); - for (id, workspace, rect, old) in returning_floats { - if self.output_workspaces.output_for_workspace(workspace) == Some(output_id) { - let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64); - let new = Rect::new( - geometry.x as f64, - geometry.y as f64, - geometry.width as f64, - geometry.height as f64, - ); - let _ = self - .workspaces - .set_floating_rect(id, moved_floating_rect(rect, old, new)); - } - } - if self - .direct_backend - .as_ref() - .is_some_and(|backend| backend.reconciling()) - { - return; - } - self.restore_output_focus(); - let visible = self.visible_workspace_ids(); - self.reconcile_workspaces(&visible); - self.send_shell_snapshots(); - } - Err(error) => { - tracing::error!(%error, output = %output.name(), "failed to register output"); - } + /// Resource invalidation is immediate; ownership and focus wait for the + /// complete usable inventory, including when the final output disappears. + pub fn unregister_output(&mut self, output: &Output) { + let standalone = self.desktop_transition.is_none(); + if standalone { + self.begin_desktop_transition(); + } + self.retire_output_resources(output); + if standalone { + let runtime = self + .current_desktop_outputs() + .into_iter() + .filter(|entry| entry.output != *output) + .collect(); + let changes = self.publish_desktop(runtime).expect("valid surviving output inventory"); + self.finish_desktop_transition(changes); } } - pub fn unregister_output(&mut self, output: &Output) { - self.display_presentation.remove_output(output); - self.refresh_idle_inhibition(); - - let Some(output_id) = self.output_ids.remove(output) else { + pub(super) fn retire_output_resources(&mut self, output: &Output) { + if let Some(transition) = self.desktop_transition.as_mut() + && !transition.retired.insert(output.clone()) + { return; - }; - + } + self.display_presentation.remove_output(output); self.output_redraw_pending.retain(|pending| pending != output); - self.outputs_by_id.remove(&output_id); - self.output_names.remove(&output_id); - self.workspace_slides.remove(&output_id); - self.render.remove_output(output_id); + if let Some(id) = self.output_ids.get(output) { + self.render.remove_output(*id); + } self.pending_screencopies.retain(|capture| { if capture.output == *output { capture.fail(); @@ -132,76 +110,9 @@ impl Ferese { true } }); - self.space.unmap_output(output); self.session_lock.surfaces.remove(output); self.session_lock.backgrounds.remove(output); self.session_lock.output_removed(output); - self.refresh_lock_outputs(); - - let old_geometry = self.output_workspaces.geometry(output_id); - let evacuated = self - .output_workspaces - .assigned_workspaces(output_id) - .collect::>(); - match self.output_workspaces.disconnect(output_id) { - Ok(Some(target)) => { - if let (Some(old), Some(new)) = (old_geometry, self.output_workspaces.geometry(target)) { - let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64); - let new = Rect::new(new.x as f64, new.y as f64, new.width as f64, new.height as f64); - let floats = self - .windows - .ids() - .values() - .filter_map(|id| { - let workspace = self.workspaces.workspace_for_window(*id)?; - if !evacuated.contains(&workspace) { - return None; - } - match self.workspaces.placement(*id)? { - WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))), - _ => None, - } - }) - .collect::>(); - for (id, rect) in floats { - let _ = self.workspaces.set_floating_rect(id, rect); - } - } - if let Some(workspace) = self.output_workspaces.active_workspace(target) { - self.activate_output_workspace(target, workspace); - } - } - Ok(None) => { - self.focused_window = None; - self.restore_keyboard_focus(); - } - Err(error) => { - tracing::error!(%error, output = %output.name(), "failed to unregister output") - } - } - - self.relayout(); - } - - pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) { - let floats = self - .windows - .ids() - .values() - .filter_map(|id| { - let workspace = self.workspaces.workspace_for_window(*id)?; - if self.output_workspaces.output_for_workspace(workspace) != Some(output) { - return None; - } - match self.workspaces.placement(*id)? { - WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))), - _ => None, - } - }) - .collect::>(); - for (id, rect) in floats { - let _ = self.workspaces.set_floating_rect(id, rect); - } } pub(super) fn activate_output_workspace(&mut self, output: OutputId, workspace: ferese_core::WorkspaceId) { diff --git a/compositor/src/state/reconciliation.rs b/compositor/src/state/reconciliation.rs new file mode 100644 index 0000000..529498d --- /dev/null +++ b/compositor/src/state/reconciliation.rs @@ -0,0 +1,538 @@ +//! Exceptional topology publication. Ordinary input/damage never constructs a plan. +use super::*; +use smithay::output::{Mode, Scale}; +use smithay::utils::Transform; + +/// Mutable resource bookkeeping lives outside the pure ownership plan. +#[derive(Default)] +pub(crate) struct DesktopTransition { + pub retired: HashSet, + pub redraw: HashSet, + pub publishing: bool, + pub shell_pending: bool, +} + +/// The backend reports hardware that actually survived application/rollback. +pub(crate) struct DesktopOutput { + pub output: Output, + pub identity: String, + pub mode: Mode, + pub transform: Transform, + pub scale: Scale, + pub position: Point, +} + +impl DesktopOutput { + fn geometry(&self) -> OutputGeometry { + let size = self + .transform + .transform_size(self.mode.size) + .to_f64() + .to_logical(self.scale.fractional_scale()) + .to_i32_ceil(); + OutputGeometry::new(self.position.x, self.position.y, size.w, size.h) + } +} + +#[derive(Default)] +pub(crate) struct DesktopChanges { + pub layout: bool, + pub focus: bool, + pub shell: bool, + pub outputs: HashSet, +} + +impl Ferese { + pub(crate) fn begin_desktop_transition(&mut self) { + assert!(self.desktop_transition.is_none(), "nested desktop transition"); + self.desktop_transition = Some(DesktopTransition::default()); + } + + /// No event-loop dispatch occurs between planning and publication. Only the + /// small ownership map is staged; layout and animation remain authoritative. + pub(crate) fn publish_desktop( + &mut self, + runtime: Vec, + ) -> Result { + assert!(self.desktop_transition.is_some()); + let inventory = runtime + .iter() + .map(|output| (self.ensure_output_identity(&output.identity), output.geometry())) + .collect::>(); + let workspaces = self.workspaces.iter().map(|workspace| workspace.id).collect::>(); + let plan = self + .output_workspaces + .plan_desktop(&inventory, &workspaces, self.workspaces.next_workspace_id())?; + let mut changes = DesktopChanges { + layout: !plan.affected_outputs.is_empty(), + focus: plan.focus_changed, + shell: !plan.affected_outputs.is_empty(), + outputs: self.desktop_transition.as_ref().unwrap().redraw.clone(), + }; + let floats = self + .windows + .ids() + .values() + .filter_map(|id| { + let workspace = self.workspaces.workspace_for_window(*id)?; + let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else { + return None; + }; + let destination = plan.outputs.output_for_workspace(workspace)?; + let new = plan.outputs.geometry(destination)?; + let old_owner = self.output_workspaces.output_for_workspace(workspace); + let old = old_owner.and_then(|owner| self.output_workspaces.geometry(owner)); + (old_owner != Some(destination) || old != Some(new)).then(|| { + let bounds = |g: OutputGeometry| Rect::new(g.x as f64, g.y as f64, g.width as f64, g.height as f64); + (*id, moved_floating_rect(rect, bounds(old.unwrap_or(new)), bounds(new))) + }) + }) + .collect::>(); + + // All fallible planning precedes publication. Resource retirement may + // already have invalidated unusable scanouts, but not desktop policy. + for id in plan.created_workspaces { + assert_eq!(self.workspaces.create_workspace(), id); + } + let surviving = runtime.iter().map(|output| output.output.clone()).collect::>(); + let old_outputs = self.output_ids.keys().cloned().collect::>(); + for output in old_outputs { + if !surviving.contains(&output) { + self.retire_output_resources(&output); + self.space.unmap_output(&output); + } + } + if self.output_ids.is_empty() && !runtime.is_empty() { + self.reset_animation_clock(); + } + self.output_ids.clear(); + self.outputs_by_id.clear(); + self.output_names.clear(); + for (output, (id, geometry)) in runtime.into_iter().zip(inventory) { + let state_changed = output.output.current_mode() != Some(output.mode) + || output.output.current_transform() != output.transform + || output.output.current_scale().fractional_scale() != output.scale.fractional_scale() + || self.space.output_geometry(&output.output).is_none_or(|old| { + old.loc != output.position || old.size.w != geometry.width || old.size.h != geometry.height + }); + if state_changed { + output.output.change_current_state( + Some(output.mode), + Some(output.transform), + Some(output.scale), + Some(output.position), + ); + self.space.map_output(&output.output, output.position); + changes.layout = true; + changes.shell = true; + } + if state_changed || plan.affected_outputs.contains(&id) { + changes.outputs.insert(output.output.clone()); + } + if state_changed && self.session_lock.active() { + self.session_lock.output_added(&output.output); + } + self.output_names.insert(id, output.output.name()); + self.outputs_by_id.insert(id, output.output.clone()); + self.output_ids.insert(output.output, id); + } + // Slides referencing evacuated workspaces cannot survive ownership + // changes. Unaffected slide/world/viewport/resize owners remain intact. + self.workspace_slides.retain(|id, slide| { + plan.outputs.geometry(*id).is_some() + && slide + .items + .iter() + .all(|item| plan.outputs.output_for_workspace(item.workspace) == Some(*id)) + }); + self.output_workspaces = plan.outputs; + for (id, rect) in floats { + self.workspaces + .set_floating_rect(id, rect) + .expect("planned floating window exists"); + } + self.refresh_workspace_slide_offsets(); + if changes.focus { + if let Some(output) = self.output_workspaces.focused_output() + && let Some(workspace) = self.output_workspaces.active_workspace(output) + { + self.activate_output_workspace(output, workspace); + } else { + self.focused_window = None; + } + } + Ok(changes) + } + + pub(crate) fn finish_desktop_transition(&mut self, mut changes: DesktopChanges) { + self.desktop_transition.as_mut().unwrap().publishing = true; + if changes.layout { + self.relayout_desktop(); + } + if changes.focus || self.session_lock.active() { + self.restore_keyboard_focus(); + } + self.refresh_lock_outputs(); + self.refresh_idle_inhibition(); + let batch = self.desktop_transition.take().unwrap(); + changes.outputs.extend(batch.redraw); + // Animation/layout helpers may request snapshots, but only this point + // can emit the complete topology generation. + if changes.shell || changes.layout || changes.focus || batch.shell_pending { + self.send_shell_snapshots(); + } + changes.outputs.retain(|output| { + self.output_ids.contains_key(output) + || self + .direct_backend + .as_ref() + .is_some_and(|backend| backend.physical_outputs().any(|physical| physical == output)) + }); + if !changes.outputs.is_empty() { + crate::backends::direct::render_on(self, &changes.outputs.into_iter().collect::>()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use smithay::output::{PhysicalProperties, Subpixel}; + use smithay::reexports::wayland_server::Resource; + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + + fn output(name: &str, x: i32, width: i32) -> DesktopOutput { + DesktopOutput { + output: Output::new( + name.into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ), + identity: name.into(), + mode: Mode { + size: (width, 800).into(), + refresh: 60_000, + }, + transform: Transform::Normal, + scale: Scale::Fractional(1.0), + position: (x, 0).into(), + } + } + + fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) { + for word in [object, (((args.len() + 2) * 4) as u32) << 16 | opcode] + .into_iter() + .chain(args.iter().copied()) + { + wire.write_all(&word.to_ne_bytes()).unwrap(); + } + } + + fn window(state: &mut Ferese, event_loop: &mut EventLoop<'static, Ferese>) -> (Window, UnixStream) { + use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase; + use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor; + use smithay::wayland::shell::xdg::XdgWmBaseUserData; + let (server, mut wire) = UnixStream::pair().unwrap(); + let client = state + .display_handle + .insert_client(server, Arc::new(ClientState::default())) + .unwrap(); + let compositor = client + .create_resource::(&state.display_handle, 6, ()) + .unwrap(); + let shell = client + .create_resource::(&state.display_handle, 6, Default::default()) + .unwrap(); + request(&mut wire, compositor.id().protocol_id(), 0, &[2]); + request(&mut wire, shell.id().protocol_id(), 2, &[3, 2]); + request(&mut wire, 3, 1, &[4]); + event_loop.dispatch(Duration::from_millis(10), state).unwrap(); + let window = state + .space + .elements() + .find(|window| { + window + .toplevel() + .unwrap() + .wl_surface() + .client() + .is_some_and(|owner| owner.id() == client.id()) + }) + .unwrap() + .clone(); + (window, wire) + } + + fn events(state: &mut Ferese, wire: &mut UnixStream) -> Vec<(u32, u32, Vec)> { + state.display_handle.flush_clients().unwrap(); + wire.set_nonblocking(true).unwrap(); + let mut bytes = Vec::new(); + match wire.read_to_end(&mut bytes) { + Ok(_) => {} + Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock), + } + let mut result = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + let word = |at| u32::from_ne_bytes(bytes[at..at + 4].try_into().unwrap()); + let header = word(offset + 4); + let end = offset + (header >> 16) as usize; + result.push(( + word(offset), + header & 0xffff, + (offset + 8..end).step_by(4).map(word).collect(), + )); + offset = end; + } + result + } + + #[test] + fn planned_geometry_matches_smithay_at_fractional_scale_and_rotation() { + for transform in [Transform::Normal, Transform::_90, Transform::Flipped270] { + let mut entry = output("geometry", -500, 1920); + entry.mode.size.h = 1080; + entry.scale = Scale::Fractional(1.75); + entry.transform = transform; + entry.output.change_current_state( + Some(entry.mode), + Some(transform), + Some(entry.scale), + Some(entry.position), + ); + let mut space = Space::::default(); + space.map_output(&entry.output, entry.position); + let actual = space.output_geometry(&entry.output).unwrap(); + assert_eq!( + entry.geometry(), + OutputGeometry::new(actual.loc.x, actual.loc.y, actual.size.w, actual.size.h) + ); + } + } + + #[test] + fn topology_publication_protocol_and_readiness_regressions() { + // The subprocess owns private sockets and config; never touches a live session. + if std::env::var_os("FERESE_DESKTOP_TEST_CHILD").is_none() { + let runtime = tempfile::tempdir().unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "state::reconciliation::tests::topology_publication_protocol_and_readiness_regressions", + "--nocapture", + ]) + .env("FERESE_DESKTOP_TEST_CHILD", "1") + .env("XDG_RUNTIME_DIR", runtime.path()) + .env("XDG_CONFIG_HOME", runtime.path()) + .env("XDG_STATE_HOME", runtime.path()) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + return; + } + let mut event_loop = EventLoop::try_new().unwrap(); + let config = crate::config::Config::default().runtime_config().unwrap(); + let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap(); + let a = output("a", 0, 1200); + let b = output("b", 1200, 800); + let a_handle = a.output.clone(); + let b_handle = b.output.clone(); + state.begin_desktop_transition(); + let changes = state.publish_desktop(vec![a, b]).unwrap(); + state.finish_desktop_transition(changes); + let a_id = state.output_ids[&a_handle]; + let b_id = state.output_ids[&b_handle]; + let b_workspace = state.output_workspaces.active_workspace(b_id).unwrap(); + + // Real protocol resources let us observe exactly what clients receive. + let (server, mut shell_wire) = UnixStream::pair().unwrap(); + let client = state + .display_handle + .insert_client(server, Arc::new(ClientState::default())) + .unwrap(); + let shell = client + .create_resource::(&state.display_handle, 6, ()) + .unwrap(); + state.shell_resources.push(shell.downgrade()); + state.send_shell_snapshots(); + events(&mut state, &mut shell_wire); + let original_serial = state.shell_snapshot_serial; + + // Invalid planning leaves output metadata, ownership and shell unchanged. + let original = state.output_workspaces.clone(); + state.begin_desktop_transition(); + let mut invalid = state.current_desktop_outputs(); + invalid[0].mode.size.w = 0; + assert!(state.publish_desktop(invalid).is_err()); + assert_eq!(state.output_workspaces, original); + assert_eq!(a_handle.current_mode().unwrap().size.w, 1200); + assert!(events(&mut state, &mut shell_wire).is_empty()); + state.finish_desktop_transition(DesktopChanges::default()); + assert_eq!(state.shell_snapshot_serial, original_serial); + + // A real XDG toplevel needs a new size; it deliberately never acknowledges it. + let (slow, _slow_wire) = window(&mut state, &mut event_loop); + let slow_id = WindowId(101); + let initial = Rect::new(1500.0, 500.0, 600.0, 600.0); + state + .workspaces + .insert_floating_window(slow_id, b_workspace, initial, true) + .unwrap(); + state.windows.register_window(slow.clone(), slow_id); + let mut geometry = WindowGeometry::new(initial, None); + geometry.client.committed_size = Some(ClientSize::from_rect(initial)); + state.windows.set_geometry(slow_id, geometry); + state.output_workspaces.focus_output(b_id).unwrap(); + state.restore_output_focus(); + state.relayout(); + events(&mut state, &mut shell_wire); + let before_serial = state.shell_snapshot_serial; + let spare = state.workspaces.create_workspace(); + state.output_workspaces.assign_workspace(b_id, spare).unwrap(); + state.workspace_slides.insert( + b_id, + WorkspaceSlide::new(None, spare, b_workspace, SwipeDirection::Right), + ); + + state.begin_desktop_transition(); + state.unregister_output(&b_handle); + state.unregister_output(&b_handle); // Duplicate retirement is harmless. + assert_eq!( + state.workspaces.placement(slow_id), + Some(WindowPlacement::Floating { rect: initial }) + ); + assert_eq!(state.output_workspaces.output_for_workspace(b_workspace), Some(b_id)); + state.send_shell_snapshots(); + assert!(events(&mut state, &mut shell_wire).is_empty()); + let mut runtime = state.current_desktop_outputs(); + runtime.retain(|entry| entry.output == a_handle); + runtime[0].mode.size = (500, 400).into(); + let changes = state.publish_desktop(runtime).unwrap(); + assert_eq!(state.output_workspaces.output_for_workspace(b_workspace), Some(a_id)); + assert_eq!(state.focused_window, Some(slow_id)); + assert!(!state.workspace_slides.contains_key(&b_id)); + state.send_shell_snapshots(); + assert!(events(&mut state, &mut shell_wire).is_empty()); + state.finish_desktop_transition(changes); + assert_eq!(state.shell_snapshot_serial, before_serial + 1); + let published = events(&mut state, &mut shell_wire); + let shell_events = published + .iter() + .filter(|(id, _, _)| *id == shell.id().protocol_id()) + .collect::>(); + assert_eq!(shell_events.iter().filter(|(_, opcode, _)| *opcode == 0).count(), 1); + assert_eq!(shell_events.iter().filter(|(_, opcode, _)| *opcode == 4).count(), 1); + assert_eq!(shell_events.iter().filter(|(_, opcode, _)| *opcode == 2).count(), 1); + for (_, opcode, args) in &shell_events { + if *opcode == 3 { + assert_eq!(args[3], a_id.0 as u32); + if args[1] == b_workspace.0 as u32 { + assert_eq!(&args[args.len() - 2..], &[1, 1]); + } + } else if *opcode == 2 { + let active = 3 + args[2].div_ceil(4) as usize; + assert_eq!(args[active + 1], b_workspace.0 as u32); + assert_eq!(args[active + 2], 1); + } else if *opcode == 1 { + assert_eq!(args[1], slow_id.0 as u32); + assert_eq!(args[3], b_workspace.0 as u32); + } + } + let WindowPlacement::Floating { rect } = state.workspaces.placement(slow_id).unwrap() else { + panic!() + }; + assert!(rect.x >= 0.0 && rect.y >= 0.0 && rect.x + rect.width <= 500.0 && rect.y + rect.height <= 400.0); + assert!( + state.windows.transaction(&slow_id).is_some(), + "topology commit must not await XDG acknowledgement" + ); + assert_eq!( + state.windows.geometry(&slow_id).unwrap().client.committed_size, + Some(ClientSize::from_rect(initial)) + ); + + // A separate workspace/output keeps progressing while the slow client waits. + state.begin_desktop_transition(); + let mut runtime = state.current_desktop_outputs(); + runtime.push(output("c", 500, 800)); + let changes = state.publish_desktop(runtime).unwrap(); + state.finish_desktop_transition(changes); + let c_id = state.persistent_output_id("c").unwrap(); + let c_workspace = state.output_workspaces.active_workspace(c_id).unwrap(); + let mut viewport = AnimatedValue::new(0.0); + viewport.set_target(200.0); + state.viewport_animations.insert(c_workspace, viewport); + let now = state.start_time.elapsed(); + state.advance_animations_at(Duration::from_millis(16), now + Duration::from_millis(16)); + assert!(state.viewport_animations[&c_workspace].current > 0.0); + assert!(state.windows.transaction(&slow_id).is_some()); + state.advance_animations_at(Duration::from_millis(334), now + Duration::from_millis(350)); + assert!(state.windows.transaction(&slow_id).is_none()); + + // A no-op/resume inventory keeps ownership and emits no duplicate generation. + events(&mut state, &mut shell_wire); + let before = state.output_workspaces.clone(); + for _ in 0..3 { + state.begin_desktop_transition(); + let runtime = state.current_desktop_outputs(); + let changes = state.publish_desktop(runtime).unwrap(); + assert!(!changes.layout && !changes.focus && !changes.shell); + assert!(changes.outputs.is_empty()); + state.finish_desktop_transition(changes); + } + assert_eq!(state.output_workspaces, before); + assert!(events(&mut state, &mut shell_wire).is_empty()); + assert_eq!(state.workspaces.workspace_for_window(slow_id), Some(b_workspace)); + + let unfocused = state.outputs_by_id[&c_id].clone(); + let focused = state.focused_window; + state.unregister_output(&unfocused); + assert_eq!( + state.focused_window, focused, + "removing a non-focused output preserves window focus" + ); + + // Lock protection remains authoritative through headless loss/recovery. + use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1; + let (server, mut lock_wire) = UnixStream::pair().unwrap(); + let client = state + .display_handle + .insert_client(server, Arc::new(ClientState::default())) + .unwrap(); + let lock = client + .create_resource::(&state.display_handle, 1, ()) + .unwrap(); + request(&mut lock_wire, lock.id().protocol_id(), 1, &[2]); + event_loop.dispatch(Duration::from_millis(10), &mut state).unwrap(); + assert!(state.session_lock.active()); + state.begin_desktop_transition(); + for output in state.output_ids.keys().cloned().collect::>() { + state.unregister_output(&output); + } + let changes = state.publish_desktop(Vec::new()).unwrap(); + state.finish_desktop_transition(changes); + assert!(state.output_ids.is_empty()); + assert!(state.space.outputs().next().is_none()); + assert!(state.output_workspaces.focused_output().is_none()); + assert!(state.focused_window.is_none()); + assert!(state.workspace_slides.is_empty()); + assert!(state.seat.get_keyboard().unwrap().current_focus().is_none()); + assert_eq!(state.workspaces.workspace_for_window(slow_id), Some(b_workspace)); + state.begin_desktop_transition(); + let changes = state.publish_desktop(vec![output("a", 0, 1200)]).unwrap(); + state.finish_desktop_transition(changes); + assert_eq!(state.output_workspaces.output_for_workspace(b_workspace), Some(a_id)); + assert!(state.session_lock.active()); + assert!(state.surface_under((20.0, 20.0).into()).is_none()); + } +} diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 8818179..7edb9b5 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -103,14 +103,13 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() .unwrap_or(60_000) as i32; refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64)); - output.change_current_state( - Some(Mode { size, refresh: rate }), - None, - Some(Scale::Fractional(scale)), - None, - ); - - state.relayout(); + let mut runtime = state.current_desktop_outputs(); + let resized = runtime.iter_mut().find(|entry| entry.output == output).unwrap(); + resized.mode = Mode { size, refresh: rate }; + resized.scale = Scale::Fractional(scale); + state.begin_desktop_transition(); + let changes = state.publish_desktop(runtime).expect("valid nested output geometry"); + state.finish_desktop_transition(changes); if let Err(error) = state.display_handle.flush_clients() { tracing::debug!(%error, "failed to flush output-resize configure"); } diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 0d562dd..f82b0f8 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -6,7 +6,7 @@ mod workspace; mod workspace_policy; pub use focus::{FocusCycle, FocusHistory}; -pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; +pub use output::{DesktopPlan, OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use workspace::{ LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet, }; diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index feb525a..4a11d81 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -32,6 +32,7 @@ impl OutputGeometry { pub enum OutputError { AlreadyConnected(OutputId), UnknownOutput(OutputId), + InvalidGeometry(OutputId), WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId }, } @@ -40,6 +41,7 @@ impl fmt::Display for OutputError { match self { Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"), Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"), + Self::InvalidGeometry(output) => write!(formatter, "invalid geometry for output {output:?}"), Self::WorkspaceAlreadyAssigned { workspace, output } => { write!(formatter, "workspace {workspace:?} is assigned to {output:?}") } @@ -55,7 +57,7 @@ pub enum WorkspaceSwitch { FocusedExisting(OutputId), } -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] struct OutputState { geometry: OutputGeometry, active: WorkspaceId, @@ -72,20 +74,20 @@ impl OutputState { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] struct EvacuatedWorkspace { workspace: WorkspaceId, target: Option, revision: u64, } -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] struct EvacuationRecord { active: WorkspaceId, workspaces: Vec, } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OutputWorkspaceMap { outputs: BTreeMap, assignments: HashMap, @@ -96,7 +98,110 @@ pub struct OutputWorkspaceMap { orphaned_focus: Option, } +/// A staged ownership decision. No windows, renderers or protocol resources are copied. +#[derive(Debug)] +pub struct DesktopPlan { + pub outputs: OutputWorkspaceMap, + pub created_workspaces: Vec, + pub affected_outputs: BTreeSet, + pub focus_changed: bool, +} + impl OutputWorkspaceMap { + /// Plan against the final usable inventory, never a per-device intermediate. + /// An empty inventory is a valid headless desktop after physical loss. + pub fn plan_desktop( + &self, + usable: &[(OutputId, OutputGeometry)], + workspaces: &[WorkspaceId], + mut next_workspace: WorkspaceId, + ) -> Result { + let mut desired = BTreeMap::new(); + for &(id, geometry) in usable { + if geometry.width <= 0 + || geometry.height <= 0 + || geometry.x.checked_add(geometry.width).is_none() + || geometry.y.checked_add(geometry.height).is_none() + { + return Err(OutputError::InvalidGeometry(id)); + } + if desired.insert(id, geometry).is_some() { + return Err(OutputError::AlreadyConnected(id)); + } + } + let returning = desired + .keys() + .filter(|id| !self.outputs.contains_key(id)) + .filter_map(|id| self.evacuations.get(id).map(|record| (*id, record.clone()))) + .collect::>(); + let mut outputs = self.clone(); + let mut created_workspaces = Vec::new(); + // New destinations exist before evacuation. Temporary removals must + // never become migration destinations, even when several GPUs disappear. + for (&id, &geometry) in &desired { + if outputs.outputs.contains_key(&id) { + outputs.update_geometry(id, geometry); + continue; + } + let fallback = workspaces + .iter() + .copied() + .filter(|workspace| { + outputs.output_for_workspace(*workspace).is_none() + && outputs.home_output(*workspace).is_none_or(|home| home == id) + }) + .min() + .unwrap_or_else(|| { + let workspace = next_workspace; + next_workspace.0 = next_workspace.0.checked_add(1).expect("workspace IDs exhausted"); + created_workspaces.push(workspace); + workspace + }); + outputs.connect(id, geometry, fallback)?; + } + let mut removed = self + .connected_outputs() + .filter(|id| !desired.contains_key(id)) + .map(|id| (id, outputs.outputs.remove(&id).unwrap())) + .collect::>(); + // Preserve the original focused workspace when several outputs vanish. + removed.sort_by_key(|(id, _)| (Some(*id) == self.focused, *id)); + for (id, state) in removed { + outputs.evacuate(id, state); + } + // A returning output may initially be unable to reclaim the only + // workspace of a donor that is itself retiring. Retry the same policy + // against the final inventory, where that donor no longer needs it. + for (id, record) in returning { + let reclaimed = outputs.reclaim(id, &record); + let state = outputs.outputs.get_mut(&id).unwrap(); + state.workspaces.extend(reclaimed.iter().copied()); + if reclaimed.contains(&record.active) { + state.activate(record.active); + } + } + debug_assert!(outputs.validate()); + let mut affected_outputs = BTreeSet::new(); + for id in self.connected_outputs().chain(outputs.connected_outputs()) { + if self.outputs.get(&id) != outputs.outputs.get(&id) { + affected_outputs.insert(id); + } + } + let focus_changed = self.focused != outputs.focused + || self.focused.and_then(|id| self.active_workspace(id)) + != outputs.focused.and_then(|id| outputs.active_workspace(id)); + if focus_changed { + affected_outputs.extend(self.focused); + affected_outputs.extend(outputs.focused); + } + Ok(DesktopPlan { + outputs, + created_workspaces, + affected_outputs, + focus_changed, + }) + } + pub fn assigned_workspaces(&self, output: OutputId) -> impl DoubleEndedIterator + '_ { self.outputs .get(&output) @@ -170,6 +275,56 @@ impl OutputWorkspaceMap { } } + fn reclaim(&mut self, output: OutputId, record: &EvacuationRecord) -> Vec { + let mut reclaimed = Vec::new(); + for evacuated in &record.workspaces { + let target = self + .assignments + .get(&evacuated.workspace) + .copied() + .or_else(|| evacuated.target.filter(|target| self.outputs.contains_key(target))); + let assignment_matches = self.homes.get(&evacuated.workspace) == Some(&output); + let revision_matches = self.revision(evacuated.workspace) == evacuated.revision; + + if assignment_matches && revision_matches { + if target == Some(output) { + reclaimed.push(evacuated.workspace); + continue; + } + // Cleanup may have removed every other workspace while this + // monitor was disconnected. Never strand the donor output; + // the reconnecting monitor can use its fresh fallback. + if target + .and_then(|target| self.outputs.get(&target)) + .is_some_and(|state| state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)) + { + continue; + } + + if let Some(target) = target + && let Some(target_state) = self.outputs.get_mut(&target) + { + target_state.workspaces.remove(&evacuated.workspace); + if target_state.previous == Some(evacuated.workspace) { + target_state.previous = None; + } + if target_state.active == evacuated.workspace + && let Some(active) = target_state + .workspaces + .iter() + .copied() + .min_by_key(|workspace| workspace.0) + { + target_state.activate(active); + } + } + self.assignments.insert(evacuated.workspace, output); + reclaimed.push(evacuated.workspace); + } + } + reclaimed + } + pub fn connect( &mut self, output: OutputId, @@ -181,52 +336,10 @@ impl OutputWorkspaceMap { } let record = self.evacuations.remove(&output); - let mut reclaimed = Vec::new(); - if let Some(record) = &record { - for evacuated in &record.workspaces { - let target = self - .assignments - .get(&evacuated.workspace) - .copied() - .or_else(|| evacuated.target.filter(|target| self.outputs.contains_key(target))); - let assignment_matches = self.homes.get(&evacuated.workspace) == Some(&output); - let revision_matches = self.revision(evacuated.workspace) == evacuated.revision; - - if assignment_matches && revision_matches { - // Cleanup may have removed every other workspace while this - // monitor was disconnected. Never strand the donor output; - // the reconnecting monitor can use its fresh fallback. - if target - .and_then(|target| self.outputs.get(&target)) - .is_some_and(|state| { - state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace) - }) - { - continue; - } - - if let Some(target) = target - && let Some(target_state) = self.outputs.get_mut(&target) - { - target_state.workspaces.remove(&evacuated.workspace); - if target_state.previous == Some(evacuated.workspace) { - target_state.previous = None; - } - if target_state.active == evacuated.workspace - && let Some(active) = target_state - .workspaces - .iter() - .copied() - .min_by_key(|workspace| workspace.0) - { - target_state.activate(active); - } - } - self.assignments.insert(evacuated.workspace, output); - reclaimed.push(evacuated.workspace); - } - } - } + let mut reclaimed = record + .as_ref() + .map(|record| self.reclaim(output, record)) + .unwrap_or_default(); let active = record .as_ref() @@ -286,6 +399,12 @@ impl OutputWorkspaceMap { pub fn disconnect(&mut self, output: OutputId) -> Result, OutputError> { let removed = self.outputs.remove(&output).ok_or(OutputError::UnknownOutput(output))?; + let target = self.evacuate(output, removed); + debug_assert!(self.validate()); + Ok(target) + } + + fn evacuate(&mut self, output: OutputId, removed: OutputState) -> Option { let target = self.migration_target(output, removed.geometry); let removed_had_focus = self.focused == Some(output); let mut evacuated = removed.workspaces.into_iter().collect::>(); @@ -332,8 +451,7 @@ impl OutputWorkspaceMap { }, ); - debug_assert!(self.validate()); - Ok(target) + target } pub fn focus_output(&mut self, output: OutputId) -> Result<(), OutputError> { @@ -445,6 +563,9 @@ impl OutputWorkspaceMap { } if donor.active == workspace { donor.active = *donor.workspaces.first().expect("donor retains a workspace"); + if donor.previous == Some(donor.active) { + donor.previous = None; + } } } self.assignments.insert(workspace, output); @@ -516,6 +637,199 @@ impl OutputWorkspaceMap { #[cfg(test)] mod tests { + fn plan(map: &OutputWorkspaceMap, ids: &[u64]) -> DesktopPlan { + map.plan_desktop( + &ids.iter() + .map(|id| (OutputId(*id), geometry(*id as i32 * 1920))) + .collect::>(), + &(1..=32).map(WorkspaceId).collect::>(), + WorkspaceId(33), + ) + .unwrap() + } + + #[test] + fn staged_add_remove_and_noop_leave_the_source_unchanged() { + let original = OutputWorkspaceMap::default(); + let first = plan(&original, &[1]); + assert_eq!(original, OutputWorkspaceMap::default()); + let second = plan(&first.outputs, &[1, 2]); + assert_eq!(first.outputs.connected_outputs().count(), 1); + assert_eq!(second.outputs.connected_outputs().count(), 2); + let unchanged = plan(&second.outputs, &[2, 1]); + assert_eq!(unchanged.outputs, second.outputs); + assert!(unchanged.affected_outputs.is_empty()); + assert!(!unchanged.focus_changed); + let removed = plan(&second.outputs, &[1]); + assert_eq!(removed.outputs.focused_output(), Some(OutputId(1))); + assert_eq!(removed.outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1))); + assert_eq!(removed.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1))); + } + + #[test] + fn simultaneous_removals_migrate_directly_to_final_outputs_and_preserve_focus() { + let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs; + before.focus_output(OutputId(2)).unwrap(); + let after = plan(&before, &[4]); + assert_eq!(after.outputs.focused_output(), Some(OutputId(4))); + assert_eq!(after.outputs.active_workspace(OutputId(4)), Some(WorkspaceId(2))); + for workspace in 1..=3 { + assert_eq!( + after.outputs.output_for_workspace(WorkspaceId(workspace)), + Some(OutputId(4)) + ); + } + for evacuation in after.outputs.evacuations.values() { + assert!( + evacuation + .workspaces + .iter() + .all(|workspace| workspace.target == Some(OutputId(4))) + ); + } + assert_eq!(before.connected_outputs().count(), 3); + } + + #[test] + fn staged_reconnect_respects_explicit_reassignment() { + let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs; + let mut evacuated = plan(&before, &[1]).outputs; + let restored = plan(&evacuated, &[1, 2]); + assert_eq!(restored.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2))); + evacuated + .reassign_workspace(OutputId(1), WorkspaceId(2), WorkspaceId(9)) + .unwrap(); + let reassigned = plan(&evacuated, &[1, 2]); + assert_eq!( + reassigned.outputs.output_for_workspace(WorkspaceId(2)), + Some(OutputId(1)) + ); + } + + #[test] + fn returning_home_reclaims_from_a_donor_retired_in_the_same_plan() { + let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs; + before.focus_output(OutputId(2)).unwrap(); + before.disconnect(OutputId(1)).unwrap(); + before.switch_workspace(OutputId(2), WorkspaceId(1)).unwrap(); + assert!(before.forget_workspace(WorkspaceId(2))); + before.focus_output(OutputId(3)).unwrap(); + let after = plan(&before, &[1, 3]).outputs; + assert_eq!(after.output_for_workspace(WorkspaceId(1)), Some(OutputId(1))); + assert_eq!(after.active_workspace(OutputId(1)), Some(WorkspaceId(1))); + assert_eq!(after.focused_output(), Some(OutputId(3))); + } + + #[test] + fn invalid_inventory_cannot_mutate_the_published_map() { + let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs; + let saved = before.clone(); + assert!( + before + .plan_desktop( + &[(OutputId(1), geometry(0)), (OutputId(1), geometry(1))], + &[], + WorkspaceId(3) + ) + .is_err() + ); + assert!( + before + .plan_desktop(&[(OutputId(3), OutputGeometry::new(0, 0, 0, 800))], &[], WorkspaceId(3)) + .is_err() + ); + assert_eq!(before, saved); + } + + #[test] + fn physical_loss_can_publish_headless_and_recover_all_workspaces() { + let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs; + before.focus_output(OutputId(2)).unwrap(); + let headless = plan(&before, &[]).outputs; + assert_eq!(headless.focused_output(), None); + assert!(headless.assignments.is_empty()); + let recovered = plan(&headless, &[3]).outputs; + assert_eq!(recovered.active_workspace(OutputId(3)), Some(WorkspaceId(2))); + for id in [1, 2] { + assert_eq!(recovered.output_for_workspace(WorkspaceId(id)), Some(OutputId(3))); + } + } + + #[test] + fn deterministic_topology_sequence_is_coherent_and_idempotent() { + let mut map = OutputWorkspaceMap::default(); + let mut seed = 0x5eed_u64; + for step in 0..512 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let ids = (1..=4).filter(|id| seed & (1 << id) != 0).collect::>(); + let next = plan(&map, &ids); + assert!(next.outputs.validate(), "step {step}"); + assert_eq!(plan(&next.outputs, &ids).outputs, next.outputs); + map = next.outputs; + if let Some(&id) = ids.first() { + map.focus_output(OutputId(id)).unwrap(); + let workspace = WorkspaceId(16 + step % 8); + map.switch_workspace(OutputId(id), workspace).unwrap(); + if step % 3 == 0 { + map.reassign_workspace(OutputId(id), workspace, WorkspaceId(31)) + .unwrap(); + } + } + assert!(map.validate()); + } + } + + #[test] + fn deterministic_desktop_sequence_preserves_every_window() { + use crate::WorkspaceSet; + use ferese_layout::{Axis, WindowId}; + let mut workspaces = WorkspaceSet::default(); + let mut outputs = OutputWorkspaceMap::default(); + let mut seed = 42u64; + let mut windows = std::collections::HashSet::new(); + for step in 1..=256 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let inventory = (1..=3) + .filter(|id| seed & (1 << id) != 0) + .map(|id| (OutputId(id), geometry(id as i32 * 800))) + .collect::>(); + let ids = workspaces.iter().map(|workspace| workspace.id).collect::>(); + let plan = outputs + .plan_desktop(&inventory, &ids, workspaces.next_workspace_id()) + .unwrap(); + for id in plan.created_workspaces { + assert_eq!(workspaces.create_workspace(), id); + } + outputs = plan.outputs; + outputs.reconcile_workspaces(&mut workspaces, &Default::default()); + let window = WindowId(step); + workspaces.insert_window(window, Axis::Horizontal, 0.5).unwrap(); + windows.insert(window); + if let Some((output, _)) = inventory.last() { + outputs.focus_output(*output).unwrap(); + let target = outputs.active_workspace(*output).unwrap(); + workspaces + .move_window_to_workspace(window, target, Axis::Horizontal, 0.5) + .unwrap(); + workspaces.activate(target).unwrap(); + } + assert!(outputs.validate()); + workspaces.validate().unwrap(); + let actual = workspaces + .iter() + .flat_map(|workspace| workspace.layout.window_ids().chain(workspace.floating.iter().copied())) + .collect::>(); + assert_eq!(actual.len(), windows.len(), "duplicate or lost window at {step}"); + assert_eq!(actual.into_iter().collect::>(), windows); + for workspace in workspaces.iter().filter(|workspace| !workspace.is_empty()) { + assert_eq!( + outputs.output_for_workspace(workspace.id).is_some(), + !inventory.is_empty() + ); + } + } + } + #[test] fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() { let mut outputs = OutputWorkspaceMap::default(); diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index cd4a6f8..6359460 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -672,6 +672,11 @@ impl WorkspaceSet { .ok_or(WorkspaceError::UnknownWorkspace(id)) } + /// Reserve workspace identities in a pure topology plan without creating them. + pub fn next_workspace_id(&self) -> WorkspaceId { + WorkspaceId(self.next_id) + } + /// Creates an identity; display positions are assigned by the output owner. pub fn create_workspace(&mut self) -> WorkspaceId { let id = WorkspaceId(self.next_id); diff --git a/docs/desktop-reconciliation.md b/docs/desktop-reconciliation.md new file mode 100644 index 0000000..78ffa72 --- /dev/null +++ b/docs/desktop-reconciliation.md @@ -0,0 +1,153 @@ +# Desktop reconciliation and presentation + +During an output topology change, Ferese publishes one logical desktop after +the usable hardware inventory is known. +The boundary is synchronous within one event-loop dispatch; +it does not wait for client commits, animation completion or page flips. + +## Previous flow + +`direct/topology.rs::reconcile_outputs` already centralized hotplug, profile +changes, retry and resume. It debounced connector events, validated device-wide +KMS requests, applied devices independently and attempted compensation on failure. +Its `reconciling` guard suppressed relayout, shell broadcasts and frame scheduling. + +Inside that guard, `apply_device` still changed live Wayland output metadata, +registered each output and updated output/workspace geometry. Registration could +restore evacuated workspaces and reposition floating windows. Retirement called +`unregister_output`, which combined render/capture/lock cleanup with workspace +migration, focus changes, floating movement and relayout. Thus later devices or +rollback operated on desktop policy already changed by earlier devices. + +The end of reconciliation restored focus, performed global relayout (including +client configures, surface preferences, a shell snapshot and redraw), then made +another global redraw request. Profile configuration reload could relayout again. + +## Current phases + +1. **Observe and choose desired hardware.** Reconciliation uses the existing + monitor identities, profile selection, position policy, hotplug coalescer and + lid/resume handling. +2. **Validate and apply hardware.** The backend uses device-wide atomic TEST_ONLY + validation, Smithay resource staging/commits, per-device rollback, cross-device + compensation and last-output recovery. + These operations update resource metadata, not live + workspace ownership or published output geometry. +3. **Plan the publishable desktop.** After all hardware outcomes are known, + `publish_runtime_desktop` reports usable logical outputs. Physical mirror + scanouts do not acquire workspaces. `OutputWorkspaceMap::plan_desktop` stages a + small copy of ownership, geometry, focus and evacuation history. It uses the + existing connect/reclaim/evacuate policy and reserves any needed workspace IDs. + It rejects duplicate IDs and invalid geometry without changing the source map. +4. **Publish logical state.** `Ferese::publish_desktop` computes floating moves + from the old geometry directly to the final geometry, then installs output + registries, Wayland output metadata, ownership, floating destinations and logical + focus. Slides that reference evacuated workspaces are retired. Unaffected + viewport, world-coordinate, window-animation and resize state remain intact. +5. **Configure and finalize.** `finish_desktop_transition` performs one relayout + when needed, restores keyboard/lock focus, refreshes lock/idle policy, releases + the shell publication guard and requests redraws for affected outputs. + Existing layout + remains responsible for tiled destinations, layer work areas, surface-tree + scale/transform preferences, XDG configures and animation target ownership. +6. **Present asynchronously.** The existing resize barriers, native-content + snapshots, animations, frame scheduling and `DisplayPresentation` handle + client readiness, motion, frame submission and displayed-content tracking. + Logical publication does not assert that new pixels were displayed. + +The desired hardware plan precedes application; the ownership plan is calculated +from the result of application and recovery. This avoids computing +and then undoing migrations to requested outputs that never became usable. + +## Types and scope + +- `DesktopPlan` is pure data: the staged `OutputWorkspaceMap`, reserved workspace + IDs, affected output IDs and focus dirtiness. It copies neither window layouts + nor client buffers nor the whole compositor. Window layout is not duplicated. +- `DesktopOutput` adapts actual backend resources to that planner. Its Wayland + handles, mode, scale and transform stay in the compositor, outside the pure map. +- `DesktopTransition` holds short-lived resource retirement/redraw bookkeeping and + guards shell publication while the synchronous transition is in progress. +- `DesktopChanges` distinguishes layout, focus, shell and redraw consequences for + the finalizer. Resume can request fresh frames without forcing a topology change. + +Registration/removal outside DRM and nested output resizing use the same logical +publication path. Profile reload carries accompanying scene dirtiness into its +finalization rather than immediately relayouting the desktop a second time. +Pointer movement, client damage, ordinary focus, title changes, cursor updates and +frame callbacks do not construct or run a desktop plan. + +## Failure, retirement and security + +A rejected hardware configuration leaves the desktop unchanged if the previous +hardware remains usable. After physical loss or failed restoration, +unusable scanouts are retired immediately, and the final plan excludes them. +There is no atomicity guarantee across independent DRM devices. + +Low-level retirement still disables globals, cancels frame timers and removes DRM +presentation clocks. `retire_output_resources` clears records of displayed client +content, output render caches, pending screencopies and lock resources. +It does **not** +migrate workspaces, change logical focus or relayout. Repeated retirement within +the same boundary is harmless. Publication removes the output from Space and the +logical registries once the final inventory is available. + +If every physical output disappears, a headless desktop is valid: windows and +workspace restoration history survive, while output ownership and keyboard focus +are cleared. A requested all-disabled configuration still follows the existing +validation/recovery policy. A powered-off output is not the same as a retired +output; runtime output power remains independent of topology. + +Session-lock protection remains mandatory for new/reconfigured scanouts. Hardware +resets invalidate records of previously displayed content and lock frames. +The boundary does not +allow a saved desktop frame to substitute for a protected lock frame. + +## Readiness, motion and shell visibility + +`ResizeTransaction` remains the only configure readiness barrier. Its existing +300 ms deadline and workspace coupling are unchanged: neighbors sharing the same +viewport may pause together, but an unrelated workspace/output continues advancing. +Existing committed client size/content may lag a newly published logical size. +Reconciliation does not acknowledge client configures, advance springs itself, +reset unrelated velocity or introduce another snapshot/animation system. + +Shell broadcasts and initial subscription snapshots are deferred while the +boundary is open. The finalizer releases that guard only after output ownership, +focus, workspace cleanup and logical window layout agree. The existing shell +snapshot protocol and deduplication remain in use. A snapshot describes the +committed logical state; it does not mean every output has displayed that state. + +`DisplayPresentation` remains a record of queued/displayed client visibility, +not a topology transaction or a frame buffer store. Existing native resize +snapshots survive output-cache retirement; GPU context loss still invalidates +textures owned by that context. No new cross-GPU snapshot transport is introduced. +Hardware preparation still uses a black modeset frame where required by the +existing backend. This change prevents unintended logical intermediate desktops; +it does not guarantee uninterrupted physical scanout through a modeset or +device loss. + +## Regression coverage + +Pure planner tests cover addition/removal, focused and non-focused removal, +replacement of multiple outputs, reconnect/explicit reassignment, a returning +output whose donor retires in the same plan, headless recovery, failed planning, +no-op/idempotent reconciliation and deterministic 512-step ownership sequences. +A separate 256-step sequence mutates topology, focus, workspace/window membership +and validates ownership and preservation of every window after each step. + +The fake hardware transaction test applies commit and rollback failures, then +plans from survivors: successful rollback produces no logical change; failed +rollback produces a coherent reduced desktop without the requested new output. + +The private Wayland integration regression uses actual shell, XDG toplevel and +session-lock resources. It checks no intermediate shell generation, one complete +final snapshot, repeated retirement, floating clamping, focused-window migration, +removal during a workspace slide, pending XDG resize after publication, another +workspace's progress during the wait, deadline expiry, no-op inventories and +locked headless loss/recovery. Fractional-scale/rotation tests compare planner +geometry directly with Smithay's Space geometry. + +Physical KMS commits, cross-GPU recovery and resume still require the native +checks in [output-management-testing.md](output-management-testing.md). Pure and +private-protocol tests do not validate a DRM driver's behavior. diff --git a/docs/development.md b/docs/development.md index 669c016..51b74d2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,6 +14,8 @@ Keep another desktop available for direct-session recovery. ## Test guides +- [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership. + - [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits. - [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.