Publish output topology through a desktop reconciliation boundary

This commit is contained in:
Samuel Onoja committed 2026-10-02 23:46:08 +01:00
1 parent 647eaf4ad9
commit f5de30d037
16 files changed
+1351 -298

No files matched your search

+18 -2
View File
@@ -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) { if let Some(next) = super::power::policy_change(backend.active, backend.low_power, battery) {
backend.low_power = next; 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); 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>) { 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 if state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -1448,6 +1459,11 @@ fn refresh_connected_info(state: &mut Ferese) {
fn remember_applied_configuration(state: &mut Ferese) { fn remember_applied_configuration(state: &mut Ferese) {
let backend = state.direct_backend.as_mut().unwrap(); 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(); let mut applied = backend.desired_outputs.clone();
for desired in &mut applied.outputs { for desired in &mut applied.outputs {
let monitor = backend.monitors.iter().find(|monitor| monitor.key == desired.key); let monitor = backend.monitors.iter().find(|monitor| monitor.key == desired.key);
+90 -6
View File
@@ -77,6 +77,8 @@ pub(super) struct Topology {
pub invalidated: HashSet<DrmNode>, pub invalidated: HashSet<DrmNode>,
pub reconciling: bool, pub reconciling: bool,
pub dirty: bool, pub dirty: bool,
pub layout_dirty: bool,
pub remember_applied: bool,
pub retry_timer: Option<RegistrationToken>, pub retry_timer: Option<RegistrationToken>,
pub settle_timer: Option<RegistrationToken>, pub settle_timer: Option<RegistrationToken>,
pub coalescer: crate::output_policy::Debouncer, pub coalescer: crate::output_policy::Debouncer,
@@ -165,6 +167,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
} }
paths.sort(); paths.sort();
let mut ready = Vec::new(); let mut ready = Vec::new();
state.begin_desktop_transition();
for node in invalidated { for node in invalidated {
remove_device(state, node); remove_device(state, node);
@@ -227,7 +230,14 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
output.frame_pending = false; output.frame_pending = false;
output.lock_frame_pending = false; output.lock_frame_pending = false;
output.power.suspended(); output.power.suspended();
state.session_lock.output_added(&output.output);
state.display_presentation.remove_output(&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()) { if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) {
tracing::warn!(%error, ?node, ?crtc, "output reset failed; recreating output"); 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); 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(); let backend = state.direct_backend.as_mut().unwrap();
backend.topology.reconciling = false; backend.topology.reconciling = false;
let retry = backend.topology.retry_deadline(backend.active); 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 { if let Some(deadline) = retry {
match state match state
.loop_handle .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); sync_battery_timer(state);
state.restore_output_focus(); state.finish_desktop_transition(desktop_changes);
state.relayout();
state.notify_monitor_state(); state.notify_monitor_state();
render_all(state);
lid::apply_suspend_policy(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::<Vec<_>>();
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::<Ferese>(&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); const SETTLE: Duration = Duration::from_millis(150);
/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer. /// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer.
+23 -65
View File
@@ -365,6 +365,13 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
}) })
.and_then(|()| output.surface.commit_frame().map_err(|error| error.to_string())); .and_then(|()| output.surface.commit_frame().map_err(|error| error.to_string()));
self.state.session_lock.output_added(&output.output); 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.lock_frame_pending = false;
output.surface.set_output_mode_source((&output.output).into()); output.surface.set_output_mode_source((&output.output).into());
cancel_output_timers(&self.state.loop_handle, output); 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 /// All allocations/tests precede commits. Logical publication waits for the
/// successful commit on this device; other devices are separately rollbackable. /// final usable inventory; devices are independently applied and restored.
pub(super) fn apply_device( pub(super) fn apply_device(
state: &mut Ferese, state: &mut Ferese,
node: DrmNode, node: DrmNode,
@@ -436,8 +443,6 @@ pub(super) fn apply_device(
for crtc in &changed { for crtc in &changed {
let selection = &selections[crtc]; let selection = &selections[crtc];
let mut output = staged.remove(crtc).or_else(|| device.outputs.remove(crtc)).unwrap(); 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 { if output.connector != selection.connector.handle() || output.identity != selection.identity {
retire(state, &mut output, node, *crtc); retire(state, &mut output, node, *crtc);
let (handle, _) = create_output( let (handle, _) = create_output(
@@ -459,65 +464,18 @@ pub(super) fn apply_device(
output.settings = selection.settings.clone(); output.settings = selection.settings.clone();
output.mode = selection.mode; output.mode = selection.mode;
output.mirror_source = selection.mirror_source.clone(); output.mirror_source = selection.mirror_source.clone();
let position = selection.settings.position.unwrap_or_else(|| { // Resource metadata describes applied hardware. Wayland output state,
old_geometry // workspace ownership and floating positions publish once after all GPUs.
.map(|geometry| [geometry.loc.x, geometry.loc.y]) state
.unwrap_or_else(|| { .desktop_transition
[ .as_mut()
state .expect("output reconciliation boundary")
.space .redraw
.outputs() .insert(output.output.clone());
.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::<Ferese>(&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,
),
);
}
}
}
state.session_lock.output_added(&output.output); 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.lock_frame_pending = false;
output.surface.set_output_mode_source((&output.output).into()); output.surface.set_output_mode_source((&output.output).into());
cancel_output_timers(&state.loop_handle, &mut output); cancel_output_timers(&state.loop_handle, &mut output);
@@ -536,7 +494,7 @@ pub(super) fn apply_device(
clock.reset_timing(); clock.reset_timing();
device.outputs.insert(*crtc, output); 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 { for crtc in removals {
let mut output = device.outputs.remove(&crtc).unwrap(); let mut output = device.outputs.remove(&crtc).unwrap();
retire(state, &mut output, node, crtc); retire(state, &mut output, node, crtc);
@@ -605,7 +563,7 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
.unwrap() .unwrap()
.devices .devices
.values() .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; return;
} }
@@ -654,7 +612,7 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
.unwrap() .unwrap()
.devices .devices
.values() .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); remember_applied_configuration(state);
state.direct_backend.as_mut().unwrap().output_error = original_error; state.direct_backend.as_mut().unwrap().output_error = original_error;
+47
View File
@@ -146,4 +146,51 @@ mod tests {
assert_eq!(failure.unavailable, vec![1]); assert_eq!(failure.unavailable, vec![1]);
assert!(!fake.active.contains(&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<u8>| {
active
.iter()
.map(|id| (OutputId(*id as u64), OutputGeometry::new(*id as i32 * 800, 0, 800, 600)))
.collect::<Vec<_>>()
};
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<_>>(),
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<_>>(), 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);
}
}
} }
+9
View File
@@ -291,6 +291,10 @@ impl Ferese {
} }
pub(crate) fn send_shell_snapshots(&mut self) { pub(crate) fn send_shell_snapshots(&mut self) {
if let Some(transition) = self.desktop_transition.as_mut() {
transition.shell_pending = true;
return;
}
if self if self
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -390,6 +394,11 @@ impl Ferese {
} }
fn send_shell_snapshot(&mut self, shell: &FereseShellV1) { 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 // New subscribers always receive a complete snapshot. Do not update the
// broadcast cache here: existing subscribers may still need this state. // broadcast cache here: existing subscribers may still need this state.
self.shell_snapshot_serial = self.shell_snapshot_serial.wrapping_add(1); self.shell_snapshot_serial = self.shell_snapshot_serial.wrapping_add(1);
+10 -7
View File
@@ -5,6 +5,8 @@ mod hit_testing;
mod layout; mod layout;
mod navigation; mod navigation;
mod outputs; mod outputs;
mod reconciliation;
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
mod prediction; mod prediction;
pub(crate) use prediction::FrameScene; pub(crate) use prediction::FrameScene;
mod window_registry; mod window_registry;
@@ -330,6 +332,7 @@ pub struct Ferese {
pub space: Space<Window>, pub space: Space<Window>,
pub workspaces: WorkspaceSet, pub workspaces: WorkspaceSet,
pub output_workspaces: OutputWorkspaceMap, pub output_workspaces: OutputWorkspaceMap,
pub(crate) desktop_transition: Option<DesktopTransition>,
pub(crate) output_ids: HashMap<Output, OutputId>, pub(crate) output_ids: HashMap<Output, OutputId>,
outputs_by_id: HashMap<OutputId, Output>, outputs_by_id: HashMap<OutputId, Output>,
pub(crate) output_names: HashMap<OutputId, String>, pub(crate) output_names: HashMap<OutputId, String>,
@@ -562,6 +565,7 @@ impl Ferese {
config.scrolling_focus_strategy, config.scrolling_focus_strategy,
), ),
output_workspaces: OutputWorkspaceMap::default(), output_workspaces: OutputWorkspaceMap::default(),
desktop_transition: None,
output_ids: HashMap::new(), output_ids: HashMap::new(),
outputs_by_id: HashMap::new(), outputs_by_id: HashMap::new(),
output_names: HashMap::new(), output_names: HashMap::new(),
@@ -688,7 +692,8 @@ impl Ferese {
let input_changed = changed(&["input"]); let input_changed = changed(&["input"]);
let daemons_changed = changed(&["autostart"]); let daemons_changed = changed(&["autostart"]);
let lock_changed = changed(&["lock_screen"]); 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 { if self.output_profiles != config.output_profiles {
crate::backends::direct::validate_live_outputs(self, &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); crate::backends::direct::reload_input_devices(self);
} }
if outputs_changed {
crate::backends::direct::reload_outputs(self);
}
if old_rules != self.window_rules { if old_rules != self.window_rules {
self.reapply_window_rules(&old_rules); self.reapply_window_rules(&old_rules);
} }
@@ -802,9 +803,11 @@ impl Ferese {
self.wallpaper.reload(config.wallpaper); 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(); self.relayout();
crate::backends::direct::render_all(self);
} }
Ok(()) Ok(())
+1 -1
View File
@@ -128,7 +128,7 @@ impl Ferese {
} }
// The production caller supplies monotonic wall time; tests supply a fake clock. // 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() { if !self.animations_need_tick() {
self.sync_window_stacking(); self.sync_window_stacking();
return false; return false;
+17 -3
View File
@@ -52,7 +52,7 @@ impl Ferese {
} }
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.relayout_outputs(None); self.relayout_outputs(None, true);
} }
pub(crate) fn relayout_window(&mut self, id: WindowId) { pub(crate) fn relayout_window(&mut self, id: WindowId) {
@@ -66,10 +66,21 @@ impl Ferese {
} }
pub(crate) fn relayout_on(&mut self, outputs: &[Output]) { 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<Vec<Output>>) { pub(super) fn relayout_desktop(&mut self) {
self.relayout_outputs(None, false);
}
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>, finalize: bool) {
if self
.desktop_transition
.as_ref()
.is_some_and(|transition| !transition.publishing)
{
return;
}
if self if self
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -416,6 +427,9 @@ impl Ferese {
self.sync_window_stacking(); self.sync_window_stacking();
self.retarget_overview(); self.retarget_overview();
if !finalize {
return;
}
self.send_shell_snapshots(); self.send_shell_snapshots();
if let Some(outputs) = redraw_outputs { if let Some(outputs) = redraw_outputs {
+64 -153
View File
@@ -26,104 +26,82 @@ impl Ferese {
} }
pub fn register_output(&mut self, output: &Output, identity: String) { pub fn register_output(&mut self, output: &Output, identity: String) {
if self.output_ids.is_empty() { assert!(
self.reset_animation_clock(); self.desktop_transition.is_none(),
} "backend must publish its complete inventory"
);
if self.session_lock.active() {
self.session_lock.output_added(output);
self.lock_input_activity();
}
let Some(geometry) = self.space.output_geometry(output) else { let Some(geometry) = self.space.output_geometry(output) else {
tracing::error!(output = %output.name(), "cannot register an unmapped output"); tracing::error!(output = %output.name(), "cannot register an unmapped output");
return; return;
}; };
let output_id = self.ensure_output_identity(&identity); let mut runtime = self.current_desktop_outputs();
let fallback_workspace = self runtime.retain(|entry| entry.output != *output);
.workspaces 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<DesktopOutput> {
self.output_ids
.iter() .iter()
.filter(|workspace| { .filter_map(|(output, id)| {
self.output_workspaces.output_for_workspace(workspace.id).is_none() Some(DesktopOutput {
&& self output: output.clone(),
.output_workspaces identity: self
.home_output(workspace.id) .output_identity_ids
.is_none_or(|home| home == output_id) .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) .collect()
.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);
let returning_floats = self /// Resource invalidation is immediate; ownership and focus wait for the
.windows /// complete usable inventory, including when the final output disappears.
.ids() pub fn unregister_output(&mut self, output: &Output) {
.values() let standalone = self.desktop_transition.is_none();
.filter_map(|id| { if standalone {
let workspace = self.workspaces.workspace_for_window(*id)?; self.begin_desktop_transition();
if self.output_workspaces.home_output(workspace) != Some(output_id) { }
return None; self.retire_output_resources(output);
} if standalone {
let owner = self.output_workspaces.output_for_workspace(workspace)?; let runtime = self
let old = self.output_workspaces.geometry(owner)?; .current_desktop_outputs()
let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else { .into_iter()
return None; .filter(|entry| entry.output != *output)
}; .collect();
Some((*id, workspace, rect, old)) let changes = self.publish_desktop(runtime).expect("valid surviving output inventory");
}) self.finish_desktop_transition(changes);
.collect::<Vec<_>>();
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");
}
} }
} }
pub fn unregister_output(&mut self, output: &Output) { pub(super) fn retire_output_resources(&mut self, output: &Output) {
self.display_presentation.remove_output(output); if let Some(transition) = self.desktop_transition.as_mut()
self.refresh_idle_inhibition(); && !transition.retired.insert(output.clone())
{
let Some(output_id) = self.output_ids.remove(output) else {
return; return;
}; }
self.display_presentation.remove_output(output);
self.output_redraw_pending.retain(|pending| pending != output); self.output_redraw_pending.retain(|pending| pending != output);
self.outputs_by_id.remove(&output_id); if let Some(id) = self.output_ids.get(output) {
self.output_names.remove(&output_id); self.render.remove_output(*id);
self.workspace_slides.remove(&output_id); }
self.render.remove_output(output_id);
self.pending_screencopies.retain(|capture| { self.pending_screencopies.retain(|capture| {
if capture.output == *output { if capture.output == *output {
capture.fail(); capture.fail();
@@ -132,76 +110,9 @@ impl Ferese {
true true
} }
}); });
self.space.unmap_output(output);
self.session_lock.surfaces.remove(output); self.session_lock.surfaces.remove(output);
self.session_lock.backgrounds.remove(output); self.session_lock.backgrounds.remove(output);
self.session_lock.output_removed(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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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) { pub(super) fn activate_output_workspace(&mut self, output: OutputId, workspace: ferese_core::WorkspaceId) {
+538
View File
@@ -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<Output>,
pub redraw: HashSet<Output>,
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<i32, Logical>,
}
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<Output>,
}
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<DesktopOutput>,
) -> Result<DesktopChanges, ferese_core::OutputError> {
assert!(self.desktop_transition.is_some());
let inventory = runtime
.iter()
.map(|output| (self.ensure_output_identity(&output.identity), output.geometry()))
.collect::<Vec<_>>();
let workspaces = self.workspaces.iter().map(|workspace| workspace.id).collect::<Vec<_>>();
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::<Vec<_>>();
// 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::<Vec<_>>();
let old_outputs = self.output_ids.keys().cloned().collect::<Vec<_>>();
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::<Vec<_>>());
}
}
}
#[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::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let shell = client
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&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<u32>)> {
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::<Window>::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::<FereseShellV1, (), Ferese>(&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::<Vec<_>>();
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::<ExtSessionLockManagerV1, (), Ferese>(&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::<Vec<_>>() {
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());
}
}
+7 -8
View File
@@ -103,14 +103,13 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.unwrap_or(60_000) as i32; .unwrap_or(60_000) as i32;
refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64)); refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64));
output.change_current_state( let mut runtime = state.current_desktop_outputs();
Some(Mode { size, refresh: rate }), let resized = runtime.iter_mut().find(|entry| entry.output == output).unwrap();
None, resized.mode = Mode { size, refresh: rate };
Some(Scale::Fractional(scale)), resized.scale = Scale::Fractional(scale);
None, state.begin_desktop_transition();
); let changes = state.publish_desktop(runtime).expect("valid nested output geometry");
state.finish_desktop_transition(changes);
state.relayout();
if let Err(error) = state.display_handle.flush_clients() { if let Err(error) = state.display_handle.flush_clients() {
tracing::debug!(%error, "failed to flush output-resize configure"); tracing::debug!(%error, "failed to flush output-resize configure");
} }
+1 -1
View File
@@ -6,7 +6,7 @@ mod workspace;
mod workspace_policy; mod workspace_policy;
pub use focus::{FocusCycle, FocusHistory}; 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::{ pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet, LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
}; };
+366 -52
View File
@@ -32,6 +32,7 @@ impl OutputGeometry {
pub enum OutputError { pub enum OutputError {
AlreadyConnected(OutputId), AlreadyConnected(OutputId),
UnknownOutput(OutputId), UnknownOutput(OutputId),
InvalidGeometry(OutputId),
WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId }, WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId },
} }
@@ -40,6 +41,7 @@ impl fmt::Display for OutputError {
match self { match self {
Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"), Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"),
Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"), Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"),
Self::InvalidGeometry(output) => write!(formatter, "invalid geometry for output {output:?}"),
Self::WorkspaceAlreadyAssigned { workspace, output } => { Self::WorkspaceAlreadyAssigned { workspace, output } => {
write!(formatter, "workspace {workspace:?} is assigned to {output:?}") write!(formatter, "workspace {workspace:?} is assigned to {output:?}")
} }
@@ -55,7 +57,7 @@ pub enum WorkspaceSwitch {
FocusedExisting(OutputId), FocusedExisting(OutputId),
} }
#[derive(Debug)] #[derive(Clone, Debug, PartialEq, Eq)]
struct OutputState { struct OutputState {
geometry: OutputGeometry, geometry: OutputGeometry,
active: WorkspaceId, active: WorkspaceId,
@@ -72,20 +74,20 @@ impl OutputState {
} }
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EvacuatedWorkspace { struct EvacuatedWorkspace {
workspace: WorkspaceId, workspace: WorkspaceId,
target: Option<OutputId>, target: Option<OutputId>,
revision: u64, revision: u64,
} }
#[derive(Debug)] #[derive(Clone, Debug, PartialEq, Eq)]
struct EvacuationRecord { struct EvacuationRecord {
active: WorkspaceId, active: WorkspaceId,
workspaces: Vec<EvacuatedWorkspace>, workspaces: Vec<EvacuatedWorkspace>,
} }
#[derive(Debug, Default)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OutputWorkspaceMap { pub struct OutputWorkspaceMap {
outputs: BTreeMap<OutputId, OutputState>, outputs: BTreeMap<OutputId, OutputState>,
assignments: HashMap<WorkspaceId, OutputId>, assignments: HashMap<WorkspaceId, OutputId>,
@@ -96,7 +98,110 @@ pub struct OutputWorkspaceMap {
orphaned_focus: Option<WorkspaceId>, orphaned_focus: Option<WorkspaceId>,
} }
/// A staged ownership decision. No windows, renderers or protocol resources are copied.
#[derive(Debug)]
pub struct DesktopPlan {
pub outputs: OutputWorkspaceMap,
pub created_workspaces: Vec<WorkspaceId>,
pub affected_outputs: BTreeSet<OutputId>,
pub focus_changed: bool,
}
impl OutputWorkspaceMap { 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<DesktopPlan, OutputError> {
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::<Vec<_>>();
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::<Vec<_>>();
// 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<Item = WorkspaceId> + '_ { pub fn assigned_workspaces(&self, output: OutputId) -> impl DoubleEndedIterator<Item = WorkspaceId> + '_ {
self.outputs self.outputs
.get(&output) .get(&output)
@@ -170,6 +275,56 @@ impl OutputWorkspaceMap {
} }
} }
fn reclaim(&mut self, output: OutputId, record: &EvacuationRecord) -> Vec<WorkspaceId> {
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( pub fn connect(
&mut self, &mut self,
output: OutputId, output: OutputId,
@@ -181,52 +336,10 @@ impl OutputWorkspaceMap {
} }
let record = self.evacuations.remove(&output); let record = self.evacuations.remove(&output);
let mut reclaimed = Vec::new(); let mut reclaimed = record
if let Some(record) = &record { .as_ref()
for evacuated in &record.workspaces { .map(|record| self.reclaim(output, record))
let target = self .unwrap_or_default();
.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 active = record let active = record
.as_ref() .as_ref()
@@ -286,6 +399,12 @@ impl OutputWorkspaceMap {
pub fn disconnect(&mut self, output: OutputId) -> Result<Option<OutputId>, OutputError> { pub fn disconnect(&mut self, output: OutputId) -> Result<Option<OutputId>, OutputError> {
let removed = self.outputs.remove(&output).ok_or(OutputError::UnknownOutput(output))?; 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<OutputId> {
let target = self.migration_target(output, removed.geometry); let target = self.migration_target(output, removed.geometry);
let removed_had_focus = self.focused == Some(output); let removed_had_focus = self.focused == Some(output);
let mut evacuated = removed.workspaces.into_iter().collect::<Vec<_>>(); let mut evacuated = removed.workspaces.into_iter().collect::<Vec<_>>();
@@ -332,8 +451,7 @@ impl OutputWorkspaceMap {
}, },
); );
debug_assert!(self.validate()); target
Ok(target)
} }
pub fn focus_output(&mut self, output: OutputId) -> Result<(), OutputError> { pub fn focus_output(&mut self, output: OutputId) -> Result<(), OutputError> {
@@ -445,6 +563,9 @@ impl OutputWorkspaceMap {
} }
if donor.active == workspace { if donor.active == workspace {
donor.active = *donor.workspaces.first().expect("donor retains a 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); self.assignments.insert(workspace, output);
@@ -516,6 +637,199 @@ impl OutputWorkspaceMap {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
fn plan(map: &OutputWorkspaceMap, ids: &[u64]) -> DesktopPlan {
map.plan_desktop(
&ids.iter()
.map(|id| (OutputId(*id), geometry(*id as i32 * 1920)))
.collect::<Vec<_>>(),
&(1..=32).map(WorkspaceId).collect::<Vec<_>>(),
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::<Vec<_>>();
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::<Vec<_>>();
let ids = workspaces.iter().map(|workspace| workspace.id).collect::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(actual.len(), windows.len(), "duplicate or lost window at {step}");
assert_eq!(actual.into_iter().collect::<std::collections::HashSet<_>>(), 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] #[test]
fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() { fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
+5
View File
@@ -672,6 +672,11 @@ impl WorkspaceSet {
.ok_or(WorkspaceError::UnknownWorkspace(id)) .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. /// Creates an identity; display positions are assigned by the output owner.
pub fn create_workspace(&mut self) -> WorkspaceId { pub fn create_workspace(&mut self) -> WorkspaceId {
let id = WorkspaceId(self.next_id); let id = WorkspaceId(self.next_id);
+153
View File
@@ -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.
+2
View File
@@ -14,6 +14,8 @@ Keep another desktop available for direct-session recovery.
## Test guides ## 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. - [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. - [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.