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
+3614 -474

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(())
}
fn render_capture<'a, E: RenderElement<GlesRenderer>>(
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
texture: &'a mut Option<GlesTexture>,
output: &Output,
+171 -3
View File
@@ -1,5 +1,5 @@
//! Resume reads are asynchronous; newer switch events win over old replies.
use std::sync::mpsc;
use std::sync::{Arc, Mutex, mpsc};
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 {
requests: mpsc::Sender<Probe>,
suspend: mpsc::Sender<PolicyRequest>,
lease: Arc<Mutex<InhibitorLease<zbus::zvariant::OwnedFd>>>,
suspend_requested: bool,
pub deadline: Option<RegistrationToken>,
}
@@ -99,7 +142,94 @@ fn read_acpi() -> Option<bool> {
}
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>> {
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 (replies, events) = smithay::reexports::calloop::channel::channel();
state.loop_handle.insert_source(events, |event, _, state| {
@@ -126,10 +256,15 @@ impl Reader {
}
})?;
Ok(Self {
let mut reader = Self {
requests,
suspend,
lease,
suspend_requested: false,
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);
}
}
backend.lid_reader.suspend_requested = false;
backend.lid_reader.lease.lock().unwrap().suspend = false;
let probe = backend.lid.begin(reactivate);
let sent = backend.lid_reader.requests.send(probe).is_ok();
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
// device/connector snapshot has been reconciled.
backend.lid_reader.suspend_requested = false;
backend.topology.reconciling = true;
state.reset_animation_clock();
state.lock_input_activity();
@@ -198,10 +336,40 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
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)]
mod tests {
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]
#[ignore = "requires dbus-daemon and private test sockets"]
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:?}");
}
}
}
+187 -85
View File
@@ -78,6 +78,8 @@ pub(super) struct Topology {
pub reconciling: bool,
pub dirty: bool,
pub retry_timer: Option<RegistrationToken>,
pub settle_timer: Option<RegistrationToken>,
pub coalescer: crate::output_policy::Debouncer,
}
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 {
state
.direct_backend
@@ -180,8 +115,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
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.dirty = false;
backend.output_error = None;
if reactivate {
backend
.presentation
@@ -191,7 +131,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
if let Some(token) = backend.topology.retry_timer.take() {
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 observed = all_gpus(backend.session.seat()).map(|paths| {
paths
@@ -209,6 +149,23 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
backend.low_power =
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 {
remove_device(state, node);
}
@@ -301,7 +258,9 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
.remove(&crtc)
.unwrap();
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
.direct_backend
@@ -311,28 +270,65 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
.remove(&(node, crtc));
}
let complete = rescan_device(state, node);
if complete.is_err() {
remove_device(state, node);
ready.push((path, node));
}
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());
}
let entry = slot_mut(state, &path);
if complete == Ok(true) {
entry.ready(node);
} else {
entry.failed(Instant::now());
// A partial connector failure must not take down working outputs.
if complete == Ok(false) {
entry.availability = Availability::Degraded(node);
} 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) {
entry.ready(*node);
succeeded.push(*node);
} else {
entry.failed(Instant::now());
entry.availability = Availability::Degraded(*node);
// Device boundaries are not cross-GPU atomic. Compensate already
// 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();
backend.connected_outputs = backend
.devices
.values()
.flat_map(|device| device.connected_outputs.clone())
.collect();
backend.topology.reconciling = false;
let retry = backend.topology.retry_deadline(backend.active);
if let Some(deadline) = retry {
@@ -355,4 +351,110 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
state.restore_output_focus();
state.relayout();
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;
use input_motion::*;
use outputs::*;
pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb};
@@ -1049,9 +1049,15 @@ mod tests {
config.output_profiles().unwrap(),
vec![OutputProfile {
name: "docked".to_owned(),
layout: OutputLayout::Extend,
confirm_timeout: 15,
lid_policy: LidPolicy::DockOrSuspend,
lid_closed: None,
mirror_source: None,
outputs: vec![
OutputSettings {
auto_refresh: false,
required: true,
matcher: "HDMI-A-1".to_owned(),
enabled: true,
mode: Some(OutputModeRequest {
@@ -1065,6 +1071,7 @@ mod tests {
},
OutputSettings {
auto_refresh: false,
required: true,
matcher: "eDP-1".to_owned(),
enabled: false,
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]
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; }");
+80
View File
@@ -43,6 +43,7 @@ impl Config {
Ok(OutputSettings {
auto_refresh: output.auto_refresh,
required: output.required,
matcher: matcher.to_owned(),
enabled: output.enabled,
mode: output
@@ -58,9 +59,38 @@ impl Config {
})
.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 {
name: name.to_owned(),
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()
@@ -112,11 +142,17 @@ pub(super) const fn default_output_scale() -> f64 {
pub struct OutputProfile {
pub name: String,
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)]
pub struct OutputSettings {
pub auto_refresh: bool,
pub required: bool,
pub matcher: String,
pub enabled: bool,
pub mode: Option<OutputModeRequest>,
@@ -125,6 +161,18 @@ pub struct OutputSettings {
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)]
pub struct OutputModeRequest {
pub width: u16,
@@ -152,10 +200,40 @@ pub enum OutputTransform {
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)]
pub(super) struct OutputProfileConfig {
pub(super) name: String,
#[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>,
}
@@ -163,6 +241,8 @@ pub(super) struct OutputProfileConfig {
pub(super) struct OutputConfig {
#[serde(default)]
pub(super) auto_refresh: bool,
#[serde(default = "default_true")]
pub(super) required: bool,
#[serde(rename = "match")]
pub(super) matcher: String,
#[serde(default = "default_true")]
+45 -5
View File
@@ -694,7 +694,40 @@ impl Ferese {
return Ok(json!(self.has_client_surfaces(pid)));
}
"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(
"unknown_command",
@@ -1026,8 +1059,8 @@ impl Ferese {
.connected_outputs
.iter()
.map(|info| {
let mapped = self.space.outputs().find(|output| output.name() == info.connector);
let id = mapped.and_then(|output| self.output_id(output));
let mapped = self.output_by_identity(&info.identity);
let id = self.persistent_output_id(&info.identity);
let geometry = mapped.and_then(|output| self.space.output_geometry(output));
let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id));
let position = geometry
@@ -1066,9 +1099,16 @@ impl Ferese {
"name": info.connector,
"connector": info.connector,
"identity": info.identity,
"connected": true,
"enabled": info.enabled && mapped.is_some(),
"connected": info.connected,
"internal": info.internal,
"requested_enabled": info.requested_enabled,
"applied_enabled": info.enabled,
"mirror_source": info.mirror_source,
"enabled": info.enabled,
"profile": info.profile,
"requested_profile": info.requested_profile,
"configuration_error": backend.output_configuration_error(),
"confirmation_pending": backend.confirmation_pending(),
"auto_refresh": info.auto_refresh,
"low_power": backend.low_power,
"focused": id.is_some() && id == focused,
+3
View File
@@ -16,6 +16,9 @@ mod input;
mod input_capture;
mod ipc;
mod metrics;
mod monitor_identity;
mod output_policy;
mod output_transaction;
mod overview;
mod portal_session;
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);
}
#[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]
fn runtime_validation_rejects_bad_edits_before_application() {
for source in [
+51 -3
View File
@@ -145,6 +145,18 @@ impl Lock {
}
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) {
if self.session_lock.activity(Instant::now()) {
crate::backends::direct::wake_locked_outputs(self);
@@ -162,7 +174,7 @@ impl Ferese {
if let Some(token) = self.session_lock.idle_timer.take() {
self.loop_handle.remove(token);
}
if !self.session_lock.ready_for_idle(self.space.outputs()) {
if !self.lock_outputs_protected() {
return;
}
@@ -189,7 +201,7 @@ impl Ferese {
}
fn update_lock_idle(&mut self, now: Instant) {
if !self.session_lock.ready_for_idle(self.space.outputs()) {
if !self.lock_outputs_protected() {
return;
}
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) {
// With no outputs there is no visible content to protect. Re-evaluate
// 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;
}
@@ -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]
fn protected_fallback_frames_allow_idle_after_early_locker_crash() {
let output = Output::new(
+8
View File
@@ -263,6 +263,14 @@ impl Ferese {
}
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());
if self.shell_resources.is_empty() {
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>>) {
if self
.direct_backend
.as_ref()
.is_some_and(|backend| backend.reconciling())
{
return;
}
if let Some(outputs) = redraw_outputs.as_mut() {
outputs.extend(
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) {
if self.output_ids.is_empty() {
self.reset_animation_clock();
@@ -20,20 +38,40 @@ impl Ferese {
tracing::error!(output = %output.name(), "cannot register an unmapped output");
return;
};
let output_id = *self.output_identity_ids.entry(identity).or_insert_with(|| {
let id = OutputId(self.next_output_id);
self.next_output_id = self.next_output_id.saturating_add(1);
id
});
let output_id = self.ensure_output_identity(&identity);
let fallback_workspace = self
.workspaces
.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)
.min_by_key(|id| id.0)
.unwrap_or_else(|| self.workspaces.create_workspace());
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
let returning_floats = self
.windows
.ids()
.values()
.filter_map(|id| {
let workspace = self.workspaces.workspace_for_window(*id)?;
if self.output_workspaces.home_output(workspace) != Some(output_id) {
return None;
}
let owner = self.output_workspaces.output_for_workspace(workspace)?;
let old = self.output_workspaces.geometry(owner)?;
let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else {
return None;
};
Some((*id, workspace, rect, old))
})
.collect::<Vec<_>>();
let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
match registration {
@@ -41,6 +79,27 @@ impl Ferese {
self.output_ids.insert(output.clone(), output_id);
self.outputs_by_id.insert(output_id, output.clone());
self.output_names.insert(output_id, output.name());
for (id, workspace, rect, old) in returning_floats {
if self.output_workspaces.output_for_workspace(workspace) == Some(output_id) {
let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64);
let new = Rect::new(
geometry.x as f64,
geometry.y as f64,
geometry.width as f64,
geometry.height as f64,
);
let _ = self
.workspaces
.set_floating_rect(id, moved_floating_rect(rect, old, new));
}
}
if self
.direct_backend
.as_ref()
.is_some_and(|backend| backend.reconciling())
{
return;
}
self.restore_output_focus();
let visible = self.visible_workspace_ids();
self.reconcile_workspaces(&visible);
@@ -80,8 +139,35 @@ impl Ferese {
self.confirm_lock_if_ready();
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) {
Ok(Some(target)) => {
if let (Some(old), Some(new)) = (old_geometry, self.output_workspaces.geometry(target)) {
let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64);
let new = Rect::new(new.x as f64, new.y as f64, new.width as f64, new.height as f64);
let floats = self
.windows
.ids()
.values()
.filter_map(|id| {
let workspace = self.workspaces.workspace_for_window(*id)?;
if !evacuated.contains(&workspace) {
return None;
}
match self.workspaces.placement(*id)? {
WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))),
_ => None,
}
})
.collect::<Vec<_>>();
for (id, rect) in floats {
let _ = self.workspaces.set_floating_rect(id, rect);
}
}
if let Some(workspace) = self.output_workspaces.active_workspace(target) {
self.activate_output_workspace(target, workspace);
}