Fix review findings across compositor and shell

This commit is contained in:
Samuel Onoja committed 2026-10-13 04:02:17 +01:00
1 parent 5d9673aad3
commit f7e5efb245
30 files changed
+1222 -181

No files matched your search

+42 -1
View File
@@ -31,6 +31,7 @@ pub(crate) struct Engine {
cached_snapshot: Option<Arc<Snapshot>>,
publisher: Option<publication::Publisher>,
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
locked: bool,
rates: BTreeMap<Owner, (u64, u16)>,
windows: BTreeMap<Id, u64>,
results: BTreeMap<Id, ferese_config::activities::ResultText>,
@@ -55,6 +56,7 @@ impl Default for Engine {
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
.ok(),
watchers: BTreeMap::new(),
locked: false,
rates: BTreeMap::new(),
results: BTreeMap::new(),
windows: BTreeMap::new(),
@@ -159,7 +161,20 @@ impl Engine {
pub fn remove_connection(&mut self, owner: u64) {
self.watchers.remove(&owner);
}
pub fn set_locked(&mut self, locked: bool) {
self.locked = locked;
if locked {
if let Some(publisher) = &self.publisher {
publisher.revoke();
}
publication::locked(std::mem::take(&mut self.watchers).into_values().collect());
}
}
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
if self.locked {
publication::locked(recipients);
return;
}
if recipients.is_empty() {
return;
}
@@ -174,7 +189,7 @@ impl Engine {
self.deliver(vec![(request, sender)]);
}
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
if since != self.revision {
if self.locked || since != self.revision {
self.get(request, sender);
} else if self.watchers.len() < 32 {
self.watchers.insert(connection, (request, sender));
@@ -492,6 +507,32 @@ mod tests {
template: Template::Progress,
}
}
#[test]
fn session_lock_cancels_pending_and_rejects_new_activity_reads() {
let mut engine = Engine::default();
let revision = engine.snapshot().revision;
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 42, revision, send);
engine.set_locked(true);
let reply = receive.recv().unwrap();
assert_eq!(reply.id, 42);
assert_eq!(reply.error.unwrap().code, "session_locked");
assert!(reply.result.is_none());
engine.changed();
for since in [revision, engine.revision] {
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.watch(2, 43, since, send);
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
}
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.get(44, send);
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
engine.set_locked(false);
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.watch(3, 45, revision, send);
assert!(receive.recv_timeout(Duration::from_secs(2)).unwrap().result.is_some());
}
#[test]
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
let mut engine = Engine::default();
+56 -10
View File
@@ -1,19 +1,22 @@
//! Bounded serialization and delivery outside the compositor event loop.
use ferese_ipc::{Response, activities::Snapshot};
use std::sync::{
Arc,
Arc, RwLock,
mpsc::{self, SyncSender},
};
type Recipient = (u64, SyncSender<Response>);
struct Job {
epoch: u64,
snapshot: Arc<Snapshot>,
recipients: Vec<Recipient>,
}
pub(super) struct Publisher(SyncSender<Job>);
pub(super) struct Publisher(SyncSender<Job>, Arc<RwLock<u64>>);
impl Publisher {
pub fn new() -> std::io::Result<Self> {
let (send, receive) = mpsc::sync_channel::<Job>(8);
let epoch = Arc::new(RwLock::new(0));
let worker_epoch = epoch.clone();
std::thread::Builder::new()
.name("ferese-activity-snapshots".into())
.spawn(move || {
@@ -26,23 +29,47 @@ impl Publisher {
cached = Some((job.snapshot.clone(), serde_json::json!(job.snapshot.as_ref())));
}
let value = &cached.as_ref().unwrap().1;
for (id, sender) in job.recipients {
// Client queues are bounded too. A slow/disconnected client
// cannot block serialization or another subscriber.
let _ = sender.try_send(Response::success(id, value.clone()));
}
// Lock acquisition revokes queued/in-flight serialization.
// Hold the guard through delivery to order sends before revocation.
deliver(job.epoch, &worker_epoch, job.recipients, value);
}
})?;
Ok(Self(send))
Ok(Self(send, epoch))
}
pub fn revoke(&self) {
let mut epoch = self.1.write().expect("activity publication epoch");
*epoch = epoch.wrapping_add(1);
}
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
let epoch = *self.1.read().expect("activity publication epoch");
if let Err(error) = self.0.try_send(Job {
epoch,
snapshot,
recipients,
}) {
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
unavailable(job.recipients);
}
}
}
fn deliver(epoch: u64, current: &RwLock<u64>, recipients: Vec<Recipient>, value: &serde_json::Value) {
let current = current.read().expect("activity publication epoch");
if epoch != *current {
locked(recipients);
return;
}
for (id, sender) in recipients {
// A slow/disconnected client cannot block another subscriber.
let _ = sender.try_send(Response::success(id, value.clone()));
}
}
pub(super) fn locked(recipients: Vec<Recipient>) {
for (id, sender) in recipients {
let _ = sender.try_send(Response::error(id, "session_locked", "Session is locked"));
}
}
pub(super) fn unavailable(recipients: Vec<Recipient>) {
for (id, sender) in recipients {
let _ = sender.try_send(Response::error(
@@ -58,10 +85,29 @@ mod tests {
use super::*;
use std::time::Duration;
#[test]
fn lock_revokes_serialized_jobs_even_after_unlock() {
let (send, _receive) = mpsc::sync_channel(1);
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
let job_epoch = *publisher.1.read().unwrap();
publisher.revoke();
let (reply, receive) = mpsc::sync_channel(1);
deliver(
job_epoch,
&publisher.1,
vec![(9, reply)],
&serde_json::json!({"title": "private"}),
);
let response = receive.recv().unwrap();
assert_eq!(response.id, 9);
assert!(response.result.is_none());
assert_eq!(response.error.unwrap().code, "session_locked");
}
#[test]
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
let (send, _receive) = mpsc::sync_channel(0);
let publisher = Publisher(send);
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
let (reply, receive) = mpsc::sync_channel(1);
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
+1 -2
View File
@@ -350,8 +350,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.as_mut()
.unwrap()
.topology
.devices
.insert(primary_path.clone(), topology::DeviceState::known(&primary_path));
.observe_devices(&[(primary_path.clone(), topology::device_identity(&primary_path))]);
lid::request_refresh(state, false);
event_loop
+212 -42
View File
@@ -4,21 +4,21 @@ use std::path::PathBuf;
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Availability {
Ready(DrmNode),
Degraded(DrmNode),
pub(super) enum Availability<Node = DrmNode> {
Ready(Node),
Degraded(Node),
Missing,
Unavailable,
}
pub(super) struct DeviceState {
pub availability: Availability,
pub(super) struct DeviceState<Node = DrmNode> {
pub availability: Availability<Node>,
identity: Option<PathBuf>,
failures: u32,
retry_at: Option<Instant>,
}
impl Default for DeviceState {
impl<Node> Default for DeviceState<Node> {
fn default() -> Self {
Self {
availability: Availability::Missing,
@@ -29,19 +29,12 @@ impl Default for DeviceState {
}
}
fn device_identity(path: &Path) -> Option<PathBuf> {
pub(super) fn device_identity(path: &Path) -> Option<PathBuf> {
// The parent hardware identity survives cardN renumbering after unplug.
std::fs::canonicalize(Path::new("/sys/class/drm").join(path.file_name()?).join("device")).ok()
}
impl DeviceState {
pub fn known(path: &Path) -> Self {
Self {
identity: device_identity(path),
..Self::default()
}
}
impl<Node> DeviceState<Node> {
fn current_path<'a>(&self, original: &Path, observed: &'a [(PathBuf, Option<PathBuf>)]) -> Option<&'a Path> {
observed
.iter()
@@ -58,7 +51,7 @@ impl DeviceState {
self.retry_at = Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
}
pub fn ready(&mut self, node: DrmNode) {
pub fn ready(&mut self, node: Node) {
self.availability = Availability::Ready(node);
self.failures = 0;
self.retry_at = None;
@@ -69,12 +62,18 @@ impl DeviceState {
self.failures = 0;
self.retry_at = None;
}
fn degraded(&mut self, node: Node, now: Instant) {
self.failed(now);
self.availability = Availability::Degraded(node);
}
}
#[derive(Default)]
pub(super) struct Topology {
pub devices: HashMap<PathBuf, DeviceState>,
pub invalidated: HashSet<DrmNode>,
pub(super) struct Topology<Node = DrmNode> {
// Hardware paths are stable keys; use the card path only when sysfs identity
// is unavailable. A reused card number must not replace another GPU's slot.
pub devices: HashMap<PathBuf, DeviceState<Node>>,
pub invalidated: HashSet<Node>,
pub reconciling: bool,
pub dirty: bool,
pub layout_dirty: bool,
@@ -84,7 +83,61 @@ pub(super) struct Topology {
pub coalescer: crate::output_policy::Debouncer,
}
impl Topology {
impl<Node> Default for Topology<Node> {
fn default() -> Self {
Self {
devices: HashMap::new(),
invalidated: HashSet::new(),
reconciling: false,
dirty: false,
layout_dirty: false,
remember_applied: false,
retry_timer: None,
settle_timer: None,
coalescer: Default::default(),
}
}
}
// Node identity is opaque to reconciliation; tests use integers without opening
// DRM devices or depending on which nodes happen to exist in the host's sysfs.
impl<Node: Copy + Eq + std::hash::Hash> Topology<Node> {
pub fn observe_devices(&mut self, observed: &[(PathBuf, Option<PathBuf>)]) {
for (path, identity) in observed {
let key = identity.as_ref().unwrap_or(path);
let fallback = if identity.is_some() {
self.devices.remove(path)
} else {
None
};
if self.devices.contains_key(key) {
continue;
}
// Preserve a slot first discovered before its sysfs identity was
// available, including its live DRM resources and retry state.
let mut slot = fallback.unwrap_or_default();
slot.identity = identity.clone();
self.devices.insert(key.clone(), slot);
}
}
fn retire_changed_devices(
&mut self,
observed: &[(PathBuf, Option<PathBuf>)],
node_for_path: impl Fn(&Path) -> Option<Node>,
) -> HashSet<Node> {
let mut retired = HashSet::new();
for (key, slot) in &mut self.devices {
if let Availability::Ready(previous) | Availability::Degraded(previous) = slot.availability
&& slot.current_path(key, observed).and_then(&node_for_path) != Some(previous)
{
retired.insert(previous);
slot.missing();
}
}
retired
}
pub fn request(&mut self, active: bool) -> bool {
self.dirty = true;
active && !self.reconciling
@@ -133,8 +186,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 mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
let invalidated = std::mem::take(&mut backend.topology.invalidated);
let mut invalidated = std::mem::take(&mut backend.topology.invalidated);
let observed = all_gpus(backend.session.seat()).map(|paths| {
paths
.into_iter()
@@ -152,19 +204,16 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
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
backend.topology.observe_devices(observed);
// Retire every obsolete node before opening any replacement. Otherwise
// a later missing slot could remove a new GPU that reused its dev_t.
invalidated.extend(
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());
}
}
.retire_changed_devices(observed, |path| DrmNode::from_path(path).ok()),
);
}
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
paths.sort();
let mut ready = Vec::new();
state.begin_desktop_transition();
@@ -290,8 +339,8 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
}) {
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());
for (path, node) in &ready {
slot_mut(state, path).degraded(*node, Instant::now());
}
} else {
// Activate destinations before disabling former owners, including across GPUs.
@@ -314,8 +363,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
entry.ready(*node);
succeeded.push(*node);
} else {
entry.failed(Instant::now());
entry.availability = Availability::Degraded(*node);
entry.degraded(*node, Instant::now());
// Device boundaries are not cross-GPU atomic. Compensate already
// applied devices using their last actual hardware settings.
if !known_good.outputs.is_empty() {
@@ -482,9 +530,131 @@ pub(super) fn hotplug(state: &mut Ferese) {
mod tests {
use super::*;
#[test]
fn replacement_gpu_at_the_same_card_path_gets_its_own_slot() {
let card = PathBuf::from("/dev/dri/card0");
let a = PathBuf::from("/sys/devices/gpu-a");
let b = PathBuf::from("/sys/devices/gpu-b");
let node = 1;
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card.clone(), Some(a.clone()))]);
topology.devices.get_mut(&a).unwrap().ready(node);
// A policy failure keeps the device open, so it must retain ownership
// of its node until replacement retirement runs.
topology.devices.get_mut(&a).unwrap().degraded(node, Instant::now());
let observed = [(card.clone(), Some(b.clone()))];
topology.observe_devices(&observed);
assert_eq!(
topology.retire_changed_devices(&observed, |_| Some(node)),
HashSet::from([node])
);
assert_eq!(topology.devices[&a].availability, Availability::Missing);
assert_eq!(topology.devices[&a].current_path(&a, &observed), None);
assert_eq!(topology.devices[&b].current_path(&b, &observed), Some(card.as_path()));
topology.devices.get_mut(&b).unwrap().ready(node);
assert!(topology.retire_changed_devices(&observed, |_| Some(node)).is_empty());
}
#[test]
fn renumbered_and_returning_gpus_keep_independent_slots() {
let a = PathBuf::from("/sys/devices/gpu-a");
let b = PathBuf::from("/sys/devices/gpu-b");
let card0 = PathBuf::from("/dev/dri/card0");
let card2 = PathBuf::from("/dev/dri/card2");
let node0 = 1;
let node2 = 2;
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card0.clone(), Some(a.clone()))]);
topology.devices.get_mut(&a).unwrap().ready(node0);
let observed = [(card0.clone(), Some(b.clone())), (card2.clone(), Some(a.clone()))];
for _ in 0..2 {
topology.observe_devices(&observed);
}
assert_eq!(topology.devices.len(), 2);
assert_eq!(topology.devices[&a].current_path(&a, &observed), Some(card2.as_path()));
assert_eq!(topology.devices[&b].current_path(&b, &observed), Some(card0.as_path()));
assert_eq!(
topology.retire_changed_devices(&observed, |path| Some(if path == card0 { node0 } else { node2 })),
HashSet::from([node0])
);
}
#[test]
fn discovering_sysfs_identity_preserves_the_existing_slot() {
let card = PathBuf::from("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/gpu");
let node = 1;
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card.clone(), None)]);
topology.devices.get_mut(&card).unwrap().ready(node);
topology.observe_devices(&[(card.clone(), Some(identity.clone()))]);
assert_eq!(topology.devices.len(), 1);
assert_eq!(topology.devices[&identity].availability, Availability::Ready(node));
}
#[test]
fn temporary_identity_loss_reconciles_through_the_card_path() {
let card = PathBuf::from("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/gpu");
let known = [(card.clone(), Some(identity.clone()))];
let unknown = [(card.clone(), None)];
let mut topology = Topology::<u64>::default();
topology.observe_devices(&known);
topology.devices.get_mut(&identity).unwrap().ready(1);
topology.observe_devices(&unknown);
assert_eq!(
topology.retire_changed_devices(&unknown, |_| Some(1)),
HashSet::from([1])
);
assert_eq!(topology.devices[&identity].availability, Availability::Missing);
assert_eq!(
topology.devices[&card].current_path(&card, &unknown),
Some(card.as_path())
);
// Reconciliation reopens the node using its card-path fallback.
topology.devices.get_mut(&card).unwrap().ready(1);
topology.observe_devices(&known);
assert_eq!(topology.devices.len(), 1);
assert!(!topology.devices.contains_key(&card));
assert_eq!(
topology.devices[&identity].current_path(&identity, &known),
Some(card.as_path())
);
// The reopened node is still valid and can be reused for this identity.
assert!(topology.retire_changed_devices(&known, |_| Some(1)).is_empty());
topology.devices.get_mut(&identity).unwrap().ready(1);
assert!(topology.retire_changed_devices(&known, |_| Some(1)).is_empty());
}
#[test]
fn swapping_device_numbers_retires_both_before_reopening_either() {
let a = PathBuf::from("/sys/devices/gpu-a");
let b = PathBuf::from("/sys/devices/gpu-b");
let card0 = PathBuf::from("/dev/dri/card0");
let card1 = PathBuf::from("/dev/dri/card1");
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card0.clone(), Some(a.clone())), (card1.clone(), Some(b.clone()))]);
topology.devices.get_mut(&a).unwrap().ready(0);
topology.devices.get_mut(&b).unwrap().ready(1);
let swapped = [(card0.clone(), Some(b.clone())), (card1.clone(), Some(a.clone()))];
let node_for_path = |path: &Path| Some(u64::from(path == card1));
topology.observe_devices(&swapped);
assert_eq!(
topology.retire_changed_devices(&swapped, node_for_path),
HashSet::from([0, 1])
);
assert_eq!(topology.devices[&a].availability, Availability::Missing);
assert_eq!(topology.devices[&b].availability, Availability::Missing);
topology.devices.get_mut(&a).unwrap().ready(1);
topology.devices.get_mut(&b).unwrap().ready(0);
assert!(topology.retire_changed_devices(&swapped, node_for_path).is_empty());
}
#[test]
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
let mut topology = Topology::default();
let mut topology = Topology::<u64>::default();
assert!(!topology.request(false));
assert!(!topology.request(false));
assert!(topology.dirty);
@@ -497,7 +667,7 @@ mod tests {
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 {
let mut slot = DeviceState::<u64> {
identity: Some(identity.clone()),
..DeviceState::default()
};
@@ -521,9 +691,9 @@ mod tests {
#[test]
fn unavailable_devices_retry_independently_with_capped_backoff() {
let now = Instant::now();
let mut topology = Topology::default();
let mut topology = Topology::<u64>::default();
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
let healthy = 1;
let mut second = DeviceState::default();
second.ready(healthy);
topology.devices.insert("/dev/dri/card1".into(), second);
+19 -9
View File
@@ -48,7 +48,12 @@ impl Config {
}
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
positive_animation_value(self.animations.speed, "speed")
self.animations
.validate_speed()
.map_err(|_| ConfigError::AnimationValue {
field: "speed",
value: self.animations.speed,
})
}
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
@@ -60,14 +65,6 @@ impl Config {
}
}
pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(ConfigError::AnimationValue { field, value })
}
}
pub(super) fn default_xkb_layout() -> String {
"us".to_owned()
}
@@ -151,3 +148,16 @@ impl Default for TouchpadConfig {
}
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compositor_speed_uses_shared_motion_bounds() {
for speed in [1e-100, 0.009, 101.0, 1e100] {
let config = Config::parse_source(&format!("animations {{ speed {speed:e}; }}")).unwrap();
assert!(config.animation_speed().is_err());
}
}
}
+13 -1
View File
@@ -48,10 +48,20 @@ pub(crate) struct Engine {
snapshot: Snapshot,
value: Value,
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
locked: bool,
commands: Option<async_channel::Sender<Command>>,
}
impl Engine {
pub(crate) fn set_locked(&mut self, locked: bool) {
self.locked = locked;
if locked {
for (_, (id, response)) in self.waiters.drain() {
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
}
}
}
pub(crate) fn value(&self) -> Value {
if self.value.is_null() {
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
@@ -75,7 +85,9 @@ impl Engine {
}
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
if since != self.snapshot.revision {
if self.locked {
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
} else if since != self.snapshot.revision {
let _ = response.try_send(Response::success(id, self.value()));
} else {
self.waiters.insert(owner, (id, response));
@@ -114,3 +114,28 @@ fn metadata_and_position_updates_do_not_recompute_idle_policy() {
));
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
}
#[test]
fn session_lock_cancels_pending_and_rejects_new_media_watches() {
let mut engine = Engine::default();
let (send, receive) = mpsc::sync_channel(1);
engine.watch(1, 42, 0, send);
engine.set_locked(true);
let reply = receive.recv().unwrap();
assert_eq!(reply.id, 42);
assert!(reply.result.is_none());
assert_eq!(reply.error.unwrap().code, "session_locked");
engine.update(Snapshot {
selected: Some(player("private", Playback::Playing).view),
..Default::default()
});
for since in [0, engine.snapshot.revision] {
let (send, receive) = mpsc::sync_channel(1);
engine.watch(2, 43, since, send);
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
}
engine.set_locked(false);
let (send, receive) = mpsc::sync_channel(1);
engine.watch(3, 44, 0, send);
assert!(receive.recv().unwrap().result.is_some());
}
+12 -1
View File
@@ -1308,7 +1308,7 @@ fn virtual_terminal(symbol: u32, ctrl: bool, alt: bool, direct: bool) -> Option<
}
(ctrl && alt && (keysyms::KEY_F1..=keysyms::KEY_F12).contains(&symbol))
.then_some((symbol - keysyms::KEY_F1 + 1) as i32)
.then(|| (symbol - keysyms::KEY_F1 + 1) as i32)
}
fn emergency_shortcut_escape(symbol: u32, ctrl: bool, alt: bool) -> bool {
@@ -1394,6 +1394,17 @@ mod tests {
assert_eq!(virtual_terminal(keysyms::KEY_F7, false, true, true), None);
}
#[test]
fn ordinary_keys_never_evaluate_virtual_terminal_arithmetic() {
for symbol in [0, keysyms::KEY_a, keysyms::KEY_Escape, keysyms::KEY_F12 + 1] {
for ctrl in [false, true] {
for alt in [false, true] {
assert_eq!(virtual_terminal(symbol, ctrl, alt, true), None);
}
}
}
}
#[test]
fn leaves_virtual_terminal_keys_to_nested_clients() {
assert_eq!(
+7 -1
View File
@@ -247,7 +247,13 @@ pub(crate) fn init(
state.activities.get(call.request.id, call.response);
}
} else if call.request.command == "activity-watch" {
if let Err(error) = validate_request(&call.request) {
if state.session_lock.active() {
let _ = call.response.try_send(Response::error(
call.request.id,
"session_locked",
"IPC unavailable while session is locked",
));
} else if let Err(error) = validate_request(&call.request) {
let _ = call
.response
.try_send(Response::error(call.request.id, error.code, error.message));
+68
View File
@@ -274,6 +274,74 @@ mod tests {
}
}
#[test]
fn wallpaper_only_reload_applies_path_and_mode_changes() {
if !crate::startup_tests::private_runtime("reload::tests::wallpaper_only_reload_applies_path_and_mode_changes")
{
return;
}
let mut events = calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let directory = tempfile::tempdir().unwrap();
let image_path = directory.path().join("wallpaper.png");
image::RgbaImage::new(2, 2).save(&image_path).unwrap();
for mode in ["fit", "fill"] {
let source = format!("wallpaper {{ path \"{}\"; mode \"{mode}\"; }}", image_path.display());
let (_, runtime, candidate) = crate::config::prepare(&source, directory.path()).unwrap();
// Model a settled theme; changing only the wallpaper must still apply.
state.theme_engine.live.theme = candidate.theme.clone();
state.theme_engine.live.presented = candidate.theme;
state.reload_config_source(source.clone()).unwrap();
assert_eq!(state.wallpaper.configuration(), &runtime.wallpaper);
let revision = state.ipc_events.config_revision;
state.reload_config_source(source).unwrap();
assert_eq!(state.ipc_events.config_revision, revision);
}
let candidate = crate::config::prepare("", directory.path()).unwrap().2;
state.theme_engine.live.theme = candidate.theme.clone();
state.theme_engine.live.presented = candidate.theme.clone();
let wallpaper = crate::wallpaper::WallpaperConfig {
path: None,
..state.wallpaper.configuration().clone()
};
state.accept_theme(candidate, wallpaper.clone());
assert_eq!(state.wallpaper.configuration(), &wallpaper);
}
#[test]
fn rejected_keymap_reload_preserves_other_configuration() {
if !crate::startup_tests::private_runtime("reload::tests::rejected_keymap_reload_preserves_other_configuration")
{
return;
}
let mut events = calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let before_xwayland = state.xwayland_config.enabled;
let before_activities = state.activities.snapshot().revision;
let before_layout = state.workspaces.active().layout.mode();
let before_input = state.input_settings.clone();
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
runtime.xwayland.enabled = !before_xwayland;
runtime.layout_mode = ferese_core::LayoutMode::Tree;
// Exercise application failure after preparation, for example when an
// XKB file disappears before the compositor installs the new keymap.
runtime.input_settings.xkb_layout = "ferese_missing_keymap_for_reload_test".into();
let error = state.apply_runtime_config(runtime, &serde_json::json!({})).unwrap_err();
assert!(error.contains("keymap reload failed"), "{error}");
assert_eq!(state.xwayland_config.enabled, before_xwayland);
assert_eq!(state.activities.snapshot().revision, before_activities);
assert_eq!(state.workspaces.active().layout.mode(), before_layout);
assert_eq!(state.input_settings, before_input);
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
runtime.xwayland.enabled = !before_xwayland;
runtime.layout_mode = ferese_core::LayoutMode::Tree;
state.apply_runtime_config(runtime, &serde_json::json!({})).unwrap();
assert_eq!(state.xwayland_config.enabled, !before_xwayland);
assert_eq!(state.workspaces.active().layout.mode(), ferese_core::LayoutMode::Tree);
assert!(state.activities.snapshot().revision > before_activities);
}
#[test]
fn worker_reload_applies_once_and_rejects_invalid_configuration() {
const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD";
+40 -1
View File
@@ -400,7 +400,7 @@ pub(super) fn layer_elements(
let Some(mode) = output.current_mode() else {
return Vec::new();
};
let output_crop = Rectangle::<i32, Physical>::from_size(mode.size);
let output_crop = layer_output_crop(mode.size, output.current_transform());
let layers = {
let map = layer_map_for_output(output);
@@ -457,6 +457,10 @@ pub(super) fn layer_elements(
elements
}
fn layer_output_crop(size: Size<i32, Physical>, transform: Transform) -> Rectangle<i32, Physical> {
Rectangle::from_size(transform.transform_size(size))
}
pub(super) fn cursor_elements(
state: &Ferese,
renderer: &mut GlesRenderer,
@@ -527,3 +531,38 @@ pub(super) fn cursor_elements(
CursorImageStatus::Hidden => Vec::new(),
}
}
#[cfg(test)]
mod layer_crop_tests {
use super::*;
#[test]
fn layer_crop_keeps_bottom_edge_content_in_every_output_transform() {
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
let mode = Size::<i32, Physical>::from((1920, 1080));
for transform in [
Transform::Normal,
Transform::_90,
Transform::_180,
Transform::_270,
Transform::Flipped,
Transform::Flipped90,
Transform::Flipped180,
Transform::Flipped270,
] {
let size = transform.transform_size(mode);
let geometry = Rectangle::new((0, size.h - 36).into(), (size.w, 36).into());
let panel = SolidColorRenderElement::new(
Id::new(),
geometry,
CommitCounter::default(),
[1.0; 4],
RenderElementKind::Unspecified,
);
let crop = CropRenderElement::from_element(panel, 1.0, layer_output_crop(mode, transform))
.expect("bottom panel must remain visible");
assert_eq!(crop.geometry(1.0.into()), geometry, "{transform:?}");
}
}
}
+4
View File
@@ -434,6 +434,8 @@ impl SessionLockHandler for Ferese {
// Replacing an orphan never passes through Unlocked. Existing protected
// presentation facts remain valid, but old surfaces must not own input.
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
self.activities.set_locked(true);
self.media_engine.set_locked(true);
self.set_binding_mode("default");
self.capture_epoch = self.capture_epoch.wrapping_add(1);
self.session_lock.surfaces.clear();
@@ -484,6 +486,8 @@ impl SessionLockHandler for Ferese {
}
let sleeping = self.session_lock.sleeping;
self.session_lock = Lock::default();
self.activities.set_locked(false);
self.media_engine.set_locked(false);
self.refresh_idle_inhibition();
if sleeping {
crate::backends::direct::wake_locked_outputs(self);
+24
View File
@@ -369,6 +369,30 @@ fn locking_exits_binding_modes_and_retains_consumed_release_ownership() {
assert_eq!(fixture.state.binding_modes.active(), None);
}
#[test]
fn acquiring_lock_revokes_activity_and_media_subscriptions() {
let Some(mut fixture) =
Fixture::new("session_lock::tests::acquiring_lock_revokes_activity_and_media_subscriptions")
else {
return;
};
fixture.output("pending");
let revision = fixture.state.activities.snapshot().revision;
let (activities, activity_reply) = std::sync::mpsc::sync_channel(1);
let (media, media_reply) = std::sync::mpsc::sync_channel(1);
fixture.state.activities.watch(1, 11, revision, activities);
let media_revision = fixture.state.media_engine.value()["revision"].as_u64().unwrap();
fixture.state.media_engine.watch(2, 22, media_revision, media);
let _locker = fixture.acquire();
assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_)));
for (id, receiver) in [(11, activity_reply), (22, media_reply)] {
let response = receiver.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(response.id, id);
assert!(response.result.is_none());
assert_eq!(response.error.unwrap().code, "session_locked");
}
}
#[test]
fn lock_confirms_without_outputs() {
let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else {
+45 -34
View File
@@ -878,15 +878,7 @@ impl Ferese {
.as_ref()
.is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key)))
};
// Desired configuration: reloads update it while endpoints stay
// session-scoped, so a difference from the running service is reported
// as `restart_required` rather than applied behind the user's back.
self.xwayland_config = config.xwayland.clone();
if changed(&["activities"]) {
self.activities
.configure(&config.activity_settings, config.activity_matcher.clone());
}
let activities_changed = changed(&["activities"]);
let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
let bindings_changed = changed(&["bindings", "binding_modes", "commands"]);
let input_changed = changed(&["input"]);
@@ -901,6 +893,39 @@ impl Ferese {
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
}
// Stage layout changes before replacing the keymap: both can fail.
// Everything below the successful keymap replacement must be infallible.
let workspaces = if layout_changed {
let mut workspaces = self.workspaces.clone();
let bounds = self
.workspaces
.iter()
.map(|workspace| {
let output = self.output_workspaces.output_for_workspace(workspace.id);
let rect = self
.output_ids
.iter()
.find(|(_, id)| Some(**id) == output)
.and_then(|(output, _)| self.output_bounds_for(output))
.or_else(|| self.output_bounds())
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
(workspace.id, rect)
})
.collect();
workspaces
.reconfigure_live(
config.layout_mode,
config.default_column_width,
config.scrolling_focus_strategy,
&bounds,
)
.map_err(|error| error.to_string())?;
Some(workspaces)
} else {
None
};
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
@@ -940,32 +965,18 @@ impl Ferese {
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
let outputs_changed = self.output_profiles != config.output_profiles;
if layout_changed {
let bounds = self
.workspaces
.iter()
.map(|workspace| {
let output = self.output_workspaces.output_for_workspace(workspace.id);
let rect = self
.output_ids
.iter()
.find(|(_, id)| Some(**id) == output)
.and_then(|(output, _)| self.output_bounds_for(output))
.or_else(|| self.output_bounds())
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
(workspace.id, rect)
})
.collect();
self.workspaces
.reconfigure_live(
config.layout_mode,
config.default_column_width,
config.scrolling_focus_strategy,
&bounds,
)
.map_err(|error| error.to_string())?;
if let Some(workspaces) = workspaces {
self.workspaces = workspaces;
}
// Desired configuration: reloads update it while endpoints stay
// session-scoped, so a difference from the running service is reported
// as `restart_required` rather than applied behind the user's back.
self.xwayland_config = config.xwayland.clone();
if activities_changed {
self.activities
.configure(&config.activity_settings, config.activity_matcher.clone());
}
if layout_changed || self.gap_config != config.gap_config || panel_layout_changed {
// Hidden layouts are resolved independently of presented geometry.
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
+4 -1
View File
@@ -425,7 +425,10 @@ impl Ferese {
if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) {
tracing::warn!(%error, "cannot arm appearance schedule");
}
if candidate.theme == self.theme_engine.live.theme && self.theme_engine.pending.is_none() {
if candidate.theme == self.theme_engine.live.theme
&& self.theme_engine.pending.is_none()
&& self.wallpaper.configuration() == &wallpaper
{
let changed = self.theme_engine.live.mode != candidate.policy.mode
|| self.theme_engine.live.warnings != candidate.warnings
|| self.theme_engine.live.error.is_some()
+16 -40
View File
@@ -273,40 +273,6 @@ mod tests {
}
}
/// Send `payload` with `MSG_TRUNC`, which makes the kernel deliver a short
/// datagram and set `MSG_TRUNC` on the receiver.
fn send_truncated_from_child(path: &Path, payload: &[u8]) {
// SAFETY: the child performs only sendmsg and _exit, both
// async-signal-safe, on freshly created descriptors.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let mut iov = libc::iovec {
iov_base: payload.as_ptr().cast_mut().cast(),
iov_len: payload.len(),
};
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
// The kernel truncates the payload and reports MSG_TRUNC.
message.msg_flags = libc::MSG_TRUNC;
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
libc::_exit(if sent >= 0 { 0 } else { 71 });
}
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must send a truncated datagram"
);
}
}
/// Send `READY=1` from a child that also passes a descriptor.
///
/// The child stays alive so the receiver can name its PID, and the extra
@@ -377,13 +343,23 @@ mod tests {
fn a_truncated_notification_is_rejected_even_with_valid_credentials() {
let runtime = private_runtime_dir("truncated");
let socket = ReadinessSocket::create(&runtime, 21).unwrap();
let path = socket.path().to_owned();
send_truncated_from_child(&path, b"READY=1");
// Any PID is fine here: the point is the flag, not the credential.
let sender = UnixDatagram::unbound().unwrap();
sender.connect(socket.path()).unwrap();
let mut payload = b"READY=1\nSTATUS=".to_vec();
payload.resize(1024, b'x');
// Both the whole message and its received prefix would otherwise pass
// the parser. The kernel truncates it to the 512-byte receive buffer.
assert_eq!(exact_line(&payload, b"READY=1"), Some(true));
assert_eq!(exact_line(&payload[..512], b"READY=1"), Some(true));
sender.send(&payload).unwrap();
assert!(
!recv_within(&socket, u32::MAX),
"MSG_TRUNC must never satisfy readiness"
!recv_within(&socket, std::process::id()),
"an oversized message from the expected PID must be rejected"
);
sender.send(b"READY=1").unwrap();
assert!(
recv_within(&socket, std::process::id()),
"valid credentials must still be accepted"
);
}