From 122e9ac7266cf69733a1ce94b5161cbf932193d7 Mon Sep 17 00:00:00 2001 From: Sreedev Kodichath Date: Wed, 5 Aug 2026 11:11:37 +0200 Subject: [PATCH] [ADD] ipc: a compositor that answers like niri A test of an arrangement needs more than one output. A nested niri instance gives one output with the name "winit", and that backend ignores a transform. The niri binary holds a headless backend, but the command line gives no access to it. This crate therefore holds a compositor of its own. It answers the same protocol on a socket of its own, and it applies the same rules: it keeps the scale factor between the limits, and it divides the physical size by the scale factor and removes the fraction. A test can then use many monitors, many scale factors and many transforms. The test gives the same result every time, and it needs no compositor and no hardware. The round trip test applies a plan and reads the outputs again. A second comparison with the same layout gives no more changes, which shows that the program reaches the state that the user asked for. The program also uses this compositor for a mode that shows the interface with no niri and no second monitor. --- crates/dynamonix-ipc/Cargo.toml | 1 + crates/dynamonix-ipc/src/fake.rs | 342 ++++++++++++++++++++++++ crates/dynamonix-ipc/src/lib.rs | 5 +- crates/dynamonix-ipc/tests/roundtrip.rs | 166 ++++++++++++ 4 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 crates/dynamonix-ipc/src/fake.rs create mode 100644 crates/dynamonix-ipc/tests/roundtrip.rs diff --git a/crates/dynamonix-ipc/Cargo.toml b/crates/dynamonix-ipc/Cargo.toml index 6c68df6..f2e7c68 100644 --- a/crates/dynamonix-ipc/Cargo.toml +++ b/crates/dynamonix-ipc/Cargo.toml @@ -17,6 +17,7 @@ thiserror.workspace = true tokio = { workspace = true, features = ["rt", "net", "io-util", "sync", "time", "macros"] } [dev-dependencies] +dynamonix-geom = { path = "../dynamonix-geom" } tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] } [lints] diff --git a/crates/dynamonix-ipc/src/fake.rs b/crates/dynamonix-ipc/src/fake.rs new file mode 100644 index 0000000..3ae3f41 --- /dev/null +++ b/crates/dynamonix-ipc/src/fake.rs @@ -0,0 +1,342 @@ +//! A compositor that answers like niri, for the tests and for a show. +//! +//! A nested niri instance gives only one output. That is not sufficient to test +//! an arrangement of many monitors. This module gives a server that speaks the +//! same protocol on a socket of its own. The server holds a group of the +//! outputs in its memory and changes them with the same rules as the +//! compositor. +//! +//! The server also gives the program a mode that shows the interface with no +//! monitor and no compositor. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use niri_ipc::{ + LogicalOutput, Mode, ModeToSet, Output, OutputAction, OutputConfigChanged, PositionToSet, + Reply, Request, Response, ScaleToSet, Transform, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::task::JoinHandle; + +use dynamonix_model::{Scale, logical_size, mode}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +type State = Arc>>; + +/// A compositor that answers like niri. +#[derive(Debug)] +pub struct FakeCompositor { + path: PathBuf, + handle: JoinHandle<()>, +} + +impl FakeCompositor { + /// Start a server that holds the outputs. + /// + /// The server makes a socket in the directory for the temporary files. + /// + /// # Errors + /// + /// The function gives an error if it cannot make the socket. + pub fn start(outputs: Vec) -> std::io::Result { + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "dynamonix-fake-{}-{count}.sock", + std::process::id() + )); + Self::start_at(path, outputs) + } + + /// Start a server that makes its socket at the path. + /// + /// # Errors + /// + /// The function gives an error if it cannot make the socket. + pub fn start_at(path: PathBuf, outputs: Vec) -> std::io::Result { + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path)?; + let state: State = Arc::new(Mutex::new( + outputs + .into_iter() + .map(|output| (output.name.clone(), output)) + .collect(), + )); + let handle = tokio::spawn(accept(listener, state)); + Ok(Self { path, handle }) + } + + /// Give the path of the socket of the server. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for FakeCompositor { + fn drop(&mut self) { + self.handle.abort(); + let _ = std::fs::remove_file(&self.path); + } +} + +async fn accept(listener: UnixListener, state: State) { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(serve(stream, state.clone())); + } +} + +async fn serve(stream: UnixStream, state: State) { + let (read, mut writer) = stream.into_split(); + let mut reader = BufReader::new(read); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + let Ok(request) = serde_json::from_str::(&line) else { + return; + }; + let streaming = matches!(request, Request::EventStream); + let reply = answer(&request, &state); + let Ok(mut text) = serde_json::to_string(&reply) else { + return; + }; + text.push('\n'); + if writer.write_all(text.as_bytes()).await.is_err() { + return; + } + if writer.flush().await.is_err() { + return; + } + if streaming { + hold(reader).await; + return; + } + } +} + +async fn hold(mut reader: BufReader) { + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + } +} + +fn answer(request: &Request, state: &State) -> Reply { + let mut outputs = state.lock().expect("the state is not poisoned"); + match request { + Request::Version => Ok(Response::Version("26.04 (dynamonix fake)".to_owned())), + Request::Outputs => Ok(Response::Outputs( + outputs + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + )), + Request::EventStream => Ok(Response::Handled), + Request::Output { output, action } => { + let Some(target) = outputs.get_mut(output) else { + return Ok(Response::OutputConfigChanged( + OutputConfigChanged::OutputWasMissing, + )); + }; + apply(target, action); + Ok(Response::OutputConfigChanged(OutputConfigChanged::Applied)) + } + _ => Err("the fake compositor does not answer this request".to_owned()), + } +} + +fn apply(output: &mut Output, action: &OutputAction) { + match action { + OutputAction::Off => output.logical = None, + OutputAction::On => { + if output.logical.is_none() { + let index = output + .modes + .iter() + .position(|item| item.is_preferred) + .unwrap_or(0); + output.current_mode = Some(index); + output.logical = Some(LogicalOutput { + x: 0, + y: 0, + width: 0, + height: 0, + scale: 1.0, + transform: Transform::Normal, + }); + resize(output); + } + } + OutputAction::Mode { mode: requested } => { + let found = match requested { + ModeToSet::Automatic => mode::preferred(&output.modes), + ModeToSet::Specific(value) => mode::matching(&output.modes, *value), + }; + if let Some(found) = found + && let Some(index) = output.modes.iter().position(|item| *item == found) + { + output.current_mode = Some(index); + resize(output); + } + } + OutputAction::Scale { scale } => { + if let Some(logical) = output.logical.as_mut() { + logical.scale = match scale { + ScaleToSet::Automatic => 1.0, + ScaleToSet::Specific(value) => Scale::clamped(*value).get(), + }; + resize(output); + } + } + OutputAction::Transform { transform } => { + if let Some(logical) = output.logical.as_mut() { + logical.transform = *transform; + resize(output); + } + } + OutputAction::Position { position } => { + if let Some(logical) = output.logical.as_mut() + && let PositionToSet::Specific(value) = position + { + logical.x = value.x; + logical.y = value.y; + } + } + OutputAction::Vrr { vrr } => output.vrr_enabled = vrr.vrr && output.vrr_supported, + OutputAction::CustomMode { .. } | OutputAction::Modeline { .. } => {} + } +} + +fn resize(output: &mut Output) { + let Some(current) = output + .current_mode + .and_then(|index| output.modes.get(index)) + else { + return; + }; + let current = *current; + if let Some(logical) = output.logical.as_mut() { + let size = logical_size(current, Scale::clamped(logical.scale), logical.transform); + logical.width = u32::try_from(size.width).unwrap_or(0); + logical.height = u32::try_from(size.height).unwrap_or(0); + } +} + +/// Make a monitor for a test or for a show. +#[must_use] +pub fn monitor(name: &str, make: &str, model: &str, sizes: &[(u16, u16, u32)]) -> Output { + let available: Vec = sizes + .iter() + .enumerate() + .map(|(index, (width, height, refresh_rate))| Mode { + width: *width, + height: *height, + refresh_rate: *refresh_rate, + is_preferred: index == 0, + }) + .collect(); + let mut output = Output { + name: name.to_owned(), + make: make.to_owned(), + model: model.to_owned(), + serial: Some(format!("{name}-0001")), + physical_size: Some((600, 340)), + modes: available, + current_mode: Some(0), + is_custom_mode: false, + vrr_supported: true, + vrr_enabled: false, + logical: Some(LogicalOutput { + x: 0, + y: 0, + width: 0, + height: 0, + scale: 1.0, + transform: Transform::Normal, + }), + }; + resize(&mut output); + output +} + +/// Make a group of the monitors that a user has frequently. +/// +/// The group has a laptop screen, a large monitor and a monitor that a user can +/// turn. The positions start at the origin and make one row. +#[must_use] +pub fn sample_desk() -> Vec { + let mut laptop = monitor( + "eDP-1", + "Lenovo", + "MNE007JA1-3", + &[ + (1920, 1200, 60_001), + (1920, 1080, 60_001), + (1280, 720, 60_001), + ], + ); + let mut wide = monitor( + "DP-2", + "Dell", + "U3423WE", + &[ + (3440, 1440, 143_999), + (3440, 1440, 59_997), + (2560, 1080, 59_997), + ], + ); + let mut side = monitor( + "HDMI-A-1", + "Dell", + "U2720Q", + &[ + (3840, 2160, 59_997), + (2560, 1440, 59_997), + (1920, 1080, 60_000), + ], + ); + place(&mut wide, 0, 0); + place(&mut laptop, 3440, 360); + place(&mut side, 5360, 0); + vec![laptop, wide, side] +} + +fn place(output: &mut Output, x: i32, y: i32) { + if let Some(logical) = output.logical.as_mut() { + logical.x = x; + logical.y = y; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sample_monitor_has_a_logical_size() { + let output = monitor("DP-1", "Make", "Model", &[(1920, 1080, 60_000)]); + let logical = output.logical.expect("the monitor is on"); + assert_eq!((logical.width, logical.height), (1920, 1080)); + } + + #[test] + fn the_sample_group_has_three_monitors_in_one_row() { + let outputs = sample_desk(); + assert_eq!(outputs.len(), 3); + for output in &outputs { + assert!(output.logical.is_some()); + } + } +} diff --git a/crates/dynamonix-ipc/src/lib.rs b/crates/dynamonix-ipc/src/lib.rs index 3e7066a..c4b92d4 100644 --- a/crates/dynamonix-ipc/src/lib.rs +++ b/crates/dynamonix-ipc/src/lib.rs @@ -3,11 +3,12 @@ //! The program speaks to the compositor only through its socket. The program //! does not read a configuration file and does not write a configuration file. //! -//! The crate has three parts: +//! The crate has four parts: //! //! - A [`Client`] sends the requests and applies a plan. //! - An [`EventStream`] receives the events of the compositor. //! - A [`Watcher`] reports a change of the outputs. +//! - A [`FakeCompositor`] answers like niri, for the tests and for a show. //! //! The compositor has no event for a change of the outputs. The [`Watcher`] //! therefore asks the compositor again at a regular time. See the `watch` @@ -16,9 +17,11 @@ pub mod client; pub mod error; pub mod events; +pub mod fake; pub mod watch; pub use client::{Client, Outcome, Report, StepResult, socket_path}; pub use error::{Error, Result}; pub use events::EventStream; +pub use fake::FakeCompositor; pub use watch::{Update, WatchConfig, Watcher}; diff --git a/crates/dynamonix-ipc/tests/roundtrip.rs b/crates/dynamonix-ipc/tests/roundtrip.rs new file mode 100644 index 0000000..c75381f --- /dev/null +++ b/crates/dynamonix-ipc/tests/roundtrip.rs @@ -0,0 +1,166 @@ +//! The examination of the full path from a layout to the compositor. +//! +//! The test uses the fake compositor. A nested niri instance gives only one +//! output, and that is not sufficient to test an arrangement of many monitors. + +use std::time::Duration; + +use dynamonix_geom::Point; +use dynamonix_ipc::fake::{FakeCompositor, sample_desk}; +use dynamonix_ipc::{Client, Update, WatchConfig, Watcher}; +use dynamonix_model::{Scale, TransformExt}; +use dynamonix_plan::{check, diff, is_safe}; +use niri_ipc::Transform; + +async fn started() -> (FakeCompositor, Client) { + let server = FakeCompositor::start(sample_desk()).expect("the server starts"); + let client = Client::connect_to(server.path()) + .await + .expect("the client connects"); + (server, client) +} + +#[tokio::test] +async fn the_client_reads_the_outputs_of_the_compositor() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + assert_eq!(snapshot.len(), 3); + assert_eq!(snapshot.enabled_count(), 3); + assert_eq!(snapshot.names(), vec!["DP-2", "HDMI-A-1", "eDP-1"]); +} + +#[tokio::test] +async fn a_layout_that_matches_the_compositor_needs_no_step() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + let plan = diff(&snapshot, &snapshot.to_layout()); + assert!(plan.is_empty(), "the plan has {} steps", plan.len()); +} + +#[tokio::test] +async fn the_compositor_reaches_the_wanted_layout() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.update("eDP-1", |config| { + config + .with_origin(Point::new(0, 1440)) + .with_scale(Scale::new(1.5).expect("the factor is valid")) + }); + wanted.update("HDMI-A-1", |config| config.with_transform(Transform::_90)); + + let problems = check(&wanted, &snapshot); + assert!(is_safe(&problems), "the layout is not safe: {problems:?}"); + + let plan = diff(&snapshot, &wanted); + assert!(!plan.is_empty()); + let report = client.apply(&plan).await.expect("the compositor answers"); + assert!(report.is_success(), "the compositor refused: {report:?}"); + + let after = client.snapshot().await.expect("the compositor answers"); + let remaining = diff(&after, &wanted); + assert!( + remaining.is_empty(), + "the layout did not converge: {:?}", + remaining.steps() + ); +} + +#[tokio::test] +async fn the_compositor_applies_a_scale_factor_and_a_turn() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.update("HDMI-A-1", |config| { + config + .with_transform(Transform::_270) + .with_scale(Scale::new(2.0).expect("the factor is valid")) + }); + let plan = diff(&snapshot, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let after = client.snapshot().await.expect("the compositor answers"); + let output = after.get("HDMI-A-1").expect("the output exists"); + let logical = output.logical.expect("the output is on"); + assert!(logical.transform.swaps_axes()); + assert_eq!((logical.width, logical.height), (1080, 1920)); +} + +#[tokio::test] +async fn the_program_can_turn_an_output_off_and_on() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.update("eDP-1", |config| config.with_enabled(false)); + let plan = diff(&snapshot, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let after = client.snapshot().await.expect("the compositor answers"); + assert_eq!(after.enabled_count(), 2); + + let back = after.to_layout(); + let mut wanted = back.clone(); + wanted.update("eDP-1", |config| config.with_enabled(true)); + let plan = diff(&after, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let final_state = client.snapshot().await.expect("the compositor answers"); + assert_eq!(final_state.enabled_count(), 3); +} + +#[tokio::test] +async fn a_change_for_an_output_that_is_not_connected_waits() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.insert( + "DP-9", + dynamonix_model::OutputConfig::default().with_origin(Point::new(9000, 0)), + ); + let plan = diff(&snapshot, &wanted); + let report = client.apply(&plan).await.expect("the compositor answers"); + assert!(report.is_success()); + assert!(report.staged_count() > 0); +} + +#[tokio::test] +async fn the_watcher_reports_the_outputs_and_then_a_change() { + let server = FakeCompositor::start(sample_desk()).expect("the server starts"); + let mut watcher = Watcher::start( + server.path().to_path_buf(), + WatchConfig { + interval: Duration::from_millis(50), + retry: Duration::from_millis(50), + }, + ); + + let first = watcher + .next_update() + .await + .expect("the task sends a message"); + let Update::Outputs(snapshot) = first else { + panic!("the first message must hold the outputs"); + }; + assert_eq!(snapshot.enabled_count(), 3); + + let mut client = Client::connect_to(server.path()) + .await + .expect("the client connects"); + let mut wanted = snapshot.to_layout(); + wanted.update("eDP-1", |config| config.with_enabled(false)); + let plan = diff(&snapshot, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let next = tokio::time::timeout(Duration::from_secs(5), watcher.next_update()) + .await + .expect("the task reports the change in time") + .expect("the task sends a message"); + let Update::Outputs(after) = next else { + panic!("the message must hold the outputs"); + }; + assert_eq!(after.enabled_count(), 2); +} -- 2.51.2