Fix review findings across compositor and shell
This commit is contained in:
1 parent
5d9673aad3
commit
f7e5efb245
30 files changed
+1222
-181
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user