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) {
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);
+90 -6
View File
@@ -77,6 +77,8 @@ pub(super) struct Topology {
pub invalidated: HashSet<DrmNode>,
pub reconciling: bool,
pub dirty: bool,
pub layout_dirty: bool,
pub remember_applied: bool,
pub retry_timer: Option<RegistrationToken>,
pub settle_timer: Option<RegistrationToken>,
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::<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);
/// 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()));
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::<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,
),
);
}
}
}
// 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;
+47
View File
@@ -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<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) {
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);
+10 -7
View File
@@ -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<Window>,
pub workspaces: WorkspaceSet,
pub output_workspaces: OutputWorkspaceMap,
pub(crate) desktop_transition: Option<DesktopTransition>,
pub(crate) output_ids: HashMap<Output, OutputId>,
outputs_by_id: HashMap<OutputId, Output>,
pub(crate) output_names: HashMap<OutputId, String>,
@@ -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(())
+1 -1
View File
@@ -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;
+17 -3
View File
@@ -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<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
.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 {
+64 -153
View File
@@ -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<DesktopOutput> {
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::<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");
}
/// 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::<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) {
+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;
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");
}