Extend output profiles with monitor reconciliation and mirroring

This commit is contained in:
Samuel Onoja committed 2026-10-02 12:08:19 +01:00
1 parent ac184576f6
commit d14cd3cbd2
23 files changed
+3595 -455

No files matched your search

File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -30,7 +30,7 @@ pub(super) fn capture_output(
Ok(()) Ok(())
} }
fn render_capture<'a, E: RenderElement<GlesRenderer>>( pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
texture: &'a mut Option<GlesTexture>, texture: &'a mut Option<GlesTexture>,
output: &Output, output: &Output,
+171 -3
View File
@@ -1,5 +1,5 @@
//! Resume reads are asynchronous; newer switch events win over old replies. //! Resume reads are asynchronous; newer switch events win over old replies.
use std::sync::mpsc; use std::sync::{Arc, Mutex, mpsc};
use super::*; use super::*;
@@ -63,8 +63,51 @@ impl LidState {
} }
} }
struct InhibitorLease<T> {
active: bool,
generation: u64,
descriptor: Option<T>,
suspend: bool,
}
impl<T> Default for InhibitorLease<T> {
fn default() -> Self {
Self {
active: false,
generation: 0,
descriptor: None,
suspend: false,
}
}
}
impl<T> InhibitorLease<T> {
fn activate(&mut self, active: bool) -> u64 {
self.generation = self.generation.wrapping_add(1);
self.active = active;
self.suspend = false;
// Closing here releases logind immediately, independently of worker progress.
self.descriptor = None;
self.generation
}
fn accept(&mut self, generation: u64, descriptor: T) {
if self.active && self.generation == generation {
self.descriptor = Some(descriptor);
}
}
}
enum PolicyRequest {
Activate(u64),
Suspend,
}
pub(super) struct Reader { pub(super) struct Reader {
requests: mpsc::Sender<Probe>, requests: mpsc::Sender<Probe>,
suspend: mpsc::Sender<PolicyRequest>,
lease: Arc<Mutex<InhibitorLease<zbus::zvariant::OwnedFd>>>,
suspend_requested: bool,
pub deadline: Option<RegistrationToken>, pub deadline: Option<RegistrationToken>,
} }
@@ -99,7 +142,94 @@ fn read_acpi() -> Option<bool> {
} }
impl Reader { impl Reader {
pub fn set_active(&mut self, active: bool) {
let generation = self.lease.lock().unwrap().activate(active);
self.suspend_requested = false;
if active {
let _ = self.suspend.send(PolicyRequest::Activate(generation));
}
}
pub fn start(state: &Ferese) -> Result<Self, Box<dyn Error>> { pub fn start(state: &Ferese) -> Result<Self, Box<dyn Error>> {
let (suspend, suspend_requests) = mpsc::channel::<PolicyRequest>();
let lease = Arc::new(Mutex::new(InhibitorLease::default()));
let worker_lease = lease.clone();
std::thread::Builder::new()
.name("ferese-lid-policy".into())
.spawn(move || {
let connection = zbus::blocking::connection::Builder::system()
.and_then(|builder| builder.method_timeout(Duration::from_secs(5)).build());
let Ok(connection) = connection else { return };
while let Ok(request) = suspend_requests.recv() {
if let PolicyRequest::Activate(generation) = request {
let current = worker_lease.lock().unwrap();
if !current.active || current.generation != generation {
continue;
}
drop(current);
let inhibitor = connection
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"Inhibit",
&(
"handle-lid-switch",
"Ferese",
"Reconcile monitors before lid action",
"block",
),
)
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedFd>());
match inhibitor {
Ok(descriptor) => worker_lease.lock().unwrap().accept(generation, descriptor),
Err(error) => tracing::warn!(%error, "could not own logind lid policy"),
}
continue;
}
let current = worker_lease.lock().unwrap();
if !current.active || !current.suspend {
continue;
}
drop(current);
if read_logind(&connection).ok() != Some(true) {
continue;
}
let allowed = connection
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.login1.Manager", "BlockInhibited"),
)
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedValue>())
.and_then(|value| String::try_from(value).map_err(Into::into));
if allowed
.as_ref()
.is_ok_and(|inhibitors| inhibitors.split(':').any(|kind| kind == "sleep"))
{
tracing::debug!("lid suspend prevented by sleep inhibitor");
continue;
}
let current = worker_lease.lock().unwrap();
let requested = current.active && current.suspend;
drop(current);
if !requested {
continue;
}
if let Err(error) = connection.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"Suspend",
&(false,),
) {
tracing::warn!(%error, "logind rejected lid suspend");
}
}
worker_lease.lock().unwrap().activate(false);
})?;
let (requests, receiver) = mpsc::channel::<Probe>(); let (requests, receiver) = mpsc::channel::<Probe>();
let (replies, events) = smithay::reexports::calloop::channel::channel(); let (replies, events) = smithay::reexports::calloop::channel::channel();
state.loop_handle.insert_source(events, |event, _, state| { state.loop_handle.insert_source(events, |event, _, state| {
@@ -126,10 +256,15 @@ impl Reader {
} }
})?; })?;
Ok(Self { let mut reader = Self {
requests, requests,
suspend,
lease,
suspend_requested: false,
deadline: None, deadline: None,
}) };
reader.set_active(true);
Ok(reader)
} }
} }
@@ -152,6 +287,8 @@ pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
cancel_output_timers(&state.loop_handle, output); cancel_output_timers(&state.loop_handle, output);
} }
} }
backend.lid_reader.suspend_requested = false;
backend.lid_reader.lease.lock().unwrap().suspend = false;
let probe = backend.lid.begin(reactivate); let probe = backend.lid.begin(reactivate);
let sent = backend.lid_reader.requests.send(probe).is_ok(); let sent = backend.lid_reader.requests.send(probe).is_ok();
let timer = state let timer = state
@@ -191,6 +328,7 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
// Waking a locked session may request redraws. Hold them until the fresh // Waking a locked session may request redraws. Hold them until the fresh
// device/connector snapshot has been reconciled. // device/connector snapshot has been reconciled.
backend.lid_reader.suspend_requested = false;
backend.topology.reconciling = true; backend.topology.reconciling = true;
state.reset_animation_clock(); state.reset_animation_clock();
state.lock_input_activity(); state.lock_input_activity();
@@ -198,10 +336,40 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
reconcile_outputs(state, reactivate); reconcile_outputs(state, reactivate);
} }
/// Only logind requests suspend. Inactive events wait for fresh resume topology.
pub(super) fn apply_suspend_policy(state: &mut Ferese) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
let requested = backend.active && backend.desired_outputs.suspend && backend.lid.closed();
backend.lid_reader.lease.lock().unwrap().suspend = requested;
if backend.lid_reader.suspend_requested != requested {
backend.lid_reader.suspend_requested = requested;
let _ = backend.lid_reader.suspend.send(PolicyRequest::Suspend);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn pause_releases_inhibitor_and_rejects_delayed_acquisition() {
let mut lease = InhibitorLease::default();
let first = lease.activate(true);
lease.accept(first, "fd");
assert_eq!(lease.descriptor, Some("fd"));
lease.activate(false);
assert_eq!(lease.descriptor, None);
lease.accept(first, "late fd");
assert_eq!(lease.descriptor, None);
let next = lease.activate(true);
lease.accept(first, "old session fd");
assert_eq!(lease.descriptor, None);
lease.accept(next, "new session fd");
assert_eq!(lease.descriptor, Some("new session fd"));
}
#[test] #[test]
#[ignore = "requires dbus-daemon and private test sockets"] #[ignore = "requires dbus-daemon and private test sockets"]
fn logind_reads_are_fresh_without_property_change_signals() { fn logind_reads_are_fresh_without_property_change_signals() {
+193
View File
@@ -0,0 +1,193 @@
//! A target displays a composition of the source, with no second workspace or input domain.
//! Source alone releases client callbacks/presentation feedback. Targets have independent KMS clocks.
use super::*;
use smithay::backend::renderer::Texture;
use smithay::utils::{Buffer, Physical, Rectangle};
/// Fit the complete source into the transformed target; black letterbox, never crop.
fn fit(source: (i32, i32), target: (i32, i32)) -> Rectangle<i32, Physical> {
let scale = (target.0 as f64 / source.0.max(1) as f64).min(target.1 as f64 / source.1.max(1) as f64);
let width = (source.0 as f64 * scale).round().max(1.0) as i32;
let height = (source.1 as f64 * scale).round().max(1.0) as i32;
Rectangle::new(
((target.0 - width) / 2, (target.1 - height) / 2).into(),
(width, height).into(),
)
}
pub(super) fn elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
source: &Output,
frame: &crate::state::FrameScene,
target: &mut DirectOutput,
) -> Result<Vec<crate::render::AnimatedWindowRenderElement>, smithay::backend::renderer::gles::GlesError> {
let elements = sampled_output_elements(state, renderer, source, true, frame);
// Target renderer owns this texture, including when source and target use different GPUs.
let canvas = target.mirror_canvas.get_or_insert_with(|| {
Output::new(
"mirror-canvas".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "Ferese".into(),
model: "Mirror composition".into(),
},
)
});
compose(renderer, &mut target.mirror_texture, canvas, source, &elements)?;
let texture = target
.mirror_texture
.as_ref()
.expect("source composition has a texture")
.clone();
target.mirror_commit.increment();
Ok(vec![
fitted_texture(texture, &target.output, target.mirror_id.clone(), target.mirror_commit).into(),
])
}
fn compose(
renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>,
canvas: &Output,
source: &Output,
elements: &[crate::render::AnimatedWindowRenderElement],
) -> Result<(), smithay::backend::renderer::gles::GlesError> {
let source_mode = source.current_mode().expect("source has a mode");
canvas.change_current_state(
Some(OutputMode {
size: source.current_transform().transform_size(source_mode.size),
refresh: source_mode.refresh,
}),
Some(Transform::Normal),
Some(source.current_scale()),
None,
);
let framebuffer = capture::render_capture(renderer, texture, canvas, elements)?;
drop(framebuffer);
Ok(())
}
fn fitted_texture(
texture: GlesTexture,
target: &Output,
id: smithay::backend::renderer::element::Id,
commit: CommitCounter,
) -> crate::presentation::NativeTextureElement {
let source_size = texture.size();
let size = target
.current_transform()
.transform_size(target.current_mode().expect("target has a mode").size);
crate::presentation::NativeTextureElement {
id,
commit,
source: Rectangle::<f64, Buffer>::from_size(source_size.to_f64()),
texture,
geometry: fit((source_size.w, source_size.h), (size.w, size.h)),
alpha: 1.0,
program: None,
uniforms: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn different_native_sizes_fit_without_cropping() {
assert_eq!(
fit((1920, 1080), (1280, 1024)),
Rectangle::new((0, 152).into(), (1280, 720).into())
);
assert_eq!(
fit((1920, 1080), (1080, 1920)),
Rectangle::new((0, 656).into(), (1080, 608).into())
);
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn mirrored_pixels_fit_rotated_source_and_target_without_cropping() {
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
use smithay::backend::renderer::element::{Id, Kind};
use smithay::backend::renderer::{Color32F, ExportMem};
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = |name: &str| {
Output::new(
name.into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
)
};
let source = output("source");
source.change_current_state(
Some(OutputMode {
size: (64, 32).into(),
refresh: 60_000,
}),
Some(Transform::_90),
Some(Scale::Fractional(2.0)),
None,
);
let canvas = output("canvas");
let colors: Vec<crate::render::AnimatedWindowRenderElement> = [(0, [1., 0., 0., 1.]), (32, [0., 0., 1., 1.])]
.into_iter()
.map(|(y, color)| {
SolidColorRenderElement::new(
Id::new(),
Rectangle::new((0, y).into(), (32, 32).into()),
CommitCounter::default(),
Color32F::from(color),
Kind::Unspecified,
)
.into()
})
.collect();
let mut texture = None;
compose(&mut renderer, &mut texture, &canvas, &source, &colors).unwrap();
assert_eq!(texture.as_ref().unwrap().size(), (32, 64).into());
let target = output("target");
for (transform, colored_area) in [(Transform::Normal, 2048), (Transform::_90, 4608)] {
target.change_current_state(
Some(OutputMode {
size: (96, 64).into(),
refresh: 120_000,
}),
Some(transform),
Some(Scale::Fractional(1.5)),
None,
);
let element = fitted_texture(
texture.as_ref().unwrap().clone(),
&target,
Id::new(),
CommitCounter::default(),
);
let mut target_texture = None;
let framebuffer = capture::render_capture(&mut renderer, &mut target_texture, &target, &[element]).unwrap();
let mapping = renderer
.copy_framebuffer(&framebuffer, Rectangle::from_size((96, 64).into()), Fourcc::Abgr8888)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
let red = pixels
.chunks_exact(4)
.filter(|pixel| pixel[0] > 100 && pixel[0] > pixel[2])
.count();
let blue = pixels
.chunks_exact(4)
.filter(|pixel| pixel[2] > 100 && pixel[2] > pixel[0])
.count();
assert_eq!(red, colored_area / 2, "{transform:?}");
assert_eq!(blue, colored_area / 2, "{transform:?}");
}
}
}
+184 -82
View File
@@ -78,6 +78,8 @@ pub(super) struct Topology {
pub reconciling: bool, pub reconciling: bool,
pub dirty: bool, pub dirty: bool,
pub retry_timer: Option<RegistrationToken>, pub retry_timer: Option<RegistrationToken>,
pub settle_timer: Option<RegistrationToken>,
pub coalescer: crate::output_policy::Debouncer,
} }
impl Topology { impl Topology {
@@ -93,73 +95,6 @@ impl Topology {
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
let mut topology = Topology::default();
assert!(!topology.request(false));
assert!(!topology.request(false));
assert!(topology.dirty);
assert!(topology.request(true));
topology.reconciling = true;
assert!(!topology.request(true));
}
#[test]
fn replug_matches_hardware_even_when_card_numbers_change() {
let original = Path::new("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
let mut slot = DeviceState {
identity: Some(identity.clone()),
..DeviceState::default()
};
slot.failed(Instant::now());
slot.missing();
let observed = vec![
(
PathBuf::from("/dev/dri/card0"),
Some(PathBuf::from("/sys/devices/other-gpu")),
),
(PathBuf::from("/dev/dri/card2"), Some(identity)),
];
assert_eq!(
slot.current_path(original, &observed),
Some(Path::new("/dev/dri/card2"))
);
assert!(slot.current_path(original, &observed[..1]).is_none());
assert!(slot.current_path(original, &[]).is_none());
}
#[test]
fn unavailable_devices_retry_independently_with_capped_backoff() {
let now = Instant::now();
let mut topology = Topology::default();
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
let mut second = DeviceState::default();
second.ready(healthy);
topology.devices.insert("/dev/dri/card1".into(), second);
let device = topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap();
device.failed(now);
assert_eq!(device.retry_at, Some(now + Duration::from_secs(1)));
for _ in 0..20 {
device.failed(now);
}
assert_eq!(device.retry_at, Some(now + Duration::from_secs(32)));
assert_eq!(
topology.devices[Path::new("/dev/dri/card1")].availability,
Availability::Ready(healthy)
);
assert!(topology.retry_deadline(false).is_none());
assert_eq!(topology.retry_deadline(true), Some(now + Duration::from_secs(32)));
topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap().missing();
assert!(topology.retry_deadline(true).is_none());
}
}
fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState { fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState {
state state
.direct_backend .direct_backend
@@ -180,8 +115,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
return; return;
} }
if let Some(token) = backend.topology.settle_timer.take() {
state.loop_handle.remove(token);
}
backend.topology.coalescer.clear();
backend.topology.reconciling = true; backend.topology.reconciling = true;
backend.topology.dirty = false; backend.topology.dirty = false;
backend.output_error = None;
if reactivate { if reactivate {
backend backend
.presentation .presentation
@@ -191,7 +131,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
if let Some(token) = backend.topology.retry_timer.take() { if let Some(token) = backend.topology.retry_timer.take() {
state.loop_handle.remove(token); state.loop_handle.remove(token);
} }
let paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>(); let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
let invalidated = std::mem::take(&mut backend.topology.invalidated); let invalidated = std::mem::take(&mut backend.topology.invalidated);
let observed = all_gpus(backend.session.seat()).map(|paths| { let observed = all_gpus(backend.session.seat()).map(|paths| {
paths paths
@@ -209,6 +149,23 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
backend.low_power = backend.low_power =
automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent()); automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent());
if let Ok(observed) = &observed {
for (path, identity) in observed {
if !backend
.topology
.devices
.values()
.any(|slot| slot.identity == *identity && identity.is_some())
&& !backend.topology.devices.contains_key(path)
{
backend.topology.devices.insert(path.clone(), DeviceState::known(path));
paths.push(path.clone());
}
}
}
paths.sort();
let mut ready = Vec::new();
for node in invalidated { for node in invalidated {
remove_device(state, node); remove_device(state, node);
} }
@@ -301,7 +258,9 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
.remove(&crtc) .remove(&crtc)
.unwrap(); .unwrap();
cancel_output_timers(&state.loop_handle, &mut output); cancel_output_timers(&state.loop_handle, &mut output);
state.display_handle.disable_global::<Ferese>(output.global); if let Some(global) = output.global.take() {
state.display_handle.disable_global::<Ferese>(global);
}
state.unregister_output(&output.output); state.unregister_output(&output.output);
state state
.direct_backend .direct_backend
@@ -311,28 +270,65 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
.remove(&(node, crtc)); .remove(&(node, crtc));
} }
let complete = rescan_device(state, node); ready.push((path, node));
if complete.is_err() {
remove_device(state, node);
} }
let entry = slot_mut(state, &path);
let known_good = state.direct_backend.as_ref().unwrap().applied_outputs.clone();
if let Err(error) = refresh_monitor_policy(state).and_then(|()| {
transaction::retire_disconnected(state);
validate_desired_outputs(state)
}) {
tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs");
state.direct_backend.as_mut().unwrap().output_error = Some(error);
for (path, _) in &ready {
slot_mut(state, path).failed(Instant::now());
}
} else {
// Activate destinations before disabling former owners, including across GPUs.
ready.sort_by_key(|(_, node)| {
let backend = state.direct_backend.as_ref().unwrap();
let prefix = format!("{}/", drm_device_key(&backend.devices[node].drm));
let desired = backend
.desired_outputs
.outputs
.iter()
.filter(|output| output.settings.enabled && output.key.starts_with(&prefix));
let logical_sources = desired.clone().filter(|output| output.mirror_source.is_none()).count();
std::cmp::Reverse((logical_sources, desired.count()))
});
let mut succeeded = Vec::new();
for (path, node) in &ready {
let complete = rescan_device(state, *node);
let entry = slot_mut(state, path);
if complete == Ok(true) { if complete == Ok(true) {
entry.ready(node); entry.ready(*node);
succeeded.push(*node);
} else { } else {
entry.failed(Instant::now()); entry.failed(Instant::now());
// A partial connector failure must not take down working outputs. entry.availability = Availability::Degraded(*node);
if complete == Ok(false) { // Device boundaries are not cross-GPU atomic. Compensate already
entry.availability = Availability::Degraded(node); // applied devices using their last actual hardware settings.
if !known_good.outputs.is_empty() {
let backend = state.direct_backend.as_mut().unwrap();
let requested = backend.desired_outputs.clone();
backend.desired_outputs =
crate::output_policy::restore_configuration(&known_good, &backend.monitors);
for previous in succeeded {
let _ = rescan_device(state, previous);
} }
state.direct_backend.as_mut().unwrap().desired_outputs = requested;
}
break;
}
}
if state.direct_backend.as_ref().unwrap().output_error.is_none() {
remember_applied_configuration(state);
} }
} }
transaction::recover_last_output(state);
refresh_connected_info(state);
let backend = state.direct_backend.as_mut().unwrap(); let backend = state.direct_backend.as_mut().unwrap();
backend.connected_outputs = backend
.devices
.values()
.flat_map(|device| device.connected_outputs.clone())
.collect();
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 let Some(deadline) = retry { if let Some(deadline) = retry {
@@ -355,4 +351,110 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
state.restore_output_focus(); state.restore_output_focus();
state.relayout(); state.relayout();
render_all(state); render_all(state);
lid::apply_suspend_policy(state);
}
const SETTLE: Duration = Duration::from_millis(150);
/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer.
pub(super) fn hotplug(state: &mut Ferese) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
backend.topology.dirty = true;
if let Some(token) = backend.topology.settle_timer.take() {
state.loop_handle.remove(token);
}
let deadline = backend
.topology
.coalescer
.request(monotonic_now(), backend.active && !backend.lid.pending());
if deadline.is_none() {
return;
}
match state
.loop_handle
.insert_source(Timer::from_duration(SETTLE), |_, _, state| {
if let Some(backend) = state.direct_backend.as_mut() {
backend.topology.settle_timer = None;
if !backend.topology.coalescer.take_due(monotonic_now()) {
return TimeoutAction::Drop;
}
}
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
Ok(token) => state.direct_backend.as_mut().unwrap().topology.settle_timer = Some(token),
Err(error) => {
tracing::warn!(%error, "could not debounce output events");
reconcile_outputs(state, false);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
let mut topology = Topology::default();
assert!(!topology.request(false));
assert!(!topology.request(false));
assert!(topology.dirty);
assert!(topology.request(true));
topology.reconciling = true;
assert!(!topology.request(true));
}
#[test]
fn replug_matches_hardware_even_when_card_numbers_change() {
let original = Path::new("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
let mut slot = DeviceState {
identity: Some(identity.clone()),
..DeviceState::default()
};
slot.failed(Instant::now());
slot.missing();
let observed = vec![
(
PathBuf::from("/dev/dri/card0"),
Some(PathBuf::from("/sys/devices/other-gpu")),
),
(PathBuf::from("/dev/dri/card2"), Some(identity)),
];
assert_eq!(
slot.current_path(original, &observed),
Some(Path::new("/dev/dri/card2"))
);
assert!(slot.current_path(original, &observed[..1]).is_none());
assert!(slot.current_path(original, &[]).is_none());
}
#[test]
fn unavailable_devices_retry_independently_with_capped_backoff() {
let now = Instant::now();
let mut topology = Topology::default();
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
let mut second = DeviceState::default();
second.ready(healthy);
topology.devices.insert("/dev/dri/card1".into(), second);
let device = topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap();
device.failed(now);
assert_eq!(device.retry_at, Some(now + Duration::from_secs(1)));
for _ in 0..20 {
device.failed(now);
}
assert_eq!(device.retry_at, Some(now + Duration::from_secs(32)));
assert_eq!(
topology.devices[Path::new("/dev/dri/card1")].availability,
Availability::Ready(healthy)
);
assert!(topology.retry_deadline(false).is_none());
assert_eq!(topology.retry_deadline(true), Some(now + Duration::from_secs(32)));
topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap().missing();
assert!(topology.retry_deadline(true).is_none());
}
} }
@@ -0,0 +1,669 @@
//! Validate a device-wide desired topology, then use Smithay for every real commit.
//! Smithay exposes TEST_ONLY per surface, but not a combined CRTC request. The
//! one raw request here is validation only; mode/plane ownership stays in Smithay.
use super::*;
use smithay::backend::allocator::Allocator;
use smithay::backend::drm::exporter::{ExportBuffer, ExportFramebuffer};
use smithay::reexports::drm::control::{AtomicCommitFlags, ResourceHandle, atomic::AtomicModeReq};
fn property<D: ControlDevice, H: ResourceHandle>(drm: &D, handle: H, name: &str) -> io::Result<property::Handle> {
for (property, _) in drm.get_properties(handle)?.iter() {
if drm.get_property(*property)?.name().to_bytes() == name.as_bytes() {
return Ok(*property);
}
}
Err(io::Error::other(format!("missing DRM property {name}")))
}
fn add<H: ResourceHandle>(
drm: &DrmDevice,
request: &mut AtomicModeReq,
handle: H,
name: &str,
value: u64,
) -> io::Result<()> {
request.add_raw_property(handle.into(), property(drm, handle, name)?, value);
Ok(())
}
fn clear_secondary_planes(
drm: &DrmDevice,
request: &mut AtomicModeReq,
crtc: crtc::Handle,
) -> Result<(), Box<dyn Error>> {
let planes = drm.planes(&crtc)?;
for plane in planes.cursor.iter().chain(&planes.overlay) {
let properties = drm.get_properties(plane.handle)?;
let owned = properties.iter().any(|(handle, value)| {
drm.get_property(*handle)
.is_ok_and(|info| info.name().to_bytes() == b"CRTC_ID" && *value == u32::from(crtc) as u64)
});
if owned {
add(drm, request, plane.handle, "FB_ID", 0)?;
add(drm, request, plane.handle, "CRTC_ID", 0)?;
}
}
Ok(())
}
struct Blobs {
fd: DrmDeviceFd,
ids: Vec<u64>,
}
impl Drop for Blobs {
fn drop(&mut self) {
for id in &self.ids {
let _ = self.fd.destroy_property_blob(*id);
}
}
}
pub(super) fn validate_device(device: &DirectDevice, scan: &OutputScan) -> Result<(), Box<dyn Error>> {
if scan.selections.iter().all(|selection| {
device
.outputs
.get(&selection.crtc)
.is_some_and(|output| output.connector == selection.connector.handle() && output.mode == selection.mode)
}) && scan.selections.len() == device.outputs.len()
{
return Ok(());
}
// Legacy KMS has no atomic test. Smithay validates its surface/mode as far as
// the API permits; commit failures still go through the restoration path.
if !device.drm.is_atomic() {
return Ok(());
}
let drm = &device.drm;
let mut request = AtomicModeReq::new();
let mut blobs = Blobs {
fd: drm.device_fd().clone(),
ids: Vec::new(),
};
let mut buffers = Vec::new();
let mut framebuffers = Vec::new();
let mut allocator = GbmAllocator::new(device.gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT);
let exporter = GbmFramebufferExporter::new(device.gbm.clone(), Some(device.render_node));
let mut used_planes = HashSet::new();
let desired_crtcs = scan
.selections
.iter()
.map(|selection| selection.crtc)
.collect::<HashSet<_>>();
let desired_connectors = scan
.selections
.iter()
.map(|selection| selection.connector.handle())
.collect::<HashSet<_>>();
for output in device.outputs.values() {
if !desired_connectors.contains(&output.connector) {
add(drm, &mut request, output.connector, "CRTC_ID", 0)?;
}
if !desired_crtcs.contains(&output.surface.crtc()) {
clear_secondary_planes(drm, &mut request, output.surface.crtc())?;
add(drm, &mut request, output.surface.crtc(), "ACTIVE", 0)?;
add(drm, &mut request, output.surface.crtc(), "MODE_ID", 0)?;
add(drm, &mut request, output.surface.plane(), "FB_ID", 0)?;
add(drm, &mut request, output.surface.plane(), "CRTC_ID", 0)?;
}
}
for selection in &scan.selections {
clear_secondary_planes(drm, &mut request, selection.crtc)?;
let planes = drm.planes(&selection.crtc)?;
let primary = device
.outputs
.get(&selection.crtc)
.map(|output| output.surface.plane())
.or_else(|| {
planes
.primary
.iter()
.find(|plane| !used_planes.contains(&plane.handle))
.map(|plane| plane.handle)
})
.ok_or("no usable primary plane")?;
used_planes.insert(primary);
if let Ok(rotation) = property(drm, primary, "rotation") {
request.add_raw_property(primary.into(), rotation, 1);
}
let blob: u64 = drm.create_property_blob(&selection.mode)?.into();
blobs.ids.push(blob);
add(drm, &mut request, selection.crtc, "MODE_ID", blob)?;
add(drm, &mut request, selection.crtc, "ACTIVE", 1)?;
add(
drm,
&mut request,
selection.connector.handle(),
"CRTC_ID",
u32::from(selection.crtc) as u64,
)?;
let (width, height) = selection.mode.size();
let formats = &planes
.primary
.iter()
.find(|plane| plane.handle == primary)
.ok_or("primary plane disappeared")?
.formats;
let mut allocated = None;
for format in formats.iter().filter(|format| {
matches!(
format.code,
Fourcc::Argb8888 | Fourcc::Abgr8888 | Fourcc::Xrgb8888 | Fourcc::Xbgr8888
)
}) {
if let Ok(buffer) = allocator.create_buffer(width.into(), height.into(), format.code, &[format.modifier])
&& let Ok(Some(fb)) = exporter.add_framebuffer(drm.device_fd(), ExportBuffer::Allocator(&buffer), false)
{
allocated = Some((buffer, fb));
break;
}
}
let (buffer, fb) = allocated.ok_or("unable to allocate a DRM validation buffer")?;
add(drm, &mut request, primary, "FB_ID", u32::from(*fb.as_ref()) as u64)?;
add(drm, &mut request, primary, "CRTC_ID", u32::from(selection.crtc) as u64)?;
for (name, value) in [
("SRC_X", 0),
("SRC_Y", 0),
("SRC_W", (width as u64) << 16),
("SRC_H", (height as u64) << 16),
("CRTC_X", 0),
("CRTC_Y", 0),
("CRTC_W", width as u64),
("CRTC_H", height as u64),
] {
add(drm, &mut request, primary, name, value)?;
}
buffers.push(buffer);
framebuffers.push(fb);
}
drm.atomic_commit(AtomicCommitFlags::TEST_ONLY | AtomicCommitFlags::ALLOW_MODESET, request)?;
Ok(())
}
fn prepare(
surface: &mut OutputCompositor,
renderer: &mut GlesRenderer,
mode: DrmMode,
settings: &OutputSettings,
) -> Result<(), Box<dyn Error>> {
surface.use_mode(mode)?;
surface.set_output_mode_source(OutputModeSource::Static {
size: OutputMode::from(mode).size,
scale: settings.scale.into(),
transform: output_transform(settings.transform),
});
surface.reset_buffer_ages();
let elements: Vec<crate::render::AnimatedWindowRenderElement> = Vec::new();
let result = surface.render_frame(
renderer,
&elements,
[0.0, 0.0, 0.0, 1.0],
smithay::backend::drm::compositor::FrameFlags::empty(),
)?;
if let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element {
primary.sync.wait()?;
}
Ok(())
}
/// Retire a logical output only after its hardware is unavailable or confirmed disabled.
fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: crtc::Handle) {
cancel_output_timers(&state.loop_handle, output);
if let Some(global) = output.global.take() {
state.display_handle.disable_global::<Ferese>(global);
}
state.session_lock.output_removed(&output.output);
state.unregister_output(&output.output);
state
.direct_backend
.as_mut()
.unwrap()
.presentation
.remove(&(node, crtc));
}
struct DeviceTransaction<'a> {
state: &'a mut Ferese,
device: &'a mut DirectDevice,
selections: &'a HashMap<crtc::Handle, OutputSelection>,
staged: HashMap<crtc::Handle, DirectOutput>,
replaced: HashSet<crtc::Handle>,
}
impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
type Key = crtc::Handle;
fn stage(&mut self, crtc: crtc::Handle) -> Result<(), String> {
let selection = &self.selections[&crtc];
if self
.device
.outputs
.get(&crtc)
.is_none_or(|output| output.connector != selection.connector.handle())
{
let output = create_direct_output(
self.state,
&mut self.device.drm,
&self.device.gbm,
&self.device.renderer,
selection.clone(),
false,
)
.map_err(|error| error.to_string())?;
self.staged.insert(crtc, output);
}
let output = self
.staged
.get_mut(&crtc)
.or_else(|| self.device.outputs.get_mut(&crtc))
.unwrap();
output
.surface
.set_connectors(&[selection.connector.handle()])
.map_err(|error| error.to_string())?;
prepare(
&mut output.surface,
&mut self.device.renderer,
selection.mode,
&selection.settings,
)
.map_err(|error| error.to_string())
}
fn commit(&mut self, crtc: crtc::Handle) -> Result<(), String> {
if self.staged.contains_key(&crtc) && self.device.outputs.contains_key(&crtc) {
// Connector and mode cannot be changed together through use_mode /
// set_connectors: each tests against the other's old value. Stage a
// replacement surface, retire the old one *before* its first commit
// (Smithay clears the CRTC on surface drop), then publish metadata.
let replacement = self.staged.remove(&crtc).unwrap();
let previous = self.device.outputs.get_mut(&crtc).unwrap();
previous.surface = replacement.surface;
self.replaced.insert(crtc);
}
self.staged
.get_mut(&crtc)
.or_else(|| self.device.outputs.get_mut(&crtc))
.unwrap()
.surface
.commit_frame()
.map_err(|error| error.to_string())
}
fn disable(&mut self, crtc: crtc::Handle) -> Result<(), String> {
self.device
.outputs
.get_mut(&crtc)
.unwrap()
.surface
.clear()
.map_err(|error| error.to_string())
}
fn abort_staged(&mut self) {
for output in self.staged.values_mut() {
let _ = output.surface.clear();
}
self.staged.clear();
}
fn restore(&mut self, crtc: crtc::Handle) -> Result<(), String> {
if self.replaced.contains(&crtc) {
let output = &self.device.outputs[&crtc];
let connector = self
.device
.drm
.get_connector(output.connector, true)
.map_err(|error| error.to_string())?;
if connector.state() != connector::State::Connected {
return Err("previous connector is no longer available".into());
}
let selection = OutputSelection {
connector,
crtc,
mode: output.mode,
settings: output.settings.clone(),
identity: output.identity.clone(),
mirror_source: output.mirror_source.clone(),
};
let mut replacement = create_direct_output(
self.state,
&mut self.device.drm,
&self.device.gbm,
&self.device.renderer,
selection,
false,
)
.map_err(|error| error.to_string())?;
prepare(
&mut replacement.surface,
&mut self.device.renderer,
replacement.mode,
&replacement.settings,
)
.map_err(|error| error.to_string())?;
let previous = self.device.outputs.get_mut(&crtc).unwrap();
previous.surface = replacement.surface;
previous.surface.commit_frame().map_err(|error| error.to_string())?;
}
let Some(output) = self.device.outputs.get_mut(&crtc) else {
return Ok(());
};
output.surface.reset_state().map_err(|error| error.to_string())?;
let restored = output
.surface
.set_connectors(&[output.connector])
.map_err(|error| error.to_string())
.and_then(|()| {
prepare(
&mut output.surface,
&mut self.device.renderer,
output.mode,
&output.settings,
)
.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);
output.lock_frame_pending = false;
output.surface.set_output_mode_source((&output.output).into());
cancel_output_timers(&self.state.loop_handle, output);
output.frame_pending = false;
restored
}
}
/// All allocations/tests precede commits. Logical publication follows every
/// successful commit on this device; other devices are separately rollbackable.
pub(super) fn apply_device(
state: &mut Ferese,
node: DrmNode,
device: &mut DirectDevice,
scan: OutputScan,
) -> Result<(), String> {
let selections = scan
.selections
.into_iter()
.map(|selection| (selection.crtc, selection))
.collect::<HashMap<_, _>>();
let mut changed = selections
.iter()
.filter_map(|(crtc, selection)| {
let same = device.outputs.get(crtc).is_some_and(|output| {
output.connector == selection.connector.handle()
&& output.identity == selection.identity
&& output.mode == selection.mode
&& output.settings.same_configuration(&selection.settings)
&& output.mirror_source == selection.mirror_source
});
(!same).then_some(*crtc)
})
.collect::<Vec<_>>();
changed.sort_by_key(|crtc| (selections[crtc].mirror_source.is_some(), u32::from(*crtc)));
let removals = device
.outputs
.keys()
.filter(|crtc| !selections.contains_key(crtc))
.copied()
.collect::<Vec<_>>();
let hardware_changes = changed
.iter()
.filter(|crtc| {
!device.outputs.get(crtc).is_some_and(|output| {
let selection = &selections[crtc];
output.connector == selection.connector.handle() && output.mode == selection.mode
})
})
.copied()
.collect::<Vec<_>>();
let mut driver = DeviceTransaction {
state,
device,
selections: &selections,
staged: HashMap::new(),
replaced: HashSet::new(),
};
let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &removals);
let mut staged = std::mem::take(&mut driver.staged);
if let Err(failure) = result {
for crtc in failure.unavailable {
if let Some(mut output) = device.outputs.remove(&crtc) {
retire(state, &mut output, node, crtc);
}
}
return Err(failure.error);
}
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(
state,
&selection.connector,
selection.mode,
selection.identity.clone(),
&selection.settings,
false,
);
output.output = handle;
}
if selection.mirror_source.is_some() && output.global.is_some() {
retire(state, &mut output, node, *crtc);
}
output.connector = selection.connector.handle();
output.internal = internal_connector(selection.connector.interface());
output.identity = selection.identity.clone();
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,
),
);
}
}
}
state.session_lock.output_added(&output.output);
output.lock_frame_pending = false;
output.surface.set_output_mode_source((&output.output).into());
cancel_output_timers(&state.loop_handle, &mut output);
output.frame_pending = false;
output.primary_commit = None;
output.capture_texture = None;
output.power.suspended();
let clock = state
.direct_backend
.as_mut()
.unwrap()
.presentation
.entry((node, *crtc))
.or_default();
clock.set_refresh(OutputMode::from(output.mode).refresh);
clock.reset_timing();
device.outputs.insert(*crtc, output);
}
// Add/register destinations before removing former owners, reusing evacuation.
for crtc in removals {
let mut output = device.outputs.remove(&crtc).unwrap();
retire(state, &mut output, node, crtc);
}
device.connected_outputs = scan.connected_outputs;
update_applied_info(device);
Ok(())
}
pub(super) fn update_applied_info(device: &mut DirectDevice) {
for info in &mut device.connected_outputs {
info.enabled = false;
info.current_mode = None;
if let Some(output) = device.outputs.values().find(|output| output.identity == info.identity) {
info.enabled = true;
info.current_mode = Some(connected_mode_info(output.mode));
info.scale = output.settings.scale;
info.transform = output.settings.transform;
info.configured_position = output.settings.position;
}
}
}
/// Disconnected hardware cannot remain logically live, even if a new policy fails validation.
pub(super) fn retire_disconnected(state: &mut Ferese) {
let missing = state
.direct_backend
.as_ref()
.unwrap()
.devices
.iter()
.flat_map(|(node, device)| {
device.outputs.iter().filter_map(|(crtc, output)| {
device
.drm
.get_connector(output.connector, true)
.ok()
.filter(|connector| connector.state() != connector::State::Connected)
.map(|_| (*node, *crtc))
})
})
.collect::<Vec<_>>();
for (node, crtc) in missing {
if let Some(mut output) = state
.direct_backend
.as_mut()
.unwrap()
.devices
.get_mut(&node)
.unwrap()
.outputs
.remove(&crtc)
{
let _ = output.surface.clear();
retire(state, &mut output, node, crtc);
}
}
}
/// If initial activation or rollback leaves no usable desktop, try a preferred
/// mode on each physical monitor independently. Never disable a surviving output.
pub(super) fn recover_last_output(state: &mut Ferese) {
if state
.direct_backend
.as_ref()
.unwrap()
.devices
.values()
.any(|device| device.outputs.values().any(|output| output.global.is_some()))
{
return;
}
let backend = state.direct_backend.as_ref().unwrap();
let requested = backend.desired_outputs.clone();
let original_error = backend.output_error.clone();
let mut candidates = backend
.monitors
.iter()
.filter(|monitor| monitor.usable)
.cloned()
.collect::<Vec<_>>();
candidates.sort_by_key(|monitor| (!monitor.internal, monitor.key.clone()));
for candidate in candidates {
let backend = state.direct_backend.as_mut().unwrap();
backend.desired_outputs = DesiredOutputConfiguration {
profile: None,
suspend: requested.suspend,
outputs: backend
.monitors
.iter()
.map(|monitor| {
let mut settings = crate::output_policy::defaults(monitor.identity.clone());
settings.enabled = monitor.key == candidate.key;
crate::output_policy::DesiredOutput {
key: monitor.key.clone(),
settings,
mirror_source: None,
}
})
.collect(),
};
let nodes = backend
.devices
.iter()
.filter(|(_, device)| candidate.key.starts_with(&format!("{}/", drm_device_key(&device.drm))))
.map(|(node, _)| *node)
.collect::<Vec<_>>();
if validate_desired_outputs(state).is_ok() {
for node in nodes {
let _ = rescan_device(state, node);
}
if state
.direct_backend
.as_ref()
.unwrap()
.devices
.values()
.any(|device| device.outputs.values().any(|output| output.global.is_some()))
{
remember_applied_configuration(state);
state.direct_backend.as_mut().unwrap().output_error = original_error;
tracing::warn!(identity = %candidate.identity, "recovered last usable output with preferred mode");
return;
}
}
}
let backend = state.direct_backend.as_mut().unwrap();
backend.desired_outputs = requested;
backend.output_error = original_error;
}
+31 -1
View File
@@ -20,7 +20,7 @@ use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
pub use input_motion::InputSettings; pub use input_motion::InputSettings;
use input_motion::*; use input_motion::*;
use outputs::*; use outputs::*;
pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
use serde::Deserialize; use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb}; use smithay::input::keyboard::{Keycode, keysyms, xkb};
@@ -1049,9 +1049,15 @@ mod tests {
config.output_profiles().unwrap(), config.output_profiles().unwrap(),
vec![OutputProfile { vec![OutputProfile {
name: "docked".to_owned(), name: "docked".to_owned(),
layout: OutputLayout::Extend,
confirm_timeout: 15,
lid_policy: LidPolicy::DockOrSuspend,
lid_closed: None,
mirror_source: None,
outputs: vec![ outputs: vec![
OutputSettings { OutputSettings {
auto_refresh: false, auto_refresh: false,
required: true,
matcher: "HDMI-A-1".to_owned(), matcher: "HDMI-A-1".to_owned(),
enabled: true, enabled: true,
mode: Some(OutputModeRequest { mode: Some(OutputModeRequest {
@@ -1065,6 +1071,7 @@ mod tests {
}, },
OutputSettings { OutputSettings {
auto_refresh: false, auto_refresh: false,
required: true,
matcher: "eDP-1".to_owned(), matcher: "eDP-1".to_owned(),
enabled: false, enabled: false,
mode: None, mode: None,
@@ -1077,6 +1084,29 @@ mod tests {
); );
} }
#[test]
fn output_profile_metadata_and_invalid_mirrors() {
let config = parse(
"output-profile desk layout=\"mirror\" mirror-source=\"eDP-1\" lid-closed=#false confirm-timeout=0 lid-policy=\"ignore\" { output eDP-1; output DP-1 required=#false; }",
);
let profile = &config.output_profiles().unwrap()[0];
assert_eq!(profile.layout, OutputLayout::Mirror);
assert_eq!(profile.lid_closed, Some(false));
assert_eq!(profile.confirm_timeout, 0);
assert_eq!(profile.lid_policy, LidPolicy::Ignore);
assert!(!profile.outputs[1].required);
for source in [
"output-profile a layout=\"mirror\" { output eDP-1; }",
"output-profile a layout=\"extend\" mirror-source=\"eDP-1\" { output eDP-1; }",
"output-profile a layout=\"mirror\" mirror-source=\"DP-2\" { output eDP-1; output DP-1; }",
"output-profile a { output eDP-1 enabled=#false; }",
"output-profile a { output eDP-1; }\noutput-profile a { output DP-1; }",
] {
assert!(parse(source).output_profiles().is_err(), "{source}");
}
assert!(Config::parse_source("output-profile a layout=\"docked\" { output eDP-1; }").is_err());
}
#[test] #[test]
fn automatic_refresh_is_opt_in_and_rejects_wrong_types() { fn automatic_refresh_is_opt_in_and_rejects_wrong_types() {
let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }"); let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }");
+80
View File
@@ -43,6 +43,7 @@ impl Config {
Ok(OutputSettings { Ok(OutputSettings {
auto_refresh: output.auto_refresh, auto_refresh: output.auto_refresh,
required: output.required,
matcher: matcher.to_owned(), matcher: matcher.to_owned(),
enabled: output.enabled, enabled: output.enabled,
mode: output mode: output
@@ -58,9 +59,38 @@ impl Config {
}) })
.collect::<Result<Vec<_>, ConfigError>>()?; .collect::<Result<Vec<_>, ConfigError>>()?;
if !outputs.iter().any(|output| output.enabled) {
return Err(ConfigError::OutputProfile(format!(
"profile {name:?} disables every output"
)));
}
if profile.layout != OutputLayout::Mirror && profile.mirror_source.is_some() {
return Err(ConfigError::OutputProfile(format!(
"profile {name:?}: mirror-source requires layout=\"mirror\""
)));
}
if let Some(source) = &profile.mirror_source
&& !outputs.iter().any(|output| &output.matcher == source && output.enabled)
{
return Err(ConfigError::OutputProfile(format!(
"profile {name:?}: mirror source must name an enabled output"
)));
}
if profile.layout == OutputLayout::Mirror && outputs.iter().filter(|output| output.enabled).count() < 2
{
return Err(ConfigError::OutputProfile(format!(
"profile {name:?}: mirror needs at least two enabled outputs"
)));
}
Ok(OutputProfile { Ok(OutputProfile {
name: name.to_owned(), name: name.to_owned(),
outputs, outputs,
layout: profile.layout,
confirm_timeout: profile.confirm_timeout,
lid_policy: profile.lid_policy,
lid_closed: profile.lid_closed,
mirror_source: profile.mirror_source.clone(),
}) })
}) })
.collect() .collect()
@@ -112,11 +142,17 @@ pub(super) const fn default_output_scale() -> f64 {
pub struct OutputProfile { pub struct OutputProfile {
pub name: String, pub name: String,
pub outputs: Vec<OutputSettings>, pub outputs: Vec<OutputSettings>,
pub layout: OutputLayout,
pub confirm_timeout: u32,
pub lid_policy: LidPolicy,
pub lid_closed: Option<bool>,
pub mirror_source: Option<String>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings { pub struct OutputSettings {
pub auto_refresh: bool, pub auto_refresh: bool,
pub required: bool,
pub matcher: String, pub matcher: String,
pub enabled: bool, pub enabled: bool,
pub mode: Option<OutputModeRequest>, pub mode: Option<OutputModeRequest>,
@@ -125,6 +161,18 @@ pub struct OutputSettings {
pub position: Option<[i32; 2]>, pub position: Option<[i32; 2]>,
} }
impl OutputSettings {
/// Selectors and requiredness affect matching, not the applied output state.
pub(crate) fn same_configuration(&self, other: &Self) -> bool {
self.enabled == other.enabled
&& self.mode == other.mode
&& self.auto_refresh == other.auto_refresh
&& self.scale == other.scale
&& self.transform == other.transform
&& self.position == other.position
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutputModeRequest { pub struct OutputModeRequest {
pub width: u16, pub width: u16,
@@ -152,10 +200,40 @@ pub enum OutputTransform {
Flipped270, Flipped270,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum LidPolicy {
#[default]
DockOrSuspend,
Ignore,
}
const fn default_confirm_timeout() -> u32 {
15
}
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum OutputLayout {
InternalOnly,
ExternalOnly,
#[default]
Extend,
Mirror,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub(super) struct OutputProfileConfig { pub(super) struct OutputProfileConfig {
pub(super) name: String, pub(super) name: String,
#[serde(default)] #[serde(default)]
pub(super) layout: OutputLayout,
#[serde(default = "default_confirm_timeout")]
pub(super) confirm_timeout: u32,
#[serde(default)]
pub(super) lid_policy: LidPolicy,
pub(super) lid_closed: Option<bool>,
pub(super) mirror_source: Option<String>,
#[serde(default)]
pub(super) outputs: Vec<OutputConfig>, pub(super) outputs: Vec<OutputConfig>,
} }
@@ -163,6 +241,8 @@ pub(super) struct OutputProfileConfig {
pub(super) struct OutputConfig { pub(super) struct OutputConfig {
#[serde(default)] #[serde(default)]
pub(super) auto_refresh: bool, pub(super) auto_refresh: bool,
#[serde(default = "default_true")]
pub(super) required: bool,
#[serde(rename = "match")] #[serde(rename = "match")]
pub(super) matcher: String, pub(super) matcher: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
+45 -5
View File
@@ -694,7 +694,40 @@ impl Ferese {
return Ok(json!(self.has_client_surfaces(pid))); return Ok(json!(self.has_client_surfaces(pid)));
} }
"get-workspaces" => return Ok(self.workspaces_json()), "get-workspaces" => return Ok(self.workspaces_json()),
"get-outputs" => return Ok(self.outputs_json()), "outputs" | "get-outputs" => return Ok(self.outputs_json()),
"output-profiles" => {
return Ok(json!({
"confirmation_pending": self.direct_backend.as_ref().is_some_and(|backend| backend.confirmation_pending()),
"manual_profile": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.profile.as_ref()),
"manual_internal": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.internal),
"profiles": self.output_profiles.iter().map(|profile| json!({
"name": profile.name, "layout": profile.layout,
"confirm_timeout": profile.confirm_timeout, "lid_policy": profile.lid_policy, "lid_closed": profile.lid_closed, "mirror_source": profile.mirror_source,
"outputs": profile.outputs.iter().map(|output| json!({"selector": output.matcher, "required": output.required, "enabled": output.enabled})).collect::<Vec<_>>()
})).collect::<Vec<_>>()
}));
}
"output-confirm" | "output-revert" => {
crate::backends::direct::confirm_output_configuration(self, command == "output-confirm")
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
return Ok(self.outputs_json());
}
"output-profile" => {
let name = args["name"]
.as_str()
.ok_or_else(|| CommandError::new("invalid_arguments", "output-profile requires a name or auto"))?;
crate::backends::direct::set_output_profile(self, name)
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
return Ok(self.outputs_json());
}
"output-internal" => {
let enabled = args["enabled"]
.as_bool()
.ok_or_else(|| CommandError::new("invalid_arguments", "output-internal requires on or off"))?;
crate::backends::direct::set_internal_output(self, enabled)
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
return Ok(self.outputs_json());
}
_ => { _ => {
return Err(CommandError::new( return Err(CommandError::new(
"unknown_command", "unknown_command",
@@ -1026,8 +1059,8 @@ impl Ferese {
.connected_outputs .connected_outputs
.iter() .iter()
.map(|info| { .map(|info| {
let mapped = self.space.outputs().find(|output| output.name() == info.connector); let mapped = self.output_by_identity(&info.identity);
let id = mapped.and_then(|output| self.output_id(output)); let id = self.persistent_output_id(&info.identity);
let geometry = mapped.and_then(|output| self.space.output_geometry(output)); let geometry = mapped.and_then(|output| self.space.output_geometry(output));
let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id)); let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id));
let position = geometry let position = geometry
@@ -1066,9 +1099,16 @@ impl Ferese {
"name": info.connector, "name": info.connector,
"connector": info.connector, "connector": info.connector,
"identity": info.identity, "identity": info.identity,
"connected": true, "connected": info.connected,
"enabled": info.enabled && mapped.is_some(), "internal": info.internal,
"requested_enabled": info.requested_enabled,
"applied_enabled": info.enabled,
"mirror_source": info.mirror_source,
"enabled": info.enabled,
"profile": info.profile, "profile": info.profile,
"requested_profile": info.requested_profile,
"configuration_error": backend.output_configuration_error(),
"confirmation_pending": backend.confirmation_pending(),
"auto_refresh": info.auto_refresh, "auto_refresh": info.auto_refresh,
"low_power": backend.low_power, "low_power": backend.low_power,
"focused": id.is_some() && id == focused, "focused": id.is_some() && id == focused,
+3
View File
@@ -16,6 +16,9 @@ mod input;
mod input_capture; mod input_capture;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod monitor_identity;
mod output_policy;
mod output_transaction;
mod overview; mod overview;
mod portal_session; mod portal_session;
mod portal_shortcuts; mod portal_shortcuts;
+215
View File
@@ -0,0 +1,215 @@
//! Human-readable EDID identities. Serial-less displays remain tied to a qualified connector.
//! Duplicate serials are qualified too; a remembered collision stays qualified after unplug.
use std::collections::{HashMap, HashSet};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Identity {
pub make: String,
pub model: String,
pub serial: Option<String>,
}
fn escaped(value: &str) -> String {
value
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
char::from(byte).to_string()
} else {
format!("%{byte:02X}")
}
})
.collect()
}
impl Identity {
pub fn from_edid(edid: &[u8]) -> Option<Self> {
if edid.len() < 128
|| edid[..8] != [0, 255, 255, 255, 255, 255, 255, 0]
|| edid[..128].iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte)) != 0
{
return None;
}
let manufacturer = u16::from_be_bytes([edid[8], edid[9]]);
let mut make = String::new();
for shift in [10, 5, 0] {
let letter = ((manufacturer >> shift) & 31) as u8;
if !(1..=26).contains(&letter) {
return None;
}
make.push(char::from(b'A' + letter - 1));
}
let product = u16::from_le_bytes([edid[10], edid[11]]);
let numeric_serial = u32::from_le_bytes(edid[12..16].try_into().ok()?);
let mut model = format!("{product:04X}");
let mut serial = None;
for descriptor in edid[54..126].chunks_exact(18) {
if descriptor[..3] != [0, 0, 0] {
continue;
}
let text = String::from_utf8_lossy(&descriptor[5..18])
.trim_matches(|c: char| c.is_whitespace() || c == '\0')
.to_owned();
if text.is_empty() {
continue;
}
match descriptor[3] {
0xfc => model = format!("{product:04X}-{}", escaped(&text)),
0xff if text != "0" => serial = Some(escaped(&text)),
_ => {}
}
}
if serial.is_none() && numeric_serial != 0 && numeric_serial != u32::MAX {
serial = Some(format!("{numeric_serial:08X}"));
}
Some(Self { make, model, serial })
}
pub fn base(&self) -> String {
format!(
"edid:{}:{}:{}",
self.make,
self.model,
self.serial.as_deref().unwrap_or("no-serial")
)
}
}
#[derive(Default)]
pub(crate) struct IdentityRegistry {
ambiguous: HashSet<String>,
/// Retains the first monitor's identity if a later collision is discovered.
known: HashMap<String, String>,
}
impl IdentityRegistry {
pub fn resolve(&mut self, monitors: &[(String, Option<Identity>)]) -> Vec<String> {
let mut counts = HashMap::new();
for (_, identity) in monitors {
if let Some(identity) = identity {
*counts.entry(identity.base()).or_insert(0) += 1;
}
}
self.ambiguous
.extend(counts.into_iter().filter(|(_, count)| *count > 1).map(|(base, _)| base));
// Prefer already-known identities, but never permit duplicate runtime IDs.
let mut used = HashSet::new();
let mut ordered = monitors.iter().enumerate().collect::<Vec<_>>();
ordered.sort_by_key(|(_, (key, _))| (!self.known.contains_key(key), key));
let mut result = vec![String::new(); monitors.len()];
for (index, (key, identity)) in ordered {
let base = identity.as_ref().map(Identity::base);
let qualified = match &base {
Some(base) => format!("{base}@{}", escaped(key)),
None => format!("drm:{}", escaped(key)),
};
let preferred = self
.known
.get(key)
.filter(|known| {
base.as_ref().is_some_and(|base| *known == base || *known == &qualified)
|| (base.is_none() && *known == &qualified)
})
.cloned()
.unwrap_or_else(|| {
if identity.as_ref().is_some_and(|identity| identity.serial.is_some())
&& !self.ambiguous.contains(base.as_ref().unwrap())
{
base.clone().unwrap()
} else {
qualified.clone()
}
});
let resolved = if used.insert(preferred.clone()) {
preferred
} else {
qualified
};
assert!(
result.iter().all(|other| other != &resolved),
"qualified hardware connector must be unique"
);
self.known.insert(key.clone(), resolved.clone());
result[index] = resolved;
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity(serial: Option<&str>) -> Identity {
Identity {
make: "DEL".into(),
model: "1234-monitor".into(),
serial: serial.map(String::from),
}
}
#[test]
fn replacement_on_same_connector_invalidates_cached_identity() {
let mut registry = IdentityRegistry::default();
let key = "gpu/DP-1".to_owned();
let dell = identity(Some("123"));
let mut lg = identity(Some("456"));
lg.make = "GSM".into();
let old = registry.resolve(&[(key.clone(), Some(dell.clone()))]);
let new = registry.resolve(&[(key.clone(), Some(lg))]);
assert_ne!(old, new);
assert!(new[0].starts_with("edid:GSM:"));
assert_eq!(registry.resolve(&[(key.clone(), Some(dell))]), old);
assert!(registry.resolve(&[(key, None)])[0].starts_with("drm:"));
}
#[test]
fn serial_survives_connector_change() {
let mut registry = IdentityRegistry::default();
let first = registry.resolve(&[("gpu/DP-1".into(), Some(identity(Some("123"))))]);
let next = registry.resolve(&[("gpu/DP-2".into(), Some(identity(Some("123"))))]);
assert_eq!(first, next);
assert_eq!(first[0], "edid:DEL:1234-monitor:123");
}
#[test]
fn identical_without_serial_are_distinct_and_reconnect_stably() {
for serial in [None, Some("duplicated")] {
let mut registry = IdentityRegistry::default();
let inputs = [
("gpu/DP-1".into(), Some(identity(serial))),
("gpu/DP-2".into(), Some(identity(serial))),
];
let resolved = registry.resolve(&inputs);
assert_ne!(resolved[0], resolved[1]);
assert_eq!(registry.resolve(&inputs[..1])[0], resolved[0]);
assert_eq!(registry.resolve(&inputs), resolved);
}
}
#[test]
fn collision_after_first_connection_does_not_change_existing_identity() {
let mut registry = IdentityRegistry::default();
let a = ("gpu/DP-1".into(), Some(identity(Some("duplicate"))));
let b = ("gpu/DP-2".into(), Some(identity(Some("duplicate"))));
let first = registry.resolve(std::slice::from_ref(&a));
let both = registry.resolve(&[a.clone(), b]);
assert_eq!(both[0], first[0]);
assert_ne!(both[0], both[1]);
assert_eq!(registry.resolve(&[a]), first);
}
#[test]
fn edid_decodes_make_product_and_numeric_serial_and_checks_checksum() {
let mut edid = [0u8; 128];
edid[..8].copy_from_slice(&[0, 255, 255, 255, 255, 255, 255, 0]);
let del = (4u16 << 10) | (5 << 5) | 12;
edid[8..10].copy_from_slice(&del.to_be_bytes());
edid[10..12].copy_from_slice(&0x1234u16.to_le_bytes());
edid[12..16].copy_from_slice(&42u32.to_le_bytes());
edid[127] = 0u8.wrapping_sub(edid.iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte)));
assert_eq!(Identity::from_edid(&edid).unwrap().base(), "edid:DEL:1234:0000002A");
edid[127] ^= 1;
assert!(Identity::from_edid(&edid).is_none());
}
}
+654
View File
@@ -0,0 +1,654 @@
//! Pure monitor selection. DRM handles and side effects never enter this module.
use std::collections::{BTreeSet, HashSet};
use crate::config::{LidPolicy, OutputLayout, OutputProfile, OutputSettings, OutputTransform};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Monitor {
/// Qualified hardware connector (GPU identity + connector), never a runtime Output handle.
pub key: String,
pub connector: String,
pub identity: String,
pub aliases: Vec<String>,
pub internal: bool,
pub usable: bool,
}
impl Monitor {
pub fn matches(&self, selector: &str) -> bool {
selector == self.connector || selector == self.identity || self.aliases.iter().any(|alias| alias == selector)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DesiredOutput {
pub key: String,
pub settings: OutputSettings,
/// A mirror target has no workspace ownership of its own.
pub mirror_source: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct DesiredOutputConfiguration {
pub profile: Option<String>,
pub outputs: Vec<DesiredOutput>,
pub suspend: bool,
}
/// Overrides expire on a *material* topology/lid change, independent of event ordering.
#[derive(Clone, Debug, Default)]
pub(crate) struct ManualOverride {
pub profile: Option<String>,
pub internal: Option<bool>,
topology: Option<(BTreeSet<String>, bool)>,
}
impl ManualOverride {
pub fn observe(&mut self, monitors: &[Monitor], lid_closed: bool, profiles: &[OutputProfile]) {
let topology = (
monitors
.iter()
.map(|monitor| format!("{}={}", monitor.key, monitor.identity))
.collect(),
lid_closed,
);
if self.topology.as_ref().is_some_and(|old| old != &topology) {
self.profile = None;
self.internal = None;
}
if self.profile.as_ref().is_some_and(|name| {
!profiles
.iter()
.any(|profile| profile.name == *name && profile_matches(profile, monitors, lid_closed))
}) {
self.profile = None;
}
self.topology = Some(topology);
}
}
fn profile_matches(profile: &OutputProfile, monitors: &[Monitor], lid_closed: bool) -> bool {
let mut matched = HashSet::new();
if profile.outputs.iter().any(|settings| {
monitors
.iter()
.filter(|monitor| monitor.matches(&settings.matcher))
.any(|monitor| !matched.insert(&monitor.key))
}) {
return false;
}
profile.lid_closed.is_none_or(|required| required == lid_closed)
&& profile
.outputs
.iter()
.filter(|settings| settings.required)
.all(|settings| {
// Ambiguous selectors cannot silently configure two monitors as one.
monitors
.iter()
.filter(|monitor| monitor.matches(&settings.matcher))
.count()
== 1
})
}
pub(crate) fn defaults(matcher: String) -> OutputSettings {
OutputSettings {
auto_refresh: false,
matcher,
required: true,
enabled: true,
mode: None,
scale: 1.0,
transform: OutputTransform::Normal,
position: None,
}
}
/// Most required monitors wins; equal specificity retains config order.
/// Unlisted outputs keep working in extend profiles. Exclusive layouts deliberately filter them.
pub(crate) fn select_profile(
monitors: &[Monitor],
lid_closed: bool,
profiles: &[OutputProfile],
manual: &ManualOverride,
) -> DesiredOutputConfiguration {
let selected = manual
.profile
.as_ref()
.and_then(|name| {
profiles
.iter()
.find(|profile| profile.name == *name && profile_matches(profile, monitors, lid_closed))
})
.or_else(|| {
profiles
.iter()
.enumerate()
.filter(|(_, profile)| profile_matches(profile, monitors, lid_closed))
.max_by_key(|(index, profile)| {
(
profile.outputs.iter().filter(|output| output.required).count(),
std::cmp::Reverse(*index),
)
})
.map(|(_, profile)| profile)
});
let layout = selected.map_or(OutputLayout::Extend, |profile| profile.layout);
let manage_lid = selected.is_none_or(|profile| profile.lid_policy == LidPolicy::DockOrSuspend);
let external = monitors.iter().any(|monitor| monitor.usable && !monitor.internal);
let mut outputs = monitors
.iter()
.map(|monitor| {
let mut settings = selected
.and_then(|profile| {
profile
.outputs
.iter()
.find(|settings| monitor.matches(&settings.matcher))
})
.cloned()
.unwrap_or_else(|| defaults(monitor.identity.clone()));
settings.enabled &= monitor.usable;
settings.enabled &= match layout {
OutputLayout::InternalOnly => monitor.internal,
OutputLayout::ExternalOnly => !monitor.internal,
OutputLayout::Extend | OutputLayout::Mirror => true,
};
if monitor.internal {
if let Some(enabled) = manual.internal {
settings.enabled = enabled && monitor.usable;
} else if manage_lid && lid_closed && external {
settings.enabled = false;
}
}
DesiredOutput {
key: monitor.key.clone(),
settings,
mirror_source: None,
}
})
.collect::<Vec<_>>();
if !outputs.iter().any(|output| output.settings.enabled) {
// Policy never disables the last usable display, even for an inapplicable exclusive layout.
if let Some((index, _)) = monitors
.iter()
.enumerate()
.filter(|(_, monitor)| monitor.usable)
.min_by_key(|(_, monitor)| (!monitor.internal, &monitor.key))
{
outputs[index].settings = defaults(monitors[index].identity.clone());
}
}
if layout == OutputLayout::Mirror {
let source = selected
.and_then(|profile| profile.mirror_source.as_ref())
.and_then(|selector| {
monitors
.iter()
.zip(&outputs)
.find(|(monitor, output)| monitor.matches(selector) && output.settings.enabled)
.map(|(_, output)| output.key.clone())
})
.or_else(|| {
outputs
.iter()
.filter(|output| output.settings.enabled)
.map(|output| output.key.clone())
.min()
});
if let Some(source) = source {
for output in &mut outputs {
if output.settings.enabled && output.key != source {
output.mirror_source = Some(source.clone());
}
}
}
}
DesiredOutputConfiguration {
profile: selected.map(|profile| profile.name.clone()),
outputs,
suspend: manage_lid && lid_closed && !external,
}
}
/// Restore actual settings only for surviving hardware; new monitors remain safe fallbacks.
pub(crate) fn restore_configuration(
previous: &DesiredOutputConfiguration,
monitors: &[Monitor],
) -> DesiredOutputConfiguration {
let mut restored = previous.clone();
restored
.outputs
.retain(|output| monitors.iter().any(|monitor| monitor.key == output.key));
for monitor in monitors {
if !restored.outputs.iter().any(|output| output.key == monitor.key) {
let mut settings = defaults(monitor.identity.clone());
settings.enabled = false;
restored.outputs.push(DesiredOutput {
key: monitor.key.clone(),
settings,
mirror_source: None,
});
}
}
let source_keys = restored
.outputs
.iter()
.filter(|output| output.settings.enabled)
.map(|output| output.key.clone())
.collect::<HashSet<_>>();
for output in &mut restored.outputs {
if output
.mirror_source
.as_ref()
.is_some_and(|source| !source_keys.contains(source))
{
output.mirror_source = None;
}
}
if !restored.outputs.iter().any(|output| output.settings.enabled) {
return select_profile(monitors, false, &[], &ManualOverride::default());
}
restored
}
/// A diff carries only affected connectors; removing a connector is an explicit disable.
pub(crate) fn changed_outputs<'a>(
old: &'a DesiredOutputConfiguration,
new: &'a DesiredOutputConfiguration,
) -> Vec<&'a str> {
let mut keys = HashSet::new();
old.outputs
.iter()
.chain(&new.outputs)
.filter_map(|output| {
let key = output.key.as_str();
if !keys.insert(key) {
return None;
}
let previous = old.outputs.iter().find(|candidate| candidate.key == key);
let next = new.outputs.iter().find(|candidate| candidate.key == key);
let same = match (previous, next) {
(Some(previous), Some(next)) => {
previous.settings.same_configuration(&next.settings) && previous.mirror_source == next.mirror_source
}
(None, None) => true,
_ => false,
};
(!same).then_some(key)
})
.collect()
}
#[derive(Default)]
pub(crate) struct Debouncer {
deadline: Option<std::time::Duration>,
}
impl Debouncer {
pub fn request(&mut self, now: std::time::Duration, active: bool) -> Option<std::time::Duration> {
self.deadline = active.then(|| now + std::time::Duration::from_millis(150));
self.deadline
}
pub fn clear(&mut self) {
self.deadline = None;
}
pub fn take_due(&mut self, now: std::time::Duration) -> bool {
if self.deadline.is_some_and(|deadline| deadline <= now) {
self.deadline = None;
true
} else {
false
}
}
}
pub(crate) struct Placement {
pub key: String,
pub size: (i32, i32),
pub previous: Option<[i32; 2]>,
}
/// Explicit positions win. Preserve an automatic output's old position when
/// it still fits; append new/conflicting outputs in stable hardware-key order.
pub(crate) fn arrange_positions(
desired: &mut DesiredOutputConfiguration,
placements: &[Placement],
) -> Result<(), String> {
let mut occupied = Vec::new();
let mut automatic = Vec::new();
for (index, output) in desired.outputs.iter().enumerate() {
if !output.settings.enabled || output.mirror_source.is_some() {
continue;
}
let placement = placements
.iter()
.find(|placement| placement.key == output.key)
.ok_or("missing output dimensions")?;
if let Some(position) = output.settings.position {
occupied.push((position[0], position[1], placement.size.0, placement.size.1));
} else {
automatic.push((index, placement));
}
}
automatic.sort_by(|(_, a), (_, b)| (a.previous.is_none(), &a.key).cmp(&(b.previous.is_none(), &b.key)));
for (index, placement) in automatic {
let fits = |position: [i32; 2]| {
let (x, y, w, h) = (
position[0] as i64,
position[1] as i64,
placement.size.0 as i64,
placement.size.1 as i64,
);
!occupied.iter().any(|&(ox, oy, ow, oh)| {
x < ox as i64 + ow as i64 && x + w > ox as i64 && y < oy as i64 + oh as i64 && y + h > oy as i64
})
};
let previous = placement.previous.filter(|position| fits(*position));
let position = if let Some(previous) = previous {
previous
} else {
let right = occupied
.iter()
.map(|&(x, _, w, _)| x as i64 + w as i64)
.max()
.unwrap_or(0)
.max(0);
let x = i32::try_from(right).map_err(|_| "automatic output position overflows")?;
x.checked_add(placement.size.0)
.ok_or("automatic output geometry overflows")?;
[x, 0]
};
desired.outputs[index].settings.position = Some(position);
occupied.push((position[0], position[1], placement.size.0, placement.size.1));
}
let source_positions = desired
.outputs
.iter()
.filter(|output| output.mirror_source.is_none())
.map(|output| (output.key.clone(), output.settings.position))
.collect::<std::collections::HashMap<_, _>>();
for output in &mut desired.outputs {
if let Some(source) = &output.mirror_source {
output.settings.position = source_positions.get(source).copied().flatten();
}
}
Ok(())
}
#[cfg(test)]
mod placement_tests {
use super::*;
#[test]
fn automatic_placement_is_stable_and_only_moves_overlapping_neighbors() {
let monitors = [
Monitor {
key: "a".into(),
connector: "a".into(),
identity: "a".into(),
aliases: vec![],
internal: true,
usable: true,
},
Monitor {
key: "b".into(),
connector: "b".into(),
identity: "b".into(),
aliases: vec![],
internal: false,
usable: true,
},
];
let mut desired = select_profile(&monitors, false, &[], &ManualOverride::default());
let placements = [
Placement {
key: "b".into(),
size: (100, 100),
previous: Some([100, 0]),
},
Placement {
key: "a".into(),
size: (100, 100),
previous: Some([0, 0]),
},
];
arrange_positions(&mut desired, &placements).unwrap();
assert_eq!(desired.outputs[0].settings.position, Some([0, 0]));
assert_eq!(desired.outputs[1].settings.position, Some([100, 0]));
let mut expanded = select_profile(&monitors, false, &[], &ManualOverride::default());
let placements = [
Placement {
key: "a".into(),
size: (150, 100),
previous: Some([0, 0]),
},
Placement {
key: "b".into(),
size: (100, 100),
previous: Some([100, 0]),
},
];
arrange_positions(&mut expanded, &placements).unwrap();
assert_eq!(expanded.outputs[0].settings.position, Some([0, 0]));
assert_eq!(expanded.outputs[1].settings.position, Some([150, 0]));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn monitor(name: &str, internal: bool) -> Monitor {
Monitor {
key: name.into(),
connector: name.into(),
identity: format!("edid:{name}"),
aliases: vec![],
internal,
usable: true,
}
}
fn profile(name: &str, outputs: &[&str], layout: OutputLayout) -> OutputProfile {
OutputProfile {
name: name.into(),
outputs: outputs.iter().map(|name| defaults((*name).into())).collect(),
layout,
confirm_timeout: 15,
lid_policy: LidPolicy::DockOrSuspend,
lid_closed: None,
mirror_source: None,
}
}
#[test]
fn specificity_and_config_order() {
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let profiles = [
profile("mobile", &["eDP-1"], OutputLayout::Extend),
profile("desk", &["eDP-1", "DP-1"], OutputLayout::Extend),
profile("tie", &["DP-1", "eDP-1"], OutputLayout::Extend),
];
assert_eq!(
select_profile(&monitors, false, &profiles, &ManualOverride::default())
.profile
.as_deref(),
Some("desk")
);
}
#[test]
fn layouts_unknown_outputs_and_safe_fallback() {
let monitors = [
monitor("eDP-1", true),
monitor("DP-1", false),
monitor("HDMI-A-1", false),
];
for (layout, count) in [
(OutputLayout::InternalOnly, 1),
(OutputLayout::ExternalOnly, 2),
(OutputLayout::Extend, 3),
(OutputLayout::Mirror, 3),
] {
let desired = select_profile(
&monitors,
false,
&[profile("test", &["eDP-1"], layout)],
&ManualOverride::default(),
);
assert_eq!(
desired.outputs.iter().filter(|output| output.settings.enabled).count(),
count
);
assert_eq!(
desired
.outputs
.iter()
.filter(|output| output.mirror_source.is_some())
.count(),
if layout == OutputLayout::Mirror { 2 } else { 0 }
);
}
let missing = [profile("absent", &["DP-9"], OutputLayout::ExternalOnly)];
let fallback = select_profile(&monitors, false, &missing, &ManualOverride::default());
assert_eq!(fallback.profile, None);
assert!(fallback.outputs.iter().all(|output| output.settings.enabled));
let internal = &monitors[..1];
let mut disabled = profile("disabled", &["eDP-1"], OutputLayout::Extend);
disabled.outputs[0].enabled = false;
assert!(
select_profile(internal, false, &[disabled], &ManualOverride::default()).outputs[0]
.settings
.enabled
);
}
#[test]
fn override_expires_only_on_material_change_or_invalid_reload() {
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let profiles = [profile("mobile", &["eDP-1"], OutputLayout::InternalOnly)];
let mut manual = ManualOverride::default();
manual.observe(&monitors, false, &profiles);
manual.profile = Some("mobile".into());
manual.observe(&monitors, false, &profiles);
assert_eq!(manual.profile.as_deref(), Some("mobile"));
manual.observe(&monitors, true, &profiles);
assert_eq!(manual.profile, None);
manual.profile = Some("mobile".into());
manual.observe(&monitors, true, &[]);
assert_eq!(manual.profile, None);
}
#[test]
fn lid_dock_undock_and_internal_toggle_never_disable_last_output() {
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let docked = select_profile(&monitors, true, &[], &ManualOverride::default());
assert!(!docked.suspend);
assert!(!docked.outputs[0].settings.enabled);
assert!(docked.outputs[1].settings.enabled);
let manual = ManualOverride {
internal: Some(false),
..ManualOverride::default()
};
let undocked = select_profile(&monitors[..1], true, &[], &manual);
assert!(undocked.suspend);
assert!(undocked.outputs[0].settings.enabled);
}
#[test]
fn desired_diff_is_local() {
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let old = select_profile(&monitors, false, &[], &ManualOverride::default());
assert!(changed_outputs(&old, &old).is_empty());
let mut new = old.clone();
new.outputs[1].settings.scale = 1.5;
assert_eq!(changed_outputs(&old, &new), vec!["DP-1"]);
}
#[test]
fn mirror_removal_and_extend_restore_single_ownership_plan() {
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let mut mirror = profile("mirror", &["eDP-1", "DP-1"], OutputLayout::Mirror);
mirror.mirror_source = Some("eDP-1".into());
let old = select_profile(&monitors, false, &[mirror.clone()], &ManualOverride::default());
assert_eq!(old.outputs[1].mirror_source.as_deref(), Some("eDP-1"));
let removed = select_profile(&monitors[..1], false, &[mirror], &ManualOverride::default());
assert!(removed.outputs[0].mirror_source.is_none());
assert!(removed.outputs[0].settings.enabled);
let extended = select_profile(&monitors, false, &[], &ManualOverride::default());
assert!(extended.outputs.iter().all(|output| output.mirror_source.is_none()));
assert_eq!(changed_outputs(&old, &extended), vec!["DP-1"]);
}
#[test]
fn optional_outputs_and_lid_specific_profiles() {
let monitors = [monitor("eDP-1", true)];
let mut mobile = profile("mobile", &["eDP-1", "DP-1"], OutputLayout::Extend);
mobile.outputs[1].required = false;
mobile.lid_closed = Some(false);
assert_eq!(
select_profile(&monitors, false, &[mobile.clone()], &ManualOverride::default())
.profile
.as_deref(),
Some("mobile")
);
assert_eq!(
select_profile(&monitors, true, &[mobile], &ManualOverride::default()).profile,
None
);
}
#[test]
fn swapping_monitor_on_same_connector_expires_override() {
let mut monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let profiles = [profile("mobile", &["eDP-1"], OutputLayout::InternalOnly)];
let mut manual = ManualOverride::default();
manual.observe(&monitors, false, &profiles);
manual.profile = Some("mobile".into());
monitors[1].identity = "edid:new-monitor".into();
manual.observe(&monitors, false, &profiles);
assert!(manual.profile.is_none());
}
#[test]
fn restoration_survives_disconnect_and_keeps_new_monitor_as_emergency_fallback() {
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
let previous = select_profile(&monitors, false, &[], &ManualOverride::default());
let restored = restore_configuration(&previous, &monitors[..1]);
assert_eq!(restored.outputs.len(), 1);
assert!(restored.outputs[0].settings.enabled);
let unknown = [monitor("HDMI-A-2", false)];
assert!(restore_configuration(&previous, &unknown).outputs[0].settings.enabled);
}
}
#[cfg(test)]
mod debounce_tests {
use super::*;
use std::time::Duration;
#[test]
fn a_dock_burst_produces_one_reconciliation_after_last_event() {
let mut debounce = Debouncer::default();
for time in [0, 40, 110] {
debounce.request(Duration::from_millis(time), true);
}
assert!(!debounce.take_due(Duration::from_millis(150)));
assert!(!debounce.take_due(Duration::from_millis(259)));
assert!(debounce.take_due(Duration::from_millis(260)));
assert!(!debounce.take_due(Duration::from_millis(400)));
}
#[test]
fn inactivity_cancels_deadlines_and_resume_uses_fresh_state() {
let mut debounce = Debouncer::default();
debounce.request(Duration::ZERO, true);
assert_eq!(debounce.request(Duration::from_millis(30), false), None);
assert!(!debounce.take_due(Duration::from_secs(10)));
debounce.request(Duration::from_secs(10), true);
assert!(debounce.take_due(Duration::from_millis(10150)));
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Device transaction sequencing, independent of DRM so failure paths are deterministic tests.
pub(crate) trait HardwareTransaction {
type Key: Copy + Eq;
fn stage(&mut self, key: Self::Key) -> Result<(), String>;
fn commit(&mut self, key: Self::Key) -> Result<(), String>;
fn disable(&mut self, key: Self::Key) -> Result<(), String>;
fn abort_staged(&mut self);
fn restore(&mut self, key: Self::Key) -> Result<(), String>;
}
#[derive(Debug)]
pub(crate) struct Failure<K> {
pub error: String,
pub unavailable: Vec<K>,
}
/// Caller must validate the *complete* device plan first. No logical publication
/// is permitted until success. Unavailable rollback results must be retired.
pub(crate) fn apply<T: HardwareTransaction>(
driver: &mut T,
changes: &[T::Key],
removals: &[T::Key],
) -> Result<(), Failure<T::Key>> {
let mut touched = Vec::new();
let result = (|| {
for key in changes {
touched.push(*key);
driver.stage(*key)?;
}
for key in changes {
driver.commit(*key)?;
}
for key in removals {
touched.push(*key);
driver.disable(*key)?;
}
Ok::<_, String>(())
})();
if let Err(error) = result {
driver.abort_staged();
let unavailable = touched
.into_iter()
.filter(|key| driver.restore(*key).is_err())
.collect();
return Err(Failure { error, unavailable });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
struct Fake {
active: BTreeSet<u8>,
previous: BTreeSet<u8>,
events: Vec<(&'static str, u8)>,
fail: Option<(&'static str, u8)>,
restore_fails: bool,
}
impl HardwareTransaction for Fake {
type Key = u8;
fn stage(&mut self, key: u8) -> Result<(), String> {
self.event("stage", key)
}
fn commit(&mut self, key: u8) -> Result<(), String> {
self.event("commit", key)?;
self.active.insert(key);
Ok(())
}
fn disable(&mut self, key: u8) -> Result<(), String> {
self.event("disable", key)?;
self.active.remove(&key);
Ok(())
}
fn abort_staged(&mut self) {
self.events.push(("abort", 0));
}
fn restore(&mut self, key: u8) -> Result<(), String> {
self.events.push(("restore", key));
if self.restore_fails {
self.active.remove(&key);
return Err("lost".into());
}
if self.previous.contains(&key) {
self.active.insert(key);
} else {
self.active.remove(&key);
}
Ok(())
}
}
impl Fake {
fn new() -> Self {
Self {
active: BTreeSet::from([1]),
previous: BTreeSet::from([1]),
events: vec![],
fail: None,
restore_fails: false,
}
}
fn event(&mut self, operation: &'static str, key: u8) -> Result<(), String> {
self.events.push((operation, key));
if self.fail == Some((operation, key)) {
Err("injected DRM failure".into())
} else {
Ok(())
}
}
}
#[test]
fn hotplug_then_remove_stages_before_applying_and_preserves_destination() {
let mut fake = Fake::new();
apply(&mut fake, &[2, 3], &[]).unwrap();
assert_eq!(&fake.events[..2], &[("stage", 2), ("stage", 3)]);
assert_eq!(fake.active, BTreeSet::from([1, 2, 3]));
fake.previous = fake.active.clone();
apply(&mut fake, &[], &[2, 3]).unwrap();
assert_eq!(fake.active, BTreeSet::from([1]));
}
#[test]
fn stage_commit_and_disable_failures_restore_known_good() {
for failure in [("stage", 3), ("commit", 3), ("disable", 1)] {
let mut fake = Fake::new();
fake.fail = Some(failure);
let error = apply(&mut fake, &[2, 3], &[1]).unwrap_err();
assert!(error.unavailable.is_empty());
assert_eq!(fake.active, fake.previous);
assert!(fake.events.iter().any(|(event, _)| *event == "abort"));
}
}
#[test]
fn failed_restoration_reports_outputs_that_must_not_remain_logically_live() {
let mut fake = Fake::new();
fake.fail = Some(("commit", 1));
fake.restore_fails = true;
let failure = apply(&mut fake, &[1], &[]).unwrap_err();
assert_eq!(failure.unavailable, vec![1]);
assert!(!fake.active.contains(&1));
}
}
+41
View File
@@ -250,6 +250,47 @@ mod tests {
assert!(!state.inactive_dim.enabled); assert!(!state.inactive_dim.enabled);
} }
#[test]
fn invalid_monitor_reload_keeps_last_good_runtime_configuration() {
const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
use std::os::unix::fs::PermissionsExt;
let directory = Directory::new();
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"reload::tests::invalid_monitor_reload_keeps_last_good_runtime_configuration",
"--nocapture",
])
.env(CHILD, "1")
.env("XDG_RUNTIME_DIR", &directory.0)
.env("XDG_CONFIG_HOME", &directory.0)
.env_remove("FERESE_SOCKET")
.output()
.unwrap();
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
return;
}
let directory = Directory::new();
let source = "output-profile mobile { output eDP-1 scale=1.75; }";
let (_, runtime, _) = crate::theme::prepare(source, &directory.0).unwrap();
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let display = smithay::reexports::wayland_server::Display::new().unwrap();
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
state.config_source = Some(source.into());
let accepted = state.output_profiles.clone();
for invalid in [
"output-profile bad { output eDP-1 scale=0; }",
"output-profile bad layout=\"mirror\" { output eDP-1; }",
"output-profile mobile { output eDP-1; }\noutput-profile mobile { output DP-1; }",
] {
assert!(state.reload_config_source(invalid.into()).is_err());
assert_eq!(state.output_profiles, accepted);
assert_eq!(state.config_source.as_deref(), Some(source));
}
}
#[test] #[test]
fn runtime_validation_rejects_bad_edits_before_application() { fn runtime_validation_rejects_bad_edits_before_application() {
for source in [ for source in [
+51 -3
View File
@@ -145,6 +145,18 @@ impl Lock {
} }
impl Ferese { impl Ferese {
fn lock_outputs_protected(&self) -> bool {
if let Some(backend) = self.direct_backend.as_ref() {
// Reconciliation temporarily removes devices while staging changes.
// Never interpret that partial inventory as protected displays.
if backend.reconciling() {
return false;
}
return self.session_lock.ready_for_idle(backend.physical_outputs());
}
self.session_lock.ready_for_idle(self.space.outputs())
}
pub(crate) fn lock_input_activity(&mut self) { pub(crate) fn lock_input_activity(&mut self) {
if self.session_lock.activity(Instant::now()) { if self.session_lock.activity(Instant::now()) {
crate::backends::direct::wake_locked_outputs(self); crate::backends::direct::wake_locked_outputs(self);
@@ -162,7 +174,7 @@ impl Ferese {
if let Some(token) = self.session_lock.idle_timer.take() { if let Some(token) = self.session_lock.idle_timer.take() {
self.loop_handle.remove(token); self.loop_handle.remove(token);
} }
if !self.session_lock.ready_for_idle(self.space.outputs()) { if !self.lock_outputs_protected() {
return; return;
} }
@@ -189,7 +201,7 @@ impl Ferese {
} }
fn update_lock_idle(&mut self, now: Instant) { fn update_lock_idle(&mut self, now: Instant) {
if !self.session_lock.ready_for_idle(self.space.outputs()) { if !self.lock_outputs_protected() {
return; return;
} }
let fade_duration = self.animation_duration(Duration::from_millis(500)); let fade_duration = self.animation_duration(Duration::from_millis(500));
@@ -257,7 +269,7 @@ impl Ferese {
pub(crate) fn confirm_lock_if_ready(&mut self) { pub(crate) fn confirm_lock_if_ready(&mut self) {
// With no outputs there is no visible content to protect. Re-evaluate // With no outputs there is no visible content to protect. Re-evaluate
// here after acquisition and output removal as well as presentation. // here after acquisition and output removal as well as presentation.
if !self.session_lock.ready_for_idle(self.space.outputs()) { if !self.lock_outputs_protected() {
return; return;
} }
@@ -629,6 +641,42 @@ mod tests {
); );
} }
#[test]
fn mirror_must_present_protected_frame_before_lock_is_ready() {
let make_output = |name: &str| {
Output::new(
name.into(),
smithay::output::PhysicalProperties {
size: (0, 0).into(),
subpixel: smithay::output::Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
)
};
let source = make_output("source");
let mirror = make_output("mirror");
let mut lock = Lock {
lifecycle: Lifecycle::Orphaned,
..Lock::default()
};
lock.presented.insert(source.clone());
let displays = [&source, &mirror];
assert!(
!lock.ready_for_idle(displays.into_iter()),
"failed mirror rendering must block confirmation"
);
lock.presented.insert(mirror.clone());
assert!(lock.ready_for_idle(displays.into_iter()));
lock.output_added(&mirror);
assert!(
!lock.ready_for_idle(displays.into_iter()),
"reconfiguration invalidates physical protection"
);
lock.output_removed(&mirror);
assert!(lock.ready_for_idle(std::iter::once(&source)));
}
#[test] #[test]
fn protected_fallback_frames_allow_idle_after_early_locker_crash() { fn protected_fallback_frames_allow_idle_after_early_locker_crash() {
let output = Output::new( let output = Output::new(
+8
View File
@@ -263,6 +263,14 @@ impl Ferese {
} }
pub(crate) fn send_shell_snapshots(&mut self) { pub(crate) fn send_shell_snapshots(&mut self) {
if self
.direct_backend
.as_ref()
.is_some_and(|backend| backend.reconciling())
{
return;
}
self.shell_resources.retain(|resource| resource.upgrade().is_ok()); self.shell_resources.retain(|resource| resource.upgrade().is_ok());
if self.shell_resources.is_empty() { if self.shell_resources.is_empty() {
self.last_shell_snapshot = None; self.last_shell_snapshot = None;
+8
View File
@@ -70,6 +70,14 @@ impl Ferese {
} }
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>) { fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>) {
if self
.direct_backend
.as_ref()
.is_some_and(|backend| backend.reconciling())
{
return;
}
if let Some(outputs) = redraw_outputs.as_mut() { if let Some(outputs) = redraw_outputs.as_mut() {
outputs.extend( outputs.extend(
self.space self.space
+92 -6
View File
@@ -7,6 +7,24 @@ impl Ferese {
} }
} }
pub(crate) fn output_by_identity(&self, identity: &str) -> Option<&Output> {
self.output_identity_ids
.get(identity)
.and_then(|id| self.outputs_by_id.get(id))
}
pub(crate) fn persistent_output_id(&self, identity: &str) -> Option<OutputId> {
self.output_identity_ids.get(identity).copied()
}
pub(crate) fn ensure_output_identity(&mut self, identity: &str) -> OutputId {
*self.output_identity_ids.entry(identity.to_owned()).or_insert_with(|| {
let id = OutputId(self.next_output_id);
self.next_output_id = self.next_output_id.checked_add(1).expect("output ID space exhausted");
id
})
}
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() { if self.output_ids.is_empty() {
self.reset_animation_clock(); self.reset_animation_clock();
@@ -20,20 +38,40 @@ impl Ferese {
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.output_identity_ids.entry(identity).or_insert_with(|| { let output_id = self.ensure_output_identity(&identity);
let id = OutputId(self.next_output_id);
self.next_output_id = self.next_output_id.saturating_add(1);
id
});
let fallback_workspace = self let fallback_workspace = self
.workspaces .workspaces
.iter() .iter()
.filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none()) .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)
})
.map(|workspace| workspace.id) .map(|workspace| workspace.id)
.min_by_key(|id| id.0) .min_by_key(|id| id.0)
.unwrap_or_else(|| self.workspaces.create_workspace()); .unwrap_or_else(|| self.workspaces.create_workspace());
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
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); let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
match registration { match registration {
@@ -41,6 +79,27 @@ impl Ferese {
self.output_ids.insert(output.clone(), output_id); self.output_ids.insert(output.clone(), output_id);
self.outputs_by_id.insert(output_id, output.clone()); self.outputs_by_id.insert(output_id, output.clone());
self.output_names.insert(output_id, output.name()); 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(); self.restore_output_focus();
let visible = self.visible_workspace_ids(); let visible = self.visible_workspace_ids();
self.reconcile_workspaces(&visible); self.reconcile_workspaces(&visible);
@@ -80,8 +139,35 @@ impl Ferese {
self.confirm_lock_if_ready(); self.confirm_lock_if_ready();
self.refresh_lock_idle_policy(); self.refresh_lock_idle_policy();
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) { match self.output_workspaces.disconnect(output_id) {
Ok(Some(target)) => { 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) { if let Some(workspace) = self.output_workspaces.active_workspace(target) {
self.activate_output_workspace(target, workspace); self.activate_output_workspace(target, workspace);
} }
+150 -5
View File
@@ -89,9 +89,11 @@ struct EvacuationRecord {
pub struct OutputWorkspaceMap { pub struct OutputWorkspaceMap {
outputs: BTreeMap<OutputId, OutputState>, outputs: BTreeMap<OutputId, OutputState>,
assignments: HashMap<WorkspaceId, OutputId>, assignments: HashMap<WorkspaceId, OutputId>,
homes: HashMap<WorkspaceId, OutputId>,
revisions: HashMap<WorkspaceId, u64>, revisions: HashMap<WorkspaceId, u64>,
evacuations: HashMap<OutputId, EvacuationRecord>, evacuations: HashMap<OutputId, EvacuationRecord>,
focused: Option<OutputId>, focused: Option<OutputId>,
orphaned_focus: Option<WorkspaceId>,
} }
impl OutputWorkspaceMap { impl OutputWorkspaceMap {
@@ -118,6 +120,10 @@ impl OutputWorkspaceMap {
self.assignments.get(&workspace).copied() self.assignments.get(&workspace).copied()
} }
pub fn home_output(&self, workspace: WorkspaceId) -> Option<OutputId> {
self.homes.get(&workspace).copied()
}
pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool { pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool {
if self.outputs.values().any(|output| output.active == workspace) { if self.outputs.values().any(|output| output.active == workspace) {
return false; return false;
@@ -131,6 +137,10 @@ impl OutputWorkspaceMap {
} }
} }
self.revisions.remove(&workspace); self.revisions.remove(&workspace);
self.homes.remove(&workspace);
if self.orphaned_focus == Some(workspace) {
self.orphaned_focus = None;
}
self.evacuations.retain(|_, record| { self.evacuations.retain(|_, record| {
record.workspaces.retain(|entry| entry.workspace != workspace); record.workspaces.retain(|entry| entry.workspace != workspace);
if record.active == workspace { if record.active == workspace {
@@ -150,6 +160,10 @@ impl OutputWorkspaceMap {
self.outputs.keys().copied() self.outputs.keys().copied()
} }
pub fn geometry(&self, output: OutputId) -> Option<OutputGeometry> {
self.outputs.get(&output).map(|state| state.geometry)
}
pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) { pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) {
if let Some(state) = self.outputs.get_mut(&output) { if let Some(state) = self.outputs.get_mut(&output) {
state.geometry = geometry; state.geometry = geometry;
@@ -170,15 +184,19 @@ impl OutputWorkspaceMap {
let mut reclaimed = Vec::new(); let mut reclaimed = Vec::new();
if let Some(record) = &record { if let Some(record) = &record {
for evacuated in &record.workspaces { for evacuated in &record.workspaces {
let assignment_matches = self.assignments.get(&evacuated.workspace).copied() == evacuated.target; 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; let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
if assignment_matches && revision_matches { if assignment_matches && revision_matches {
// Cleanup may have removed every other workspace while this // Cleanup may have removed every other workspace while this
// monitor was disconnected. Never strand the donor output; // monitor was disconnected. Never strand the donor output;
// the reconnecting monitor can use its fresh fallback. // the reconnecting monitor can use its fresh fallback.
if evacuated if target
.target
.and_then(|target| self.outputs.get(&target)) .and_then(|target| self.outputs.get(&target))
.is_some_and(|state| { .is_some_and(|state| {
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace) state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
@@ -187,7 +205,7 @@ impl OutputWorkspaceMap {
continue; continue;
} }
if let Some(target) = evacuated.target if let Some(target) = target
&& let Some(target_state) = self.outputs.get_mut(&target) && let Some(target_state) = self.outputs.get_mut(&target)
{ {
target_state.workspaces.remove(&evacuated.workspace); target_state.workspaces.remove(&evacuated.workspace);
@@ -214,7 +232,7 @@ impl OutputWorkspaceMap {
.as_ref() .as_ref()
.and_then(|record| reclaimed.contains(&record.active).then_some(record.active)) .and_then(|record| reclaimed.contains(&record.active).then_some(record.active))
.or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0)); .or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0));
let active = match active { let mut active = match active {
Some(active) => active, Some(active) => active,
None => { None => {
if let Some(owner) = self.assignments.get(&fallback_workspace).copied() { if let Some(owner) = self.assignments.get(&fallback_workspace).copied() {
@@ -224,11 +242,33 @@ impl OutputWorkspaceMap {
}); });
} }
self.assignments.insert(fallback_workspace, output); self.assignments.insert(fallback_workspace, output);
self.homes.insert(fallback_workspace, output);
reclaimed.push(fallback_workspace); reclaimed.push(fallback_workspace);
fallback_workspace fallback_workspace
} }
}; };
if self.outputs.is_empty() {
// A replacement may have to reuse the final CRTC, or every cable
// may be unplugged. Attach unowned evacuated workspaces without
// changing their homes, so every window remains reachable.
let orphaned = self
.homes
.keys()
.filter(|workspace| !self.assignments.contains_key(workspace))
.copied()
.collect::<Vec<_>>();
for workspace in orphaned {
self.assignments.insert(workspace, output);
reclaimed.push(workspace);
}
if let Some(focused) = self.orphaned_focus.take()
&& reclaimed.contains(&focused)
{
active = focused;
}
}
self.outputs.insert( self.outputs.insert(
output, output,
OutputState { OutputState {
@@ -266,6 +306,9 @@ impl OutputWorkspaceMap {
if removed_had_focus { if removed_had_focus {
self.focused = target; self.focused = target;
if target.is_none() {
self.orphaned_focus = Some(removed.active);
}
if let Some(target) = target { if let Some(target) = target {
self.outputs self.outputs
.get_mut(&target) .get_mut(&target)
@@ -327,6 +370,7 @@ impl OutputWorkspaceMap {
state.activate(workspace); state.activate(workspace);
self.focused = Some(output); self.focused = Some(output);
if reassigned { if reassigned {
self.homes.insert(workspace, output);
self.bump_revision(workspace); self.bump_revision(workspace);
} }
@@ -357,6 +401,7 @@ impl OutputWorkspaceMap {
} }
self.assignments.insert(workspace, output); self.assignments.insert(workspace, output);
self.homes.insert(workspace, output);
self.outputs self.outputs
.get_mut(&output) .get_mut(&output)
.expect("output was checked above") .expect("output was checked above")
@@ -368,6 +413,48 @@ impl OutputWorkspaceMap {
Ok(output) Ok(output)
} }
/// Explicit reassignment changes the home, unlike merely focusing an evacuated workspace.
/// The caller supplies a fresh workspace if moving the donor's final workspace.
pub fn reassign_workspace(
&mut self,
output: OutputId,
workspace: WorkspaceId,
donor_fallback: WorkspaceId,
) -> Result<(), OutputError> {
if !self.outputs.contains_key(&output) {
return Err(OutputError::UnknownOutput(output));
}
if let Some(owner) = self.assignments.get(&workspace).copied()
&& owner != output
{
let donor = self.outputs.get_mut(&owner).expect("assigned owner is connected");
if donor.workspaces.len() == 1 {
if let Some(assigned) = self.assignments.get(&donor_fallback) {
return Err(OutputError::WorkspaceAlreadyAssigned {
workspace: donor_fallback,
output: *assigned,
});
}
donor.workspaces.insert(donor_fallback);
self.assignments.insert(donor_fallback, owner);
self.homes.insert(donor_fallback, owner);
}
donor.workspaces.remove(&workspace);
if donor.previous == Some(workspace) {
donor.previous = None;
}
if donor.active == workspace {
donor.active = *donor.workspaces.first().expect("donor retains a workspace");
}
}
self.assignments.insert(workspace, output);
self.homes.insert(workspace, output);
self.outputs.get_mut(&output).unwrap().workspaces.insert(workspace);
self.bump_revision(workspace);
debug_assert!(self.validate());
Ok(())
}
fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> { fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> {
if let Some(focused) = self.focused if let Some(focused) = self.focused
&& focused != removed && focused != removed
@@ -705,4 +792,62 @@ mod tests {
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1))); assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1))); assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
} }
#[test]
fn two_hop_evacuation_restores_original_home() {
let mut outputs = OutputWorkspaceMap::default();
for id in 1..=3 {
outputs
.connect(OutputId(id), geometry(id as i32 * 1920), WorkspaceId(id))
.unwrap();
}
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
outputs.connect(OutputId(1), geometry(0), WorkspaceId(4)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert!(outputs.active_workspace(OutputId(3)).is_some());
}
#[test]
fn explicit_reassignment_after_evacuation_prevents_home_steal_back() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(9))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(3)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(2)));
}
#[test]
fn reassigning_last_workspace_supplies_donor_fallback() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(3)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
}
#[test]
fn final_output_replacement_keeps_all_workspaces_reachable_without_changing_homes() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.assign_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs.switch_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs.connect(OutputId(3), geometry(0), WorkspaceId(3)).unwrap();
assert_eq!(outputs.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
outputs.connect(OutputId(1), geometry(1920), WorkspaceId(4)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
}
} }
+100 -12
View File
@@ -445,14 +445,25 @@ window-rule app-id="dev.ferese.Settings" floating=#true
## Displays ## Displays
`output-profile` has a required unique nonempty `name` and one or more Each `output-profile` needs a unique, nonempty `name` and at least one `output`
`output-profile → output` entries. The first profile whose listed monitors entry. A profile matches when every required monitor is connected and its
are connected wins. Find connector names and persistent identities using optional `lid-closed` condition matches. The profile with the most required
`feresectl get-outputs`. monitors wins; ties use config order. If none matches, Ferese extends across all
usable connected monitors. Find selectors with `feresectl outputs`.
| `output-profile` key | Type | Default |
| --- | --- | --- |
| `name` | unique nonempty string | required |
| `layout` | `internal-only`, `external-only`, `extend`, `mirror` | `extend` |
| `lid-closed` | boolean | either lid state |
| `mirror-source` | selector of an enabled output in this profile | first enabled hardware key |
| `lid-policy` | `dock-or-suspend`, `ignore` | `dock-or-suspend` |
| `confirm-timeout` | seconds; `0` disables confirmation | `15` |
| `output-profile → output` key | Type | Default | | `output-profile → output` key | Type | Default |
| --- | --- | --- | | --- | --- | --- |
| `match` | nonempty connector or persistent identity string | required; unique within profile | | `match` | nonempty connector or persistent identity string | required; unique within profile |
| `required` | boolean | `true`; set `false` for an optional monitor |
| `enabled` | boolean | `true` | | `enabled` | boolean | `true` |
| `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode | | `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode |
| `auto-refresh` | boolean | `false` | | `auto-refresh` | boolean | `false` |
@@ -461,8 +472,68 @@ are connected wins. Find connector names and persistent identities using
| `position` | `[integer, integer]` | Automatic horizontal placement | | `position` | `[integer, integer]` | Automatic horizontal placement |
Transforms: `normal`, `rotate-90`, `rotate-180`, `rotate-270`, `flipped`, Transforms: `normal`, `rotate-90`, `rotate-180`, `rotate-270`, `flipped`,
`flipped-90`, `flipped-180`, `flipped-270`. Unspecified connected displays stay `flipped-90`, `flipped-180`, `flipped-270`. In extend and mirror profiles,
enabled with defaults. Disabling every usable output is rejected. unlisted monitors stay enabled with defaults. Internal-only and external-only
filter by connector type. A layout that would disable the final usable display
falls back to a working display. Profiles that explicitly disable all their
listed outputs are invalid.
Monitor identities use EDID manufacturer, product/model and serial, rather than
a hash of the entire EDID. Without a usable serial, the identity includes the
GPU's sysfs hardware path and connector. For duplicate serials, Ferese adds the
same path and connector to distinguish the monitors. If both arrive together,
both identities include those details. If the second arrives later, the first
keeps its identity and only the second gets the path and connector. Ferese
remembers that choice for the compositor session. Without serials, Ferese
identifies monitors by port and cannot tell them apart if you swap their ports.
Connector names and old `drm-edid:` selectors still work. Ambiguous or
overlapping selectors are rejected during live reload. A disconnected identity
can remain in config; it prevents matching only when its entry has
`required=#true`.
```kdl
output-profile "desk" layout="external-only" {
output "DP-1" mode="2560x1440@60" scale=1.25
output "eDP-1" required=#false enabled=#false
}
output-profile "mobile" layout="internal-only" {
output "eDP-1" scale=1.75
}
output-profile "presentation" layout="mirror" mirror-source="eDP-1" {
output "eDP-1" scale=1.75
output "HDMI-A-1" mode="1920x1080@60"
}
```
Mirror uses the source's logical size, workspace, layers and pointer. Targets
own no workspaces or separate pointer region. Ferese fits the complete source
into each target's transformed physical mode, preserving aspect ratio and
letterboxing instead of cropping. Target scale does not resize the source
layout. Each target has its own hardware cadence; only the source releases
client frame callbacks and presentation feedback. Mirrored monitors are listed
by `outputs`, but expose one logical output to clients and the shell. If a required mirror target disconnects, Ferese selects another matching
profile or extends across all usable connected monitors. Leaving mirror
restores evacuated workspaces.
```sh
feresectl outputs
feresectl output-profiles
feresectl output-profile presentation
feresectl output-confirm
feresectl output-profile auto
feresectl output-internal off
feresectl output-revert
```
A manual choice overrides automatic selection until the monitor set or lid
state changes. Reload keeps it only if its profile still exists and matches.
Manual changes revert after `confirm-timeout` seconds unless confirmed;
`output-revert` restores immediately. Reverting restores the last confirmed modes,
scales, transforms and positions, even if you make several changes before
confirming. The timeout still applies if the client that made the change exits. Automatic hotplug/lid changes never wait for confirmation.
Disabling the last usable internal panel is rejected.
```kdl ```kdl
output-profile name="docked" { output-profile name="docked" {
@@ -486,9 +557,10 @@ changes. Unavailable battery data restores normal refresh. If no matching 60 Hz
mode exists, normal refresh is retained. Rejected DRM mode changes retain the mode exists, normal refresh is retained. Rejected DRM mode changes retain the
current output. A refresh-rate change can briefly blank the display (about one current output. A refresh-rate change can briefly blank the display (about one
second on some panels), including automatic changes below 30%, recovery at 35%, second on some panels), including automatic changes below 30%, recovery at 35%,
and restoration on AC. Session activation only re-evaluates the power policy; and restoration on AC. Session activation refreshes lid and connector state
an unchanged policy does not trigger an additional rescan. Connector changes before rendering; unchanged outputs retain their handles and workspaces.
continue to use the existing hotplug handling. Hotplug bursts settle for 150 ms before one reconciliation. Ferese records changes that arrive while the session is inactive and applies
them on activation.
```kdl ```kdl
output-profile laptop { output-profile laptop {
@@ -496,9 +568,25 @@ output-profile laptop {
} }
``` ```
With a usable external display, closing the lid disables internal panels. With a usable external display, closing the lid disables internal panels after
Workspaces move to a remaining display and return when their original output external activation succeeds. Without one, Ferese asks logind to suspend,
returns. Ferese does not change the system's suspend policy. respecting sleep inhibitors. `lid-policy="ignore"` disables this behavior for
the selected profile. Resume reads lid state from logind, with ACPI fallback,
before reconciling fresh hardware. Ferese releases its logind lid inhibitor on
session pause, so logind handles the lid while another session or TTY is active.
Workspaces keep their home output and return when it reconnects, unless you
reassigned or deleted them after evacuation. Floating
windows are remapped and clamped to the surviving display.
On atomic DRM, Ferese tests the complete configuration for each GPU before
changing it.
Smithay performs the real commits; logical changes follow successful hardware
application. Failed commits attempt restoration, and outputs that cannot be
restored are removed from logical state. Each GPU commits separately. If a later GPU fails, Ferese attempts to restore
the earlier GPUs. Legacy KMS lacks atomic
test-only validation. Hardware failures can still prevent every monitor from
working; Ferese tries preferred modes to recover a display instead of
intentionally turning off the last working one.
## Status controls ## Status controls
+3 -2
View File
@@ -14,6 +14,7 @@ Keep another desktop available for direct-session recovery.
## Test guides ## Test guides
- [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.
- [Performance measurements](performance-baseline.md): sampling and historical results. - [Performance measurements](performance-baseline.md): sampling and historical results.
@@ -88,8 +89,8 @@ have a three-second deadline so a stalled bus cannot block recovery indefinitely
When a device fails, Ferese removes its output globals and retries with exponential When a device fails, Ferese removes its output globals and retries with exponential
backoff capped at 32 seconds. Working devices remain usable. Known hardware can return backoff capped at 32 seconds. Working devices remain usable. Known hardware can return
under a different `cardN` path. This does not expand support to previously under a different `cardN` path. Reconciliation also discovers newly connected
unmanaged secondary GPUs. Connected-output reports mark a mode active only when GPUs. Connected-output reports mark a mode active only when
output creation succeeded. Lock ownership is retained throughout recovery. output creation succeeded. Lock ownership is retained throughout recovery.
Run the ordering and private D-Bus checks with: Run the ordering and private D-Bus checks with:
+86
View File
@@ -0,0 +1,86 @@
# Output management tests
Run these on a direct DRM session. Nested mode cannot exercise KMS commits,
physical hotplug, logind lid policy or cross-GPU restoration. Do not use nested
results as evidence that a driver accepts the atomic request.
Build and run the policy/state tests first:
```sh
cargo test --release -p ferese -p ferese-core -p feresectl -- --test-threads=1
cargo clippy --release -p ferese -p ferese-core -p feresectl --all-targets -- -D warnings
```
Record `feresectl outputs` and `feresectl output-profiles` before each change.
Check `identity`, `connected`, `requested_enabled`, `applied_enabled`, mode,
scale, transform, position, profile, mirror source and configuration error.
Manual changes normally need `feresectl output-confirm` within 15 seconds;
use `output-revert` to restore immediately. The timeout is profile-configurable.
## Hardware checks
1. Connect one external monitor, then a second. Check distinct IDs and identities,
most-specific profile selection and deterministic positions. Unplug/replug
each; serial identities should survive connector changes where unambiguous.
Check identical monitors with missing/duplicated serials on separate ports.
2. Select internal-only, external-only and extend profiles. Unaffected outputs
should keep their globals and workspaces. Try `output-internal off` undocked:
it must fail without disabling the panel. Close/open the lid docked, then
unplug the dock with the lid closed. Repeat docking while the lid is closed.
3. Undocked, close the lid. Check logind suspension. Repeat with a block sleep
inhibitor; Ferese must not bypass it. Set `lid-policy="ignore"` in the matching
profile and confirm lid closure does not trigger Ferese's suspend request.
4. Suspend or switch VT, change monitor topology while inactive, then resume.
Check that connector and lid state are current, the lock screen wakes and
frame callbacks continue. If a DRM device fails to reactivate, its outputs
must not appear active.
5. Mirror different resolutions, scales and rotations. The desktop, overview,
shell and pointer must match the source; letterboxing must preserve the full
scene. Check lock-screen mirroring too. Remove a target, then the source;
remaining windows must be reachable. Return to extend and verify each
monitor gets a workspace without duplicating ownership.
6. Move and resize floating windows near each edge. Remove their home output,
remove the evacuation destination, then reconnect the home. Windows must
remain visible throughout. Deleted workspaces must not return. If you explicitly
reassign a workspace, that must change its home; reconnecting the old monitor
must not move it back.
7. Make a manual switch without confirming and disconnect feresectl. Check that
timeout restores the actual old modes/positions. Repeat with timeout disabled.
Apply a second change before confirming: its timeout must still restore the
original confirmed configuration.
A connector or lid change must cancel the pending confirmation and use auto.
8. Reload duplicate profile names/selectors, invalid mode syntax, zero scale,
unknown transforms, invalid mirror sources and all-disabled profiles. Check
the error and verify the accepted config/output state remains active.
## Driver failure checks
Use VKMS or a dedicated DRM test seat with a fault-injection shim; avoid forcing
errors on the desktop being used to run the test. Inject failures during buffer allocation,
TEST_ONLY, a later real CRTC commit, output disable and rollback. The `output_transaction` unit tests cover those failures without hardware.
A TEST_ONLY failure must leave existing hardware and logical outputs unchanged.
A real-commit failure must restore earlier commits where possible. If restoration
fails, the unavailable output must lose its logical global/workspace ownership;
remaining windows must evacuate to a working output. With multiple GPUs, make the later GPU's commit fail and check that Ferese
attempts to restore the earlier devices. This
is not a cross-GPU atomic commit.
Check a mode change under load with a pending page flip. The old flip must not
release callbacks for the new frame. Verify ordinary and mirror source feedback
still reports actual presentation, while targets send no duplicate callbacks.
Capture compositor tracing around each failure. Include driver/GPU names and the
before/after `outputs` dumps when reporting results; a passing mock transaction
does not establish driver-level rollback behavior.
Lock and session ownership checks:
- In mirror mode, delay or fail target rendering while acquiring a session lock.
The lock must remain unconfirmed until every physical display has presented
protected content. Reconfigure a mirror and repeat; old presentation evidence
must not satisfy the new configuration.
- Switch to a TTY and check `systemd-inhibit --list`: Ferese must release its
`handle-lid-switch` inhibitor. Returning to Ferese should reacquire it.
- Replace a monitor on the same connector. `feresectl outputs` must report the
replacement identity, rather than the previous monitor's identity.
+44 -1
View File
@@ -111,6 +111,19 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
let command = args.next().ok_or_else(usage)?; let command = args.next().ok_or_else(usage)?;
let positional = args.collect::<Vec<_>>(); let positional = args.collect::<Vec<_>>();
let payload = match command.as_str() { let payload = match command.as_str() {
"output-profile" => {
exactly_one(&command, &positional, "profile name or auto")?;
json!({ "name": positional[0] })
}
"output-internal" => {
exactly_one(&command, &positional, "on or off")?;
let enabled = match positional[0].as_str() {
"on" => true,
"off" => false,
_ => return Err("output-internal requires on or off".into()),
};
json!({ "enabled": enabled })
}
"screenshot-window" => { "screenshot-window" => {
exactly_one(&command, &positional, "window ID")?; exactly_one(&command, &positional, "window ID")?;
let window = positional[0] let window = positional[0]
@@ -156,6 +169,10 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
| "get-windows" | "get-windows"
| "get-workspaces" | "get-workspaces"
| "get-outputs" | "get-outputs"
| "outputs"
| "output-profiles"
| "output-confirm"
| "output-revert"
| "reload-config" | "reload-config"
| "exit" | "exit"
| "request-logout" | "request-logout"
@@ -191,7 +208,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned() "usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
@@ -272,6 +289,32 @@ mod tests {
); );
} }
#[test]
fn output_commands_have_consistent_arguments() {
for command in ["outputs", "output-profiles", "output-confirm", "output-revert"] {
assert_eq!(parse_args([command.into()]).unwrap(), (command.into(), json!({})));
assert!(parse_args([command.into(), "extra".into()]).is_err());
}
assert_eq!(
parse_args(["output-profile".into(), "auto".into()]).unwrap().1,
json!({"name": "auto"})
);
assert_eq!(
parse_args(["output-profile".into(), "desk".into()]).unwrap().1,
json!({"name": "desk"})
);
assert_eq!(
parse_args(["output-internal".into(), "off".into()]).unwrap().1,
json!({"enabled": false})
);
assert_eq!(
parse_args(["output-internal".into(), "on".into()]).unwrap().1,
json!({"enabled": true})
);
assert!(parse_args(["output-internal".into(), "maybe".into()]).is_err());
assert!(parse_args(["output-profile".into()]).is_err());
}
#[test] #[test]
fn rejects_unknown_or_missing_arguments() { fn rejects_unknown_or_missing_arguments() {
assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({}))); assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));