diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 53c091e..06b6fe7 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -30,9 +30,9 @@ serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true xcursor.workspace = true +tempfile.workspace = true [dev-dependencies] -tempfile.workspace = true [features] # Opt-in counters/histograms with a per-client exit summary. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index fb7e6f3..859a6ac 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -3,12 +3,14 @@ mod bindings; mod input_motion; mod layout; mod outputs; +mod xwayland; use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt; use std::path::PathBuf; +pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup}; use appearance::*; pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings}; use bindings::*; @@ -61,6 +63,8 @@ pub struct Config { scrolling: ScrollingConfig, #[serde(default)] output_profiles: Vec, + #[serde(default)] + pub(crate) xwayland: XwaylandConfig, #[serde(default, rename = "status")] _status: ShellStatusConfig, } @@ -114,6 +118,7 @@ impl Config { pub(crate) fn runtime_config(&self) -> Result { self.notifications.validate().map_err(ConfigError::Binding)?; self.desktop_widgets.validate().map_err(ConfigError::Binding)?; + self.xwayland.validate().map_err(ConfigError::Binding)?; for daemon in &self.autostart { if daemon.command.first().is_none_or(|program| program.trim().is_empty()) { return Err(ConfigError::Binding("autostart command must contain a program".into())); @@ -143,6 +148,7 @@ impl Config { output_profiles: self.output_profiles()?, wallpaper: self.wallpaper_settings(), overview_font_family: self.overview_font_family(), + xwayland: self.xwayland.clone(), }) } @@ -517,10 +523,13 @@ mod tests { #[test] fn packaged_and_custom_kdl_pass_runtime_validation() { - Config::parse_source(include_str!("../../packaging/config.kdl")) - .unwrap() - .runtime_config() - .unwrap(); + let packaged = Config::parse_source(include_str!("../../packaging/config.kdl")).unwrap(); + packaged.runtime_config().unwrap(); + + assert!( + packaged.xwayland.enabled, + "packaging/config.kdl must ship X11 support enabled, matching the default" + ); let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n"; let config = Config::parse_source(source).unwrap(); config.runtime_config().unwrap(); @@ -557,6 +566,40 @@ mod tests { ferese_config::from_str(source).unwrap() } + #[test] + fn an_empty_configuration_enables_the_managed_x11_service() { + let config = parse(""); + + assert_eq!(config.xwayland, XwaylandConfig::default()); + assert!(config.xwayland.enabled, "an empty config keeps the default"); + assert_eq!(config.xwayland.startup, XwaylandStartup::OnDemand); + let runtime = config.runtime_config().expect("an empty config is valid"); + assert!(runtime.xwayland.enabled); + } + + #[test] + fn a_configuration_without_an_xwayland_section_keeps_the_defaults() { + let source = r#" + layout-mode "scrolling"; + desktop-widgets { + clock { + time-format "%H:%M"; + }; + } + scroll-factor 1.0; + "#; + let config = parse(source); + + assert!( + source.find("xwayland").is_none(), + "the sample must have no xwayland node" + ); + assert_eq!(config.xwayland, XwaylandConfig::default()); + assert!(config.xwayland.enabled); + let runtime = config.runtime_config().expect("the existing config stays valid"); + assert!(runtime.xwayland.enabled); + } + #[test] fn defaults_to_half_width_scrolling_columns() { let config = parse(""); @@ -1084,7 +1127,7 @@ mod tests { #[test] fn parses_and_validates_window_rules() { let config = parse( - "window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false block-out-from-screencasts=#true\n", + "window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 min-width=500 min-height=400 fullscreen=#false block-out-from-screencasts=#true\n", ); let rules = config.window_rules().unwrap(); let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false); @@ -1093,6 +1136,8 @@ mod tests { assert_eq!(result.floating, Some(true)); assert_eq!(result.width, Some(900.0)); assert_eq!(result.height, Some(600.0)); + assert_eq!(result.min_width, Some(500.0)); + assert_eq!(result.min_height, Some(400.0)); assert_eq!(result.fullscreen, Some(false)); assert_eq!(result.block_out_from_screencasts, Some(true)); } @@ -1101,9 +1146,11 @@ mod tests { fn rejects_invalid_window_rule_configuration() { let catch_all = parse("window-rule floating=#true\n"); let zero_workspace = parse("window-rule app-id=\"editor\" workspace=0\n"); + let zero_minimum = parse("window-rule app-id=\"editor\" min-width=0\n"); assert!(catch_all.window_rules().is_err()); assert!(zero_workspace.window_rules().is_err()); + assert!(zero_minimum.window_rules().is_err()); } #[test] diff --git a/compositor/src/config/xwayland.rs b/compositor/src/config/xwayland.rs new file mode 100644 index 0000000..7a81de7 --- /dev/null +++ b/compositor/src/config/xwayland.rs @@ -0,0 +1,101 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum XwaylandStartup { + #[default] + OnDemand, + Eager, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct XwaylandConfig { + pub enabled: bool, + pub startup: XwaylandStartup, + pub path: PathBuf, +} + +impl Default for XwaylandConfig { + fn default() -> Self { + Self { + enabled: true, + startup: XwaylandStartup::OnDemand, + path: PathBuf::from("xwayland-satellite"), + } + } +} + +impl XwaylandConfig { + pub fn validate(&self) -> Result<(), String> { + if self.enabled && self.path.as_os_str().is_empty() { + return Err("xwayland.path must name an executable".into()); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_enable_on_demand_with_satellite_on_path() { + let config = XwaylandConfig::default(); + + assert!(config.enabled); + assert_eq!(config.startup, XwaylandStartup::OnDemand); + assert_eq!(config.path, PathBuf::from("xwayland-satellite")); + assert_eq!(config.validate(), Ok(())); + } + + #[test] + fn disabled_config_does_not_require_a_path() { + let config = XwaylandConfig { + enabled: false, + path: PathBuf::new(), + ..XwaylandConfig::default() + }; + + assert_eq!(config.validate(), Ok(())); + } + + #[test] + fn enabled_config_rejects_an_empty_path() { + let config = XwaylandConfig { + enabled: true, + path: PathBuf::new(), + ..XwaylandConfig::default() + }; + + assert_eq!( + config.validate(), + Err("xwayland.path must name an executable".to_owned()) + ); + } + + #[test] + fn startup_mode_accepts_kebab_case() { + #[derive(Deserialize)] + struct Wrapper { + startup: XwaylandStartup, + } + + assert_eq!( + serde_json::from_str::(r#"{"startup":"eager"}"#) + .unwrap() + .startup, + XwaylandStartup::Eager + ); + assert_eq!( + serde_json::from_str::(r#"{"startup":"on-demand"}"#) + .unwrap() + .startup, + XwaylandStartup::OnDemand + ); + assert!(serde_json::from_str::(r#"{"startup":"eagerly"}"#).is_err()); + } +} diff --git a/compositor/src/grabs/resize_grab.rs b/compositor/src/grabs/resize_grab.rs index f830dde..f907c10 100644 --- a/compositor/src/grabs/resize_grab.rs +++ b/compositor/src/grabs/resize_grab.rs @@ -150,7 +150,18 @@ impl PointerGrab for ResizeSurfaceGrab { let state = cached.current(); (state.min_size, state.max_size) }); - self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum); + let (minimum, maximum) = data.effective_size_constraints( + &self.window, + (minimum.w, minimum.h), + (maximum.w, maximum.h), + ); + self.last_size = constrained_size( + self.initial_rect.size, + delta, + self.edges, + minimum, + (maximum.0, maximum.1), + ); let rect = resized_rect(self.initial_rect, self.last_size, self.edges); data.set_floating_window_geometry(&self.window, rect.loc, rect.size); if let Some(id) = data.windows.ids().get(&self.window).copied() { @@ -303,8 +314,8 @@ fn constrained_size( initial: Size, delta: Point, edges: ResizeEdge, - minimum: Size, - maximum: Size, + minimum: (i32, i32), + maximum: (i32, i32), ) -> Size { let mut width = initial.w; let mut height = initial.h; @@ -322,17 +333,17 @@ fn constrained_size( // Protocol validation rejects these ranges, but a grab may still be alive // when its client is disconnected. Keep this path safe for any cached state. - let minimum_width = minimum.w.max(1); - let minimum_height = minimum.h.max(1); - let maximum_width = if maximum.w <= 0 { + let minimum_width = minimum.0.max(1); + let minimum_height = minimum.1.max(1); + let maximum_width = if maximum.0 <= 0 { i32::MAX } else { - maximum.w.max(minimum_width) + maximum.0.max(minimum_width) }; - let maximum_height = if maximum.h <= 0 { + let maximum_height = if maximum.1 <= 0 { i32::MAX } else { - maximum.h.max(minimum_height) + maximum.1.max(minimum_height) }; ( width.clamp(minimum_width, maximum_width), @@ -394,8 +405,8 @@ mod tests { (800, 600).into(), (100.0, 50.0).into(), ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft), - (1, 1).into(), - (0, 0).into(), + (1, 1), + (0, 0), ); assert_eq!(size, (700, 550).into()); } @@ -406,8 +417,8 @@ mod tests { (800, 600).into(), (100.0, 50.0).into(), ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), - (1, 1).into(), - (0, 0).into(), + (1, 1), + (0, 0), ); assert_eq!(size, (900, 650).into()); } @@ -418,8 +429,8 @@ mod tests { (800, 600).into(), (1_000.0, 1_000.0).into(), ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), - (640, 480).into(), - (1_024, 768).into(), + (640, 480), + (1_024, 768), ); assert_eq!(size, (1_024, 768).into()); } @@ -437,8 +448,8 @@ mod tests { (800, 600).into(), delta.into(), ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), - minimum.into(), - maximum.into(), + minimum, + maximum, ); assert!(size.w >= 1 && size.h >= 1); let _ = resized_rect( diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 9a39221..704240b 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -11,6 +11,7 @@ mod xdg_shell; use smithay::desktop::{PopupManager, layer_map_for_output}; use smithay::input::pointer::{CursorImageStatus, PointerHandle}; use smithay::input::{Seat, SeatHandler, SeatState}; +use smithay::reexports::wayland_protocols::xdg::shell::server::{xdg_popup, xdg_positioner, xdg_surface, xdg_wm_base}; use smithay::reexports::wayland_server::Resource; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::wayland::compositor::{TraversalAction, with_states, with_surface_tree_downward}; @@ -30,6 +31,9 @@ use smithay::wayland::selection::data_device::{ use smithay::wayland::selection::primary_selection::{ PrimarySelectionHandler, PrimarySelectionState, set_primary_focus, }; +use smithay::wayland::shell::xdg::{ + XdgPositionerUserData, XdgShellState, XdgShellSurfaceUserData, XdgSurfaceUserData, XdgWmBaseUserData, +}; use smithay::wayland::tablet_manager::TabletSeatHandler; use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState}; use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler; @@ -338,7 +342,21 @@ smithay::delegate_viewporter!(Ferese); smithay::delegate_xdg_activation!(Ferese); smithay::delegate_xdg_decoration!(Ferese); smithay::delegate_xdg_foreign!(Ferese); -smithay::delegate_xdg_shell!(Ferese); +smithay::reexports::wayland_server::delegate_global_dispatch!( + Ferese: [xdg_wm_base::XdgWmBase: ()] => XdgShellState +); +smithay::reexports::wayland_server::delegate_dispatch!( + Ferese: [xdg_wm_base::XdgWmBase: XdgWmBaseUserData] => XdgShellState +); +smithay::reexports::wayland_server::delegate_dispatch!( + Ferese: [xdg_positioner::XdgPositioner: XdgPositionerUserData] => XdgShellState +); +smithay::reexports::wayland_server::delegate_dispatch!( + Ferese: [xdg_popup::XdgPopup: XdgShellSurfaceUserData] => XdgShellState +); +smithay::reexports::wayland_server::delegate_dispatch!( + Ferese: [xdg_surface::XdgSurface: XdgSurfaceUserData] => XdgShellState +); smithay::delegate_xdg_toplevel_icon!(Ferese); smithay::delegate_data_control!(Ferese); diff --git a/compositor/src/handlers/screenshot_worker.rs b/compositor/src/handlers/screenshot_worker.rs index b2ef510..5336fe0 100644 --- a/compositor/src/handlers/screenshot_worker.rs +++ b/compositor/src/handlers/screenshot_worker.rs @@ -54,9 +54,8 @@ impl Worker { fn run(receiver: Receiver, results: channel::SyncSender) { while let Ok(job) = receiver.recv() { - let result = encode(&job); let request = job.request; - drop(job); + let result = encode(job); // This send cannot block. Each request produces exactly one Encode, so at // most QUEUE_CAPACITY jobs can be queued while one more is being encoded, // and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here @@ -99,8 +98,10 @@ pub(crate) fn sweep_stale_files() { } } -fn encode(job: &Job) -> Result { +fn encode(job: Job) -> Result { let canvas = compose(&job.frames)?; + drop(job); + let png = encode_png(&canvas)?; write_private_file(&png) } @@ -114,6 +115,7 @@ fn encode_png(canvas: &Canvas) -> Result, String> { } let image = image::ImageBuffer::, _>::from_raw(width, height, canvas.pixels.as_slice()) .ok_or("Screenshot buffer does not match its dimensions")?; + let mut out = Vec::new(); let encoder = image::codecs::png::PngEncoder::new_with_quality( std::io::Cursor::new(&mut out), diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index ef80f96..7944edf 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -5,15 +5,17 @@ use smithay::desktop::{ use smithay::input::Seat; use smithay::input::pointer::{Focus, GrabStartData}; use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode; +use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as ToplevelState; -use smithay::reexports::wayland_server::Resource; use smithay::reexports::wayland_server::protocol::wl_seat; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, Resource, backend::ClientId}; use smithay::utils::{Logical, Point, Rectangle, Serial}; use smithay::wayland::compositor::with_states; use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler; use smithay::wayland::shell::xdg::{ - PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData, + PopupSurface, PositionerState, SurfaceCachedState, ToplevelSurface, XdgShellHandler, XdgShellState, + XdgShellSurfaceUserData, XdgToplevelSurfaceData, }; use crate::Ferese; @@ -467,6 +469,72 @@ fn popup_constraint_target( output } +impl Dispatch for Ferese { + fn request( + state: &mut Self, + client: &Client, + resource: &xdg_toplevel::XdgToplevel, + request: xdg_toplevel::Request, + data: &XdgShellSurfaceUserData, + dhandle: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + let unusable = match &request { + xdg_toplevel::Request::SetMinSize { width, height } => { + !size_limits_usable(state, resource, (*width, *height), true) + } + xdg_toplevel::Request::SetMaxSize { width, height } => { + !size_limits_usable(state, resource, (*width, *height), false) + } + _ => false, + }; + if unusable { + resource.post_error(xdg_toplevel::Error::InvalidSize, "size limits are unusable"); + return; + } + + >::request( + state, client, resource, request, data, dhandle, data_init, + ); + } + + fn destroyed( + state: &mut Self, + client: ClientId, + resource: &xdg_toplevel::XdgToplevel, + data: &XdgShellSurfaceUserData, + ) { + >::destroyed( + state, client, resource, data, + ); + } +} + +fn size_limits_usable( + state: &Ferese, + toplevel: &xdg_toplevel::XdgToplevel, + proposed: (i32, i32), + minimum: bool, +) -> bool { + if proposed.0 < 0 || proposed.1 < 0 { + return false; + } + + let Some(surface) = state.xdg_shell_state.get_toplevel(toplevel) else { + return true; + }; + let (min, max) = with_states(surface.wl_surface(), |states| { + let mut cached = states.cached_state.get::(); + let pending = cached.pending(); + ( + (pending.min_size.w, pending.min_size.h), + (pending.max_size.w, pending.max_size.h), + ) + }); + let (min, max) = if minimum { (proposed, max) } else { (min, proposed) }; + (max.0 == 0 || min.0 <= max.0) && (max.1 == 0 || min.1 <= max.1) +} + #[cfg(test)] mod tests { use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner; diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 61322f1..9dfdbdd 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -535,6 +535,7 @@ impl Ferese { | "input-capture-disable" | "input-capture-release" | "get-session-state" + | ferese_ipc::xwayland::STATUS_COMMAND | "portal-inhibit" | "portal-monitor-register" | "portal-monitor-ack" @@ -576,6 +577,14 @@ impl Ferese { "get-session-state" => { return Ok(self.portal_session.snapshot()); } + ferese_ipc::xwayland::STATUS_COMMAND => { + return Ok(crate::xwayland::status_snapshot(self).to_value()); + } + ferese_ipc::xwayland::RETRY_COMMAND => { + crate::xwayland::retry(self).map_err(|error| CommandError::new("x11_unavailable", error))?; + + return Ok(crate::xwayland::status_snapshot(self).to_value()); + } "portal-monitor-register" => { self.portal_session .register_monitor(owner) diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 0b5d80f..de3c7cf 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -32,6 +32,7 @@ mod render; mod resize_metrics; mod resize_transaction; mod resume; +mod session_environment; mod session_lock; mod shell_control; mod stacking; @@ -42,6 +43,9 @@ mod theme; mod wallpaper; mod window_rules; mod winit; +mod xwayland; + +pub(crate) use session_environment::{SessionEnvironment, X11Environment}; use std::error::Error; use std::io; @@ -109,6 +113,8 @@ fn main() -> Result<(), Box> { overview::init_font_loader(&mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?; + xwayland::initialize(&mut state); + info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); let child = spawn_client(&mut state, launch.client, launch.client_capabilities) @@ -131,6 +137,8 @@ fn main() -> Result<(), Box> { terminate_child(&mut child); } + xwayland::shutdown_after_loop(&mut state); + result.map_err(Into::into) } diff --git a/compositor/src/process.rs b/compositor/src/process.rs index 837915e..baf38f4 100644 --- a/compositor/src/process.rs +++ b/compositor/src/process.rs @@ -52,9 +52,8 @@ pub(crate) fn spawn_client>( let mut command = command(program); command.args(args); // Workers may already exist. Change only this child's environment. - command.env("WAYLAND_DISPLAY", &state.socket_name); - command.env_remove("WAYLAND_SOCKET"); - command.env_remove("FERESE_SHELL_CONTROL_SOCKET"); + // Apply before granting the child a private Wayland connection. + state.session_environment.apply_public(&mut command); command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); let private_connection = if capabilities.is_empty() { None @@ -83,6 +82,11 @@ pub(crate) fn spawn_client>( } pub(crate) fn pidfd(pid: u32) -> io::Result { + #[cfg(test)] + if crate::xwayland::test_hooks::PIDFD_UNAVAILABLE.load(std::sync::atomic::Ordering::SeqCst) { + return Err(io::Error::from_raw_os_error(libc::ENOSYS)); + } + // SAFETY: pidfd_open has no pointer arguments and returns a new owned fd. let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; if fd < 0 { diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index 31b9a6d..c423acc 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -513,6 +513,58 @@ mod tests { assert!(!state.inactive_dim.enabled); } + /// The status endpoint must be able to tell "the live service is running + /// with the settings it started with" from "the file was edited", so the + /// desired X11 configuration has to follow live reloads. + #[test] + fn xwayland_configuration_follows_live_reloads() { + const CHILD: &str = "FERESE_XWAYLAND_RELOAD_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + use std::os::unix::fs::PermissionsExt; + let directory = Directory::new(); + std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "reload::tests::xwayland_configuration_follows_live_reloads", + "--nocapture", + ]) + .env(CHILD, "1") + .env("XDG_RUNTIME_DIR", &directory.0) + .env("XDG_CONFIG_HOME", &directory.0) + .env_remove("FERESE_SOCKET") + .output() + .unwrap(); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + return; + } + + let directory = Directory::new(); + let (_, runtime, _) = crate::theme::prepare("xwayland { enabled #false; }", &directory.0).unwrap(); + let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let display = smithay::reexports::wayland_server::Display::new().unwrap(); + let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap(); + + assert!( + !state.xwayland_config.enabled, + "the session starts with the configured value" + ); + + state + .reload_config_source("xwayland { enabled #true; startup \"eager\"; }".into()) + .unwrap(); + + assert!( + state.xwayland_config.enabled, + "a reload must update the desired configuration, or restart_required can never become true" + ); + assert_eq!( + state.xwayland_config.startup, + crate::config::XwaylandStartup::Eager, + "every field follows the reload, not just the enabled flag" + ); + } + #[test] fn invalid_monitor_reload_keeps_last_good_runtime_configuration() { const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD"; diff --git a/compositor/src/session_environment.rs b/compositor/src/session_environment.rs new file mode 100644 index 0000000..ca2a06b --- /dev/null +++ b/compositor/src/session_environment.rs @@ -0,0 +1,138 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct X11Environment { + pub display: OsString, + pub authority: PathBuf, +} + +// Child endpoints are separate from the host endpoints used by the nested backend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SessionEnvironment { + pub wayland_display: OsString, + pub x11: Option, +} + +impl SessionEnvironment { + /// Apply before private_client::prepare_command(), which overrides WAYLAND_SOCKET. + pub fn apply_public(&self, command: &mut Command) { + command + .env("WAYLAND_DISPLAY", &self.wayland_display) + .env("FERESE_PUBLIC_WAYLAND_DISPLAY", &self.wayland_display) + .env_remove("WAYLAND_SOCKET") + .env_remove("FERESE_SHELL_CONTROL_SOCKET") + .env_remove("DISPLAY") + .env_remove("XAUTHORITY"); + + if let Some(x11) = &self.x11 { + command.env("DISPLAY", &x11.display).env("XAUTHORITY", &x11.authority); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn environment(x11: Option) -> SessionEnvironment { + SessionEnvironment { + wayland_display: OsString::from("wayland-7"), + x11, + } + } + + fn rendered(command: &Command) -> Vec<(String, Option)> { + command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + fn value_of(command: &Command, key: &str) -> Option> { + rendered(command) + .into_iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value) + } + + #[test] + fn x11_available_publishes_display_and_authority() { + let mut command = Command::new("true"); + environment(Some(X11Environment { + display: OsString::from(":7"), + authority: PathBuf::from("/run/user/1000/ferese-xauth-abc"), + })) + .apply_public(&mut command); + + assert_eq!(value_of(&command, "DISPLAY"), Some(Some(":7".to_owned()))); + assert_eq!( + value_of(&command, "XAUTHORITY"), + Some(Some("/run/user/1000/ferese-xauth-abc".to_owned())) + ); + assert_eq!( + value_of(&command, "WAYLAND_DISPLAY"), + Some(Some("wayland-7".to_owned())) + ); + } + + #[test] + fn x11_unavailable_removes_display_and_authority() { + let mut command = Command::new("true"); + environment(None).apply_public(&mut command); + + assert_eq!(value_of(&command, "DISPLAY"), Some(None)); + assert_eq!(value_of(&command, "XAUTHORITY"), Some(None)); + } + + #[test] + fn the_host_private_socket_is_always_cleared() { + let mut command = Command::new("true"); + environment(None).apply_public(&mut command); + + assert_eq!(value_of(&command, "WAYLAND_SOCKET"), Some(None)); + assert_eq!(value_of(&command, "FERESE_SHELL_CONTROL_SOCKET"), Some(None)); + } + + #[test] + fn the_public_socket_is_exported_for_shell_launched_apps() { + let mut command = Command::new("true"); + environment(None).apply_public(&mut command); + + assert_eq!( + value_of(&command, "FERESE_PUBLIC_WAYLAND_DISPLAY"), + Some(Some("wayland-7".to_owned())) + ); + } + + #[test] + fn unrelated_variables_are_left_alone() { + let mut command = Command::new("true"); + command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); + environment(None).apply_public(&mut command); + + assert_eq!( + value_of(&command, "FERESE_COMPOSITOR_WALLPAPER"), + Some(Some("1".to_owned())) + ); + } + + #[test] + fn a_host_display_is_replaced_by_the_session_display() { + let mut command = Command::new("true"); + command.env("DISPLAY", ":0"); + environment(Some(X11Environment { + display: OsString::from(":7"), + authority: PathBuf::from("/run/user/1000/auth"), + })) + .apply_public(&mut command); + + assert_eq!(value_of(&command, "DISPLAY"), Some(Some(":7".to_owned()))); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 73c9b9c..fd30da1 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -402,6 +402,14 @@ pub struct Ferese { pub(crate) theme_engine: crate::theme::Engine, pub start_time: Instant, pub socket_name: OsString, + + pub(crate) session_environment: crate::SessionEnvironment, + + pub(crate) xwayland: Option, + + pub(crate) xwayland_config: crate::config::XwaylandConfig, + + pub(crate) x11_diagnostic: Option, pub display_handle: DisplayHandle, pub loop_signal: LoopSignal, pub(crate) loop_handle: LoopHandle<'static, Self>, @@ -552,6 +560,7 @@ pub struct RuntimeConfig { pub spring_config: SpringConfig, pub viewport_spring_config: SpringConfig, pub output_profiles: Vec, + pub(crate) xwayland: crate::config::XwaylandConfig, } impl Ferese { @@ -643,7 +652,15 @@ impl Ferese { config_worker: None, theme_engine: Default::default(), start_time, + + session_environment: crate::SessionEnvironment { + wayland_display: socket_name.clone(), + x11: None, + }, socket_name, + xwayland: None, + xwayland_config: config.xwayland.clone(), + x11_diagnostic: None, display_handle, loop_signal: event_loop.get_signal(), loop_handle: event_loop.handle(), @@ -793,6 +810,11 @@ 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(); + let layout_changed = changed(&["layout", "scrolling", "workspaces"]); let bindings_changed = changed(&["bindings", "commands"]); let input_changed = changed(&["input"]); diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs index fa49eec..cf781d3 100644 --- a/compositor/src/state/floating.rs +++ b/compositor/src/state/floating.rs @@ -300,16 +300,21 @@ impl Ferese { let constraints = window .toplevel() .map(|top| { - with_states(top.wl_surface(), |states| { + let (minimum, maximum) = with_states(top.wl_surface(), |states| { let mut cache = states.cached_state.get::(); let state = cache.current(); - SizeConstraints { - min_width: state.min_size.w.max(1) as f64, - min_height: state.min_size.h.max(1) as f64, - max_width: (state.max_size.w > 0).then_some(state.max_size.w as f64), - max_height: (state.max_size.h > 0).then_some(state.max_size.h as f64), - } - }) + ( + (state.min_size.w, state.min_size.h), + (state.max_size.w, state.max_size.h), + ) + }); + let (minimum, maximum) = self.effective_size_constraints(window, minimum, maximum); + SizeConstraints { + min_width: minimum.0.max(1) as f64, + min_height: minimum.1.max(1) as f64, + max_width: (maximum.0 > 0).then_some(maximum.0 as f64), + max_height: (maximum.1 > 0).then_some(maximum.1 as f64), + } }) .unwrap_or_default(); let stored_size = if parent_rect.is_none() { diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 42bef2a..d1a05b8 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -574,14 +574,19 @@ impl Ferese { let state = cached.current(); (state.min_size, state.max_size) }); + let (minimum, maximum) = self.effective_size_constraints( + window, + (minimum.w, minimum.h), + (maximum.w, maximum.h), + ); Some(( *id, SizeConstraints { - min_width: minimum.w.max(1) as f64, - min_height: minimum.h.max(1) as f64, - max_width: (maximum.w > 0).then_some(maximum.w as f64), - max_height: (maximum.h > 0).then_some(maximum.h as f64), + min_width: minimum.0.max(1) as f64, + min_height: minimum.1.max(1) as f64, + max_width: (maximum.0 > 0).then_some(maximum.0 as f64), + max_height: (maximum.1 > 0).then_some(maximum.1 as f64), }, )) }) diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 0a14714..6cb4481 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -463,3 +463,41 @@ fn malformed_resize_limits_disconnect_only_the_client_and_motion_survives() { let (healthy, _wire) = window(&mut state, &mut events, 0xff112233); assert!(state.windows.ids().contains_key(&healthy)); } + +#[test] +fn window_rule_minimum_size_replaces_the_client_advertised_minimum() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::window_rule_minimum_size_replaces_the_client_advertised_minimum", + ) { + return; + } + + let (mut events, mut state, _) = fixture(); + let (window, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + request(&mut wire, 4, 8, &[800, 600], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 600)); + assert_eq!(state.window_constraints()[&id].min_width, 800.0); + assert_eq!(state.window_constraints()[&id].min_height, 600.0); + + state.window_rules = + crate::config::Config::parse_source("window-rule transient=#false min-width=500 min-height=400\n") + .unwrap() + .window_rules() + .unwrap(); + assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (500, 400)); + let constraints = state.window_constraints(); + assert_eq!(constraints[&id].min_width, 500.0); + assert_eq!(constraints[&id].min_height, 400.0); + + state.window_rules = crate::config::Config::parse_source("window-rule transient=#false min-height=400\n") + .unwrap() + .window_rules() + .unwrap(); + assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 400)); + + state.window_rules.clear(); + assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 600)); +} diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 68fc3f7..a3e0edd 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -261,8 +261,42 @@ impl Ferese { self.apply_window_rule_result(window, rule); } + pub(crate) fn effective_size_constraints( + &self, + window: &Window, + minimum: (i32, i32), + maximum: (i32, i32), + ) -> ((i32, i32), (i32, i32)) { + let Some(toplevel) = window.toplevel() else { + return (minimum, maximum); + }; + let rules = &self.window_rules; + with_states(toplevel.wl_surface(), |states| { + let attributes = states + .data_map + .get::() + .expect("xdg toplevel state exists") + .lock() + .expect("xdg toplevel state is not poisoned"); + let rule = resolve_window_rules( + rules, + attributes.app_id.as_deref(), + attributes.title.as_deref(), + attributes.parent.is_some(), + ); + ( + ( + rule.min_width.map_or(minimum.0, |width| width.ceil() as i32), + rule.min_height.map_or(minimum.1, |height| height.ceil() as i32), + ), + maximum, + ) + }) + } + pub(super) fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) { let windows = self.windows.ids().keys().cloned().collect::>(); + let mut minimums_changed = false; for window in windows { let Some(toplevel) = window.toplevel() else { continue; @@ -278,10 +312,14 @@ impl Ferese { }); let old = resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient); let new = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); + minimums_changed |= old.min_width != new.min_width || old.min_height != new.min_height; if let Some(new) = crate::window_rules::live_result(old, new, transient) { self.apply_window_rule_result(&window, new); } } + if minimums_changed { + self.relayout(); + } } pub(super) fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) { diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index b1239d8..63b132c 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -9,6 +9,8 @@ pub(crate) struct WindowRuleConfig { pub floating: Option, pub width: Option, pub height: Option, + pub min_width: Option, + pub min_height: Option, pub fullscreen: Option, pub block_out_from_screencasts: Option, pub idle_inhibit: Option, @@ -23,6 +25,8 @@ pub struct WindowRule { floating: Option, width: Option, height: Option, + min_width: Option, + min_height: Option, fullscreen: Option, block_out_from_screencasts: Option, idle_inhibit: Option, @@ -40,6 +44,8 @@ pub struct WindowRuleResult { pub floating: Option, pub width: Option, pub height: Option, + pub min_width: Option, + pub min_height: Option, pub fullscreen: Option, pub block_out_from_screencasts: Option, pub idle_inhibit: Option, @@ -56,6 +62,10 @@ pub(crate) fn live_result( new.block_out_from_screencasts = None; old.idle_inhibit = None; new.idle_inhibit = None; + old.min_width = None; + old.min_height = None; + new.min_width = None; + new.min_height = None; if old == new { return None; } @@ -106,6 +116,8 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>, result.floating = rule.floating.or(result.floating); result.width = rule.width.or(result.width); result.height = rule.height.or(result.height); + result.min_width = rule.min_width.or(result.min_width); + result.min_height = rule.min_height.or(result.min_height); result.fullscreen = rule.fullscreen.or(result.fullscreen); result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts); result.idle_inhibit = rule.idle_inhibit.or(result.idle_inhibit); @@ -138,6 +150,8 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result Result String { #[cfg(test)] mod tests { - #[test] - fn live_rule_removal_reverts_placement_but_unchanged_rules_preserve_manual_state() { - let old = super::WindowRuleResult { - floating: Some(true), - fullscreen: Some(true), - ..Default::default() - }; - assert_eq!(super::live_result(old, old, false), None); - let removed = super::live_result(old, Default::default(), false).unwrap(); - assert_eq!(removed.floating, Some(false)); - assert_eq!(removed.fullscreen, Some(false)); - assert_eq!( - super::live_result(old, Default::default(), true).unwrap().floating, - Some(true) - ); - let new = super::WindowRuleResult { - width: Some(800.), - ..Default::default() - }; - assert_eq!(super::live_result(old, new, false).unwrap().floating, None); - } use super::*; fn config(app_id: &str) -> WindowRuleConfig { @@ -226,6 +221,8 @@ mod tests { floating: None, width: None, height: None, + min_width: None, + min_height: None, fullscreen: None, block_out_from_screencasts: None, idle_inhibit: None, @@ -358,6 +355,8 @@ mod tests { floating: Some(true), width: None, height: None, + min_width: None, + min_height: None, fullscreen: None, block_out_from_screencasts: None, idle_inhibit: None, @@ -366,8 +365,91 @@ mod tests { width: Some(f64::NAN), ..config("editor") }; + let invalid_minimum = WindowRuleConfig { + min_width: Some(0.0), + ..config("editor") + }; assert!(validate(&[catch_all]).is_err()); assert!(validate(&[invalid_size]).is_err()); + assert!(validate(&[invalid_minimum]).is_err()); + } + + #[test] + fn minimum_size_overrides_are_per_axis_and_later_rules_win() { + let rules = validate(&[ + WindowRuleConfig { + min_width: Some(800.0), + min_height: Some(600.0), + ..config("spotify") + }, + WindowRuleConfig { + min_width: Some(500.0), + ..config("spotify") + }, + ]) + .unwrap(); + + let result = resolve(&rules, Some("spotify"), None, false); + assert_eq!(result.min_width, Some(500.0)); + assert_eq!(result.min_height, Some(600.0)); + + let single = validate(&[WindowRuleConfig { + min_height: Some(400.0), + ..config("spotify") + }]) + .unwrap(); + let result = resolve(&single, Some("spotify"), None, false); + assert_eq!(result.min_width, None); + assert_eq!(result.min_height, Some(400.0)); + assert_eq!(resolve(&[], Some("spotify"), None, false).min_width, None); + } + + #[test] + fn live_rule_removal_reverts_placement_but_unchanged_rules_preserve_manual_state() { + let old = super::WindowRuleResult { + floating: Some(true), + fullscreen: Some(true), + ..Default::default() + }; + assert_eq!(super::live_result(old, old, false), None); + let removed = super::live_result(old, Default::default(), false).unwrap(); + assert_eq!(removed.floating, Some(false)); + assert_eq!(removed.fullscreen, Some(false)); + assert_eq!( + super::live_result(old, Default::default(), true).unwrap().floating, + Some(true) + ); + let new = super::WindowRuleResult { + width: Some(800.), + ..Default::default() + }; + assert_eq!(super::live_result(old, new, false).unwrap().floating, None); + } + + #[test] + fn minimum_size_changes_are_constraint_policy_not_placement_work() { + let old = super::WindowRuleResult { + min_width: Some(500.), + min_height: Some(400.), + ..Default::default() + }; + assert_eq!(super::live_result(old, Default::default(), false), None); + let raised = super::WindowRuleResult { + min_width: Some(600.), + min_height: Some(400.), + ..Default::default() + }; + assert_eq!(super::live_result(old, raised, false), None); + assert_eq!(super::live_result(old, old, false), None); + + let with_placement = super::WindowRuleResult { + floating: Some(true), + min_width: Some(700.), + ..Default::default() + }; + let applied = super::live_result(old, with_placement, false).unwrap(); + assert_eq!(applied.floating, Some(true)); + assert_eq!(applied.min_width, None); } } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 83bf3d8..89e0f40 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -107,7 +107,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() .current_monitor() .and_then(|monitor| monitor.refresh_rate_millihertz()) .unwrap_or(60_000) as i32; - if !resize_nested_output(state, &output, &drawable, size, scale_factor, rate) { + if !resize_nested_output(state, &output, &drawable, (size.w, size.h), scale_factor, rate) { return; } @@ -249,19 +249,23 @@ fn configure_nested_protocols(state: &mut Ferese) { .disable_global::(state.presentation_state.global()); } +fn usable_dimensions(width: i32, height: i32) -> bool { + width > 0 && height > 0 +} + fn usable_size(size: Size) -> bool { - size.w > 0 && size.h > 0 + usable_dimensions(size.w, size.h) } fn resize_nested_output( state: &mut Ferese, output: &Output, drawable: &Cell, - size: Size, + size: (i32, i32), scale_factor: f64, rate: i32, ) -> bool { - if !usable_size(size) { + if !usable_dimensions(size.0, size.1) { drawable.set(false); return false; } @@ -272,7 +276,7 @@ fn resize_nested_output( return false; }; resized.mode = Mode { - size, + size: (size.0, size.1).into(), refresh: rate.max(1), }; resized.scale = Scale::Fractional(normalized_scale(scale_factor)); @@ -372,19 +376,14 @@ mod tests { &mut state, &output, &drawable, - (900, 600).into(), + (900, 600), 1.25, 60_000 )); let before = output.current_mode(); for size in [(0, 600), (900, 0), (0, 0), (-1, 600)] { assert!(!resize_nested_output( - &mut state, - &output, - &drawable, - size.into(), - 2.0, - 144_000 + &mut state, &output, &drawable, size, 2.0, 144_000 )); assert_eq!(output.current_mode(), before); assert_eq!(output.current_scale().fractional_scale(), 1.25); @@ -396,7 +395,7 @@ mod tests { &mut state, &output, &drawable, - (1200, 800).into(), + (1200, 800), 1.5, 120_000 )); diff --git a/compositor/src/xwayland/auth.rs b/compositor/src/xwayland/auth.rs new file mode 100644 index 0000000..3b9bcaf --- /dev/null +++ b/compositor/src/xwayland/auth.rs @@ -0,0 +1,223 @@ +use std::fs; +use std::io::{self, Write}; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::Path; + +use tempfile::NamedTempFile; + +// The cookie authenticates the server; it does not isolate X11 clients from each other. +/// Deletion has one owner: the `NamedTempFile` removes the cookie file when +/// this value drops, so nothing here unlinks the path a second time. +#[derive(Debug)] +pub(crate) struct AuthorityFile { + file: NamedTempFile, +} + +fn random_cookie() -> io::Result<[u8; 16]> { + let mut cookie = [0_u8; 16]; + let mut filled = 0; + while filled < cookie.len() { + let count = unsafe { libc::getrandom(cookie[filled..].as_mut_ptr().cast(), cookie.len() - filled, 0) }; + if count < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if count == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "getrandom returned zero")); + } + filled += count as usize; + } + + Ok(cookie) +} + +fn hostname() -> io::Result> { + let mut buffer = [0_u8; 256]; + if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 { + return Err(io::Error::last_os_error()); + } + let end = buffer + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "hostname is not terminated"))?; + + Ok(buffer[..end].to_vec()) +} + +fn field(output: &mut impl Write, bytes: &[u8]) -> io::Result<()> { + let length = u16::try_from(bytes.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Xauthority field is too long"))?; + output.write_all(&length.to_be_bytes())?; + output.write_all(bytes) +} + +impl AuthorityFile { + pub fn create(runtime_directory: &Path, display: u32) -> io::Result { + let metadata = fs::symlink_metadata(runtime_directory)?; + let uid = unsafe { libc::geteuid() }; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != uid + || metadata.mode() & 0o077 != 0 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "XDG_RUNTIME_DIR must be a private directory owned by this user", + )); + } + + let mut file = tempfile::Builder::new() + .prefix("ferese-xauth-") + .tempfile_in(runtime_directory)?; + file.as_file().set_permissions(fs::Permissions::from_mode(0o600))?; + + let cookie = random_cookie()?; + let host = hostname()?; + let number = display.to_string(); + let output = file.as_file_mut(); + output.write_all(&256_u16.to_be_bytes())?; // FamilyLocal + field(output, &host)?; + field(output, number.as_bytes())?; + field(output, b"MIT-MAGIC-COOKIE-1")?; + field(output, &cookie)?; + output.flush()?; + + Ok(Self { file }) + } + + pub fn path(&self) -> &Path { + self.file.path() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn private_runtime_dir(name: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("ferese-xauth-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + root + } + + fn read_record(path: &Path) -> (u16, String, String, String, Vec) { + let bytes = fs::read(path).unwrap(); + let mut offset = 0; + let family = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]); + offset += 2; + + let take = &|offset: &mut usize| -> Vec { + let length = u16::from_be_bytes([bytes[*offset], bytes[*offset + 1]]) as usize; + *offset += 2; + let value = bytes[*offset..*offset + length].to_vec(); + *offset += length; + value + }; + + let host = String::from_utf8(take(&mut offset)).unwrap(); + let display = String::from_utf8(take(&mut offset)).unwrap(); + let name = String::from_utf8(take(&mut offset)).unwrap(); + let cookie = take(&mut offset); + assert_eq!(offset, bytes.len(), "trailing bytes in authority file"); + + (family, host, display, name, cookie) + } + + #[test] + fn writes_a_family_local_mit_cookie_record() { + let runtime = private_runtime_dir("record"); + let file = AuthorityFile::create(&runtime, 7).unwrap(); + + let (family, host, display, name, cookie) = read_record(file.path()); + + assert_eq!(family, 256, "FamilyLocal"); + assert!(!host.is_empty(), "hostname must be recorded"); + assert_eq!(display, "7"); + assert_eq!(name, "MIT-MAGIC-COOKIE-1"); + assert_eq!(cookie.len(), 16); + } + + #[test] + fn the_cookie_is_fresh_for_each_display() { + let runtime = private_runtime_dir("fresh"); + let first = AuthorityFile::create(&runtime, 1).unwrap(); + let second = AuthorityFile::create(&runtime, 2).unwrap(); + + assert_ne!(first.path(), second.path()); + assert_ne!(read_record(first.path()).4, read_record(second.path()).4); + } + + #[test] + fn the_file_is_private_and_removed_on_drop() { + let runtime = private_runtime_dir("private"); + let path = { + let file = AuthorityFile::create(&runtime, 3).unwrap(); + let path = file.path().to_owned(); + let mode = fs::metadata(&path).unwrap().mode() & 0o777; + assert_eq!(mode, 0o600, "authority file must not be group/world readable"); + path + }; + + assert!(!path.exists(), "authority file must be removed on drop"); + } + + #[test] + fn a_group_readable_runtime_directory_is_rejected() { + let runtime = std::env::temp_dir().join(format!("ferese-xauth-loose-{}", std::process::id())); + let _ = fs::remove_dir_all(&runtime); + fs::create_dir_all(&runtime).unwrap(); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o755)).unwrap(); + + let error = AuthorityFile::create(&runtime, 1).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn a_missing_runtime_directory_is_rejected() { + let error = AuthorityFile::create(Path::new("/nonexistent/ferese-runtime"), 1).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn the_record_is_decodable_by_libxau_via_xauth() { + let runtime = private_runtime_dir("libxau"); + let file = AuthorityFile::create(&runtime, 91).unwrap(); + + let output = std::process::Command::new("xauth") + .args(["-f", file.path().to_str().unwrap(), "list"]) + .output() + .expect("xauth must be available to verify the authority file"); + + assert!( + output.status.success(), + "xauth rejected the authority file: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let listing = String::from_utf8_lossy(&output.stdout); + let entry = listing + .lines() + .find(|line| line.contains("MIT-MAGIC-COOKIE-1")) + .unwrap_or_else(|| panic!("xauth did not list the cookie record: {listing:?}")); + assert!(entry.contains(":91"), "entry must reference the display: {entry:?}"); + + let cookie_hex = entry.split_whitespace().last().expect("cookie field"); + assert_eq!(cookie_hex.len(), 32, "16-byte cookie as hex: {cookie_hex}"); + assert!(cookie_hex.chars().any(|c| c != '0'), "cookie must not be zero"); + } + + #[test] + fn another_user_cannot_read_the_authority_file() { + let runtime = private_runtime_dir("cross-user"); + let file = AuthorityFile::create(&runtime, 92).unwrap(); + let mode = fs::metadata(file.path()).unwrap().mode() & 0o777; + + assert_eq!(mode, 0o600); + assert_eq!(fs::metadata(&runtime).unwrap().mode() & 0o777, 0o700); + } +} diff --git a/compositor/src/xwayland/child.rs b/compositor/src/xwayland/child.rs new file mode 100644 index 0000000..476b3dc --- /dev/null +++ b/compositor/src/xwayland/child.rs @@ -0,0 +1,571 @@ +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Child, Stdio}; + +use crate::session_environment::SessionEnvironment; + +use super::sockets::Reservation; + +#[cfg(test)] +pub(crate) mod test_hook { + use std::cell::{Cell, RefCell}; + + type InChild = Box std::io::Result<()> + Send + Sync>; + + thread_local! { + static IN_CHILD: RefCell> = const { RefCell::new(None) }; + /// Group signals this thread's owners actually sent. + /// + /// Kept per thread so parallel tests cannot inflate it: an owner whose + /// leader was reaped must send none, and a test that measures before + /// and after a shutdown is measuring that owner alone. + static GROUP_SIGNALS: Cell = const { Cell::new(0) }; + } + + pub(crate) fn record_group_signal() { + GROUP_SIGNALS.with(|count| count.set(count.get() + 1)); + } + + pub(crate) fn group_signals() -> usize { + GROUP_SIGNALS.with(Cell::get) + } + + /// Arrange an action that must happen in *this* child only, after fork. + /// + /// Close-on-exec must be cleared here: the parent is shared with parallel + /// spawns, so a parent-side change would race them. + pub(crate) fn set_in_child(hook: impl FnMut() -> std::io::Result<()> + Send + Sync + 'static) { + IN_CHILD.with(|slot| *slot.borrow_mut() = Some(Box::new(hook))); + } + + pub(crate) fn take_in_child() -> Option { + IN_CHILD.with(|slot| slot.borrow_mut().take()) + } +} + +// Use the public Wayland socket: Satellite passes its environment to Xwayland. +pub(crate) fn spawn_satellite( + executable: &Path, + environment: &SessionEnvironment, + reservation: &Reservation, + authority: &Path, + notify_socket: Option<&Path>, +) -> io::Result { + if reservation.listeners().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot launch Satellite without reserved listening sockets", + )); + } + + satellite_command(executable, environment, reservation, authority, notify_socket).spawn() +} + +fn satellite_command( + executable: &Path, + environment: &SessionEnvironment, + reservation: &Reservation, + authority: &Path, + notify_socket: Option<&Path>, +) -> std::process::Command { + let inherited: Vec<_> = reservation.listeners().iter().map(AsRawFd::as_raw_fd).collect(); + if inherited.is_empty() { + panic!("cannot build a Satellite command without reserved listening sockets"); + } + + let mut command = crate::process::command(executable); + environment.apply_public(&mut command); + command + .arg(reservation.display_name()) + .arg("-auth") + .arg(authority) + .args(["-nolisten", "tcp"]) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .process_group(0); + + for name in [ + "NOTIFY_SOCKET", + "WATCHDOG_PID", + "WATCHDOG_USEC", + "LISTEN_PID", + "LISTEN_FDS", + "LISTEN_FDNAMES", + ] { + command.env_remove(name); + } + if let Some(path) = notify_socket { + command.env("NOTIFY_SOCKET", path); + } + + for fd in &inherited { + command.arg("-listenfd").arg(fd.to_string()); + } + + // PR_SET_PDEATHSIG follows the spawning thread; spawn from the compositor thread. + let expected_parent = unsafe { libc::getpid() }; + // SAFETY: this child-only hook performs only raw, non-allocating + // Linux/POSIX operations on prevalidated descriptors. All formatting and + // allocation occurred above, before fork. + unsafe { + command.pre_exec(move || { + for fd in &inherited { + let flags = libc::fcntl(*fd, libc::F_GETFD); + if flags == -1 || libc::fcntl(*fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 { + return Err(io::Error::last_os_error()); + } + } + + if libc::prctl( + libc::PR_SET_PDEATHSIG, + libc::SIGTERM as libc::c_ulong, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + ) == -1 + { + return Err(io::Error::last_os_error()); + } + + if libc::getppid() != expected_parent { + return Err(io::Error::from_raw_os_error(libc::ESRCH)); + } + + Ok(()) + }); + } + + // One-shot, thread-local, and consumed by exactly this spawn: the hook + // changes descriptors in the forked child, never in the parent. + #[cfg(test)] + if let Some(hook) = test_hook::take_in_child() { + // SAFETY: same contract as the hook above: a closure that runs in the + // forked child before exec, using only prevalidated descriptors and + // no allocation. + unsafe { + command.pre_exec(hook); + } + } + + command +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Observation { + Running, + Exited, + Failed(String), +} + +pub(crate) struct OwnedProcessGroup { + pid: u32, + child: Option, + reaped: bool, + cleanup_error: Option, +} + +impl OwnedProcessGroup { + pub fn adopt(child: Child) -> Self { + let pid = child.id(); + Self { + pid, + child: Some(child), + reaped: false, + cleanup_error: None, + } + } + + #[cfg(test)] + fn from_command(mut command: std::process::Command) -> std::io::Result { + command.process_group(0); + Ok(Self::adopt(command.spawn()?)) + } + + pub fn pid(&self) -> u32 { + self.pid + } + + pub fn observe(&mut self) -> Observation { + if self.reaped { + return Observation::Exited; + } + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a live, correctly aligned local; P_PID with our own + // pid, and WNOWAIT|WNOHANG leave the status claimable by `wait`. + let result = unsafe { + libc::waitid( + libc::P_PID, + self.pid, + std::ptr::from_mut(&mut info), + libc::WEXITED | libc::WNOWAIT | libc::WNOHANG, + ) + }; + if result == -1 { + // ECHILD means someone else already reaped the leader, so the + // process-group identity can no longer be trusted. + return Observation::Failed(format!( + "cannot observe X11 service {}: {}", + self.pid, + io::Error::last_os_error() + )); + } + // si_pid == 0 with WNOHANG means no state change is pending. + // SAFETY: `info` was filled in by the waitid call above. + if unsafe { info.si_pid() } == 0 { + Observation::Running + } else { + Observation::Exited + } + } + + /// Ask the whole group to stop, but only while this owner still holds the + /// unreaped leader that names it. + /// + /// Once the leader is reaped the group number identifies nothing this + /// owner controls: it may already belong to an unrelated process group. + /// Cleanup in that state resumes by *waiting* for the group, never by + /// signalling it, so a stale number can never kill a stranger. + pub fn signal_group(&self, signal: i32) { + if self.reaped { + // The group number is stale: it may already name an unrelated + // process group, so resume by waiting and never by signalling. + return; + } + #[cfg(test)] + test_hook::record_group_signal(); + signal_group(self.pid, signal); + } + + pub fn group_is_empty(&self) -> bool { + #[cfg(test)] + if super::test_hooks::force_group_not_empty() { + return false; + } + + // SAFETY: `kill` with signal 0 performs error checking only. A negative + // pid addresses the process group led by `self.pid`. + let outcome = unsafe { libc::kill(-(self.pid as i32), 0) }; + if outcome == 0 { + return false; + } + // ESRCH is the only "nothing left" answer; anything else (notably + // EPERM) means the group still exists and must not be assumed gone. + std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + } + + pub fn is_reaped(&self) -> bool { + self.reaped + } + + pub fn terminate(&mut self, grace: std::time::Duration) -> Result, String> { + self.signal_group(libc::SIGTERM); + + let deadline = std::time::Instant::now() + grace; + loop { + match self.observe() { + Observation::Exited => break, + Observation::Running => { + if std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Observation::Failed(error) => return Err(error), + } + } + + self.signal_group(libc::SIGKILL); + // A zombie still counts as a group member, so reap before asking + // whether a descendant is left. + let status = self.reap()?; + + let deadline = std::time::Instant::now() + grace; + while !self.group_is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + if !self.group_is_empty() { + // A survivor may still hold the X11 listeners, so report it. + return Err("the X11 service group survived SIGKILL".to_owned()); + } + Ok(status) + } + + pub fn reap(&mut self) -> Result, String> { + if self.reaped { + return Ok(None); + } + let Some(child) = self.child.as_mut() else { + self.reaped = true; + return Ok(None); + }; + match child.wait() { + Ok(status) => { + self.reaped = true; + Ok(Some(status)) + } + Err(error) => Err(format!("cannot reap X11 service {}: {error}", self.pid)), + } + } + + pub fn cleanup_error(&self) -> Option<&str> { + self.cleanup_error.as_deref() + } + + pub fn note_cleanup_error(&mut self, error: String) { + self.cleanup_error = Some(error); + } +} + +impl Drop for OwnedProcessGroup { + fn drop(&mut self) { + if self.reaped { + return; + } + + // Last resort: the manager must already have reached a completed + // cleanup transition, so a process is never silently forgotten. + self.signal_group(libc::SIGKILL); + let _ = self.reap(); + } +} + +/// Requires an unreaped group leader owned by the caller. +pub(crate) fn signal_group(pid: u32, signal: i32) { + // SAFETY: negative pid targets the process group led by pid. + unsafe { + libc::kill(-(pid as i32), signal); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SessionEnvironment; + use std::ffi::OsString; + use std::time::Duration; + + fn environment() -> SessionEnvironment { + SessionEnvironment { + wayland_display: OsString::from("wayland-7"), + x11: None, + } + } + + #[test] + fn refusing_to_launch_without_owned_listeners() { + let mut reservation = Reservation::allocate().unwrap(); + reservation.close_listeners(); + + let error = spawn_satellite( + Path::new("/nonexistent/xwayland-satellite"), + &environment(), + &reservation, + Path::new("/nonexistent/auth"), + None, + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("without reserved listening sockets")); + } + + #[test] + fn a_missing_executable_is_reported_not_panicked() { + let reservation = Reservation::allocate().unwrap(); + let error = spawn_satellite( + Path::new("/nonexistent/xwayland-satellite"), + &environment(), + &reservation, + Path::new("/nonexistent/auth"), + None, + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn a_stopped_group_reports_itself_empty() { + let mut command = std::process::Command::new("/bin/sh"); + command.arg("-c").arg("exit 0"); + let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh"); + + assert!(!process.group_is_empty()); + process.signal_group(libc::SIGTERM); + let status = process.terminate(Duration::from_secs(5)).expect("terminate"); + + assert!(status.is_some()); + assert!(process.group_is_empty(), "a reaped group has no members left"); + assert_eq!(process.cleanup_error(), None); + } + + #[test] + fn a_descendant_that_ignores_sigterm_is_still_killed() { + // A leader that exits immediately while a descendant keeps running is + // the crash shape that leaks X11 listeners into the next generation. + let mut command = std::process::Command::new("/bin/sh"); + command.arg("-c").arg("sleep 300 & exit 0"); + let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh"); + + // The leader is gone, yet the group still has a member. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while process.observe() == Observation::Running && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(process.observe(), Observation::Exited); + assert!( + !process.group_is_empty(), + "the surviving descendant keeps the group non-empty" + ); + + process.terminate(Duration::from_secs(5)).expect("terminate"); + + assert!( + process.group_is_empty(), + "SIGKILL must reclaim a descendant that ignored SIGTERM" + ); + } + + #[test] + fn reaping_reports_a_failure_instead_of_panicking() { + let mut process = OwnedProcessGroup { + pid: 0, + child: None, + reaped: false, + cleanup_error: None, + }; + + // An owner with no child has nothing left to reap. + assert_eq!(process.reap().expect("reap"), None); + assert!(process.reaped); + // Reaping twice is harmless, so cleanup paths stay simple. + assert_eq!(process.reap().expect("reap again"), None); + } + + #[test] + fn a_reaped_owner_resumes_shutdown_without_signalling_a_stale_group() { + let mut command = std::process::Command::new("/bin/sh"); + command.arg("-c").arg("exit 0"); + let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh"); + process.reap().expect("reap the leader"); + assert!(process.is_reaped(), "the leader is reaped"); + + // A shutdown that resumes here still owns the cleanup record, but the + // group number may already name another process group: it must be + // waited for, never signalled. + let before = test_hook::group_signals(); + let status = process + .terminate(Duration::from_secs(5)) + .expect("terminating a reaped owner must not fail"); + + assert_eq!(status, None, "there is no leader left to report a status for"); + assert_eq!( + test_hook::group_signals(), + before, + "a reaped owner must resume by waiting, not by signalling its stale group number" + ); + assert!(process.group_is_empty(), "the group really was reclaimed"); + } + + #[test] + fn the_command_line_matches_the_verified_cli_contract() { + let reservation = Reservation::allocate().unwrap(); + let notify = std::env::temp_dir().join("ferese-notify-test"); + + let command = satellite_command( + Path::new("xwayland-satellite"), + &environment(), + &reservation, + Path::new("/run/user/1000/ferese-xauth-abc"), + Some(notify.as_path()), + ); + + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + args.first().map(String::as_str), + Some(reservation.display_name().as_str()) + ); + + let mut listenfds = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "-listenfd" => { + listenfds.push(args[index + 1].clone()); + index += 2; + } + "-auth" => { + assert_eq!(args[index + 1], "/run/user/1000/ferese-xauth-abc"); + index += 2; + } + "-nolisten" => { + assert_eq!(args[index + 1], "tcp"); + index += 2; + } + _ => index += 1, + } + } + + assert!(args.windows(2).any(|pair| pair == ["-nolisten", "tcp"])); + assert!(!args.iter().any(|arg| arg == "-ac")); + + assert!(!args.iter().any(|arg| arg == "-displayfd")); + assert_eq!(listenfds.len(), reservation.listeners().len()); + } + + #[test] + fn inherited_socket_activation_state_is_cleared() { + let reservation = Reservation::allocate().unwrap(); + let command = satellite_command( + Path::new("xwayland-satellite"), + &environment(), + &reservation, + Path::new("/auth"), + None, + ); + + for (key, value) in command.get_envs() { + let key = key.to_string_lossy(); + if matches!( + key.as_ref(), + "LISTEN_PID" | "LISTEN_FDS" | "LISTEN_FDNAMES" | "WATCHDOG_PID" | "WATCHDOG_USEC" | "NOTIFY_SOCKET" + ) { + assert_eq!(value, None, "{key} must not be inherited from the launching service"); + } + if key.as_ref() == "WAYLAND_SOCKET" { + assert_eq!( + value, None, + "Satellite must connect by display name, not an inherited socket" + ); + } + } + } + + #[test] + fn a_private_notify_socket_replaces_the_host_one() { + let reservation = Reservation::allocate().unwrap(); + let private = std::env::temp_dir().join("ferese-notify-private"); + let command = satellite_command( + Path::new("xwayland-satellite"), + &environment(), + &reservation, + Path::new("/auth"), + Some(private.as_path()), + ); + + let notify = command + .get_envs() + .find(|(key, _)| key.to_string_lossy() == "NOTIFY_SOCKET") + .and_then(|(_, value)| value) + .expect("private NOTIFY_SOCKET must be set"); + + assert_eq!(notify, private); + } +} diff --git a/compositor/src/xwayland/integration_tests.rs b/compositor/src/xwayland/integration_tests.rs new file mode 100644 index 0000000..43f7dda --- /dev/null +++ b/compositor/src/xwayland/integration_tests.rs @@ -0,0 +1,1619 @@ +//! Child and notification helper tests; these do not drive the manager event loop. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::time::{Duration, Instant}; + +use super::auth::AuthorityFile; +use super::child; +use super::readiness::ReadinessSocket; +use super::sockets::Reservation; +use crate::session_environment::{SessionEnvironment, X11Environment}; + +struct Fixture { + directory: PathBuf, + record: PathBuf, + wrapper: PathBuf, + reservation: Reservation, + authority: AuthorityFile, + runtime_directory: PathBuf, +} + +impl Fixture { + fn new(name: &str) -> Self { + Self::with_allocator(name, false) + } + + /// Production display-slot inputs: an X client resolves `DISPLAY` against `/tmp/.X11-unix`. + fn new_live(name: &str) -> Self { + Self::with_allocator(name, true) + } + + fn with_allocator(name: &str, production_inputs: bool) -> Self { + let directory = std::env::temp_dir().join(format!( + "ferese-x11-it-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the fixture directory"); + let runtime_directory = directory.join("run"); + fs::create_dir_all(&runtime_directory).expect("create the runtime directory"); + fs::set_permissions(&runtime_directory, fs::Permissions::from_mode(0o700)) + .expect("make the runtime directory private"); + + if production_inputs { + crate::xwayland::test_hooks::allocator_root_in(PathBuf::from("/tmp/.X11-unix"), PathBuf::from("/tmp")); + } else { + // Owned inputs, so this fixture never allocates a real slot in `/tmp/.X11-unix`. + let allocator = directory.join("x11"); + fs::create_dir_all(&allocator).expect("create the allocator directory"); + fs::set_permissions(&allocator, fs::Permissions::from_mode(0o700)).expect("make the allocator private"); + crate::xwayland::test_hooks::allocator_root(allocator); + } + + let reservation = Reservation::allocate().expect("reserve a display"); + let authority = + AuthorityFile::create(&runtime_directory, reservation.display_number()).expect("create an authority file"); + + Self { + record: directory.join("starts.jsonl"), + wrapper: directory.join("fake-satellite"), + reservation, + authority, + runtime_directory, + directory, + } + } + + fn script(&self, assignments: &[(&str, &str)]) -> &Self { + let fake = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scripts/tests/fake-xwayland-satellite.py"); + assert!(fake.exists(), "the fake Satellite must exist at {}", fake.display()); + let record = self.record.display(); + let fake = fake.display(); + + let mut body = String::from("#!/bin/sh\n"); + body.push_str(&format!( + "FAKE_SATELLITE_RECORD='{record}'\nexport FAKE_SATELLITE_RECORD\n" + )); + for (key, value) in assignments { + body.push_str(&format!("{key}='{value}'\nexport {key}\n")); + } + body.push_str(&format!("exec '{fake}' \"$@\"\n")); + fs::write(&self.wrapper, body).expect("write the wrapper script"); + fs::set_permissions(&self.wrapper, fs::Permissions::from_mode(0o755)).expect("make the wrapper executable"); + self + } + + fn environment(&self) -> SessionEnvironment { + SessionEnvironment { + wayland_display: std::ffi::OsString::from("wayland-it"), + x11: Some(X11Environment { + display: std::ffi::OsString::from(self.reservation.display_name()), + authority: self.authority.path().to_owned(), + }), + } + } + + fn starts(&self) -> Vec { + match fs::read_to_string(&self.record) { + Ok(contents) => contents + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("each record must be valid JSON")) + .collect(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => panic!("cannot read {}: {error}", self.record.display()), + } + } + + fn spawn(&self, generation: u64) -> (Child, ReadinessSocket) { + let socket = ReadinessSocket::create(&self.runtime_directory, generation).expect("create a readiness socket"); + let notify = socket.path().to_owned(); + let spawned = child::spawn_satellite( + &self.wrapper, + &self.environment(), + &self.reservation, + self.authority.path(), + Some(notify.as_path()), + ) + .expect("spawn the fake Satellite"); + (spawned, socket) + } + + fn spawn_real(&self, generation: u64, executable: &Path) -> (Child, ReadinessSocket) { + let socket = ReadinessSocket::create(&self.runtime_directory, generation).expect("create a readiness socket"); + let notify = socket.path().to_owned(); + let mut environment = self.environment(); + + environment.wayland_display = std::env::var_os("WAYLAND_DISPLAY").expect("a live Wayland session is required"); + let spawned = child::spawn_satellite( + executable, + &environment, + &self.reservation, + self.authority.path(), + Some(notify.as_path()), + ) + .expect("spawn the real Satellite"); + (spawned, socket) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} + +/// Run `test_path` in a dedicated process and report whether this is that process. +/// +/// Process-wide checks must not share a process with parallel tests that fork +/// children: those inherit every descriptor of this process, close-on-exec or +/// not, and would read as unrelated holders. +fn run_isolated(test_path: &str) -> bool { + const CHILD: &str = "FERESE_XWAYLAND_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let directory = std::env::temp_dir().join(format!( + "ferese-isolated-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the isolated session directory"); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).expect("make the session private"); + + let status = Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", test_path, "--nocapture"]) + .env(CHILD, "1") + .env("XDG_RUNTIME_DIR", &directory) + .env("XDG_CONFIG_HOME", &directory) + .env_remove("FERESE_SOCKET") + .output() + .expect("re-exec the test"); + assert!(status.status.success(), "{}", String::from_utf8_lossy(&status.stderr)); + false +} + +fn wait_until(mut condition: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if condition() { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + false +} + +fn read_ready(socket: &ReadinessSocket, pid: u32) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + match socket.recv_ready(pid) { + Ok(true) => return true, + Ok(false) => return false, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("readiness read failed: {error}"), + } + } + false +} + +fn reap(mut child: Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn process_exists(pid: u32) -> bool { + Path::new(&format!("/proc/{pid}")).exists() +} + +#[test] +fn satellite_receives_exactly_the_owned_listeners_and_no_extra_descriptors() { + let sentinel = std::net::TcpListener::bind("127.0.0.1:0").expect("bind a sentinel"); + let sentinel_fd = sentinel.as_raw_fd(); + + let fixture = Fixture::new("listeners"); + let sentinel_target = std::fs::read_link(format!("/proc/self/fd/{sentinel_fd}")) + .expect("the sentinel descriptor is open") + .to_string_lossy() + .into_owned(); + fixture.script(&[ + ("FAKE_SATELLITE_READY", "0"), + ("FAKE_SATELLITE_SENTINEL_FD", &sentinel_fd.to_string()), + ("FAKE_SATELLITE_SENTINEL_TARGET", &sentinel_target), + ]); + let (satellite, _socket) = fixture.spawn(1); + assert!( + wait_until(|| !fixture.starts().is_empty()), + "the fake must record a start" + ); + let record = fixture.starts().remove(0); + + assert_eq!(record["display"], fixture.reservation.display_name()); + assert_eq!(record["authority"], fixture.authority.path().to_str().unwrap()); + + let listenfds = record["listenfds"].as_array().expect("listenfds array"); + assert_eq!(listenfds.len(), fixture.reservation.listeners().len()); + assert_eq!( + record["all_listening"], true, + "every passed fd must be a listening socket" + ); + assert_eq!( + record["sentinel_leaked"], false, + "the ordinary spawn must prove the parent's own descriptor never leaked" + ); + reap(satellite); + + // Positive control: with the flag cleared in the forked child, the same descriptor is visible. + let flags = unsafe { libc::fcntl(sentinel_fd, libc::F_GETFD) }; + assert_ne!(flags, -1); + assert_ne!( + flags & libc::FD_CLOEXEC, + 0, + "the sentinel must start close-on-exec, otherwise this test proves nothing" + ); + + let control = Fixture::new("listeners-control"); + control.script(&[ + ("FAKE_SATELLITE_READY", "0"), + ("FAKE_SATELLITE_SENTINEL_FD", &sentinel_fd.to_string()), + ("FAKE_SATELLITE_SENTINEL_TARGET", &sentinel_target), + ]); + child::test_hook::set_in_child(move || { + if unsafe { libc::fcntl(sentinel_fd, libc::F_SETFD, 0) } == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + let (satellite, _socket) = control.spawn(1); + assert!( + wait_until(|| !control.starts().is_empty()), + "the control must record a start" + ); + let control_record = control.starts().remove(0); + assert_eq!( + control_record["sentinel_leaked"], true, + "the control proves the descriptor check can detect an inherited fd" + ); + reap(satellite); + + let flags = unsafe { libc::fcntl(sentinel_fd, libc::F_GETFD) }; + assert_ne!( + flags & libc::FD_CLOEXEC, + 0, + "the parent's descriptor flags must be unchanged after the control" + ); +} + +#[test] +fn satellite_gets_the_session_environment_and_never_a_private_socket() { + let fixture = Fixture::new("environment"); + fixture.script(&[("FAKE_SATELLITE_READY", "0")]); + let (satellite, _socket) = fixture.spawn(1); + assert!(wait_until(|| !fixture.starts().is_empty())); + let record = fixture.starts().remove(0); + let environment = &record["env"]; + + assert_eq!(environment["DISPLAY"], fixture.reservation.display_name()); + assert_eq!(environment["XAUTHORITY"], fixture.authority.path().to_str().unwrap()); + assert_eq!(environment["WAYLAND_DISPLAY"], "wayland-it"); + + assert_eq!(environment["WAYLAND_SOCKET"], serde_json::Value::Null); + assert_eq!(environment["FERESE_SHELL_CONTROL_SOCKET"], serde_json::Value::Null); + + reap(satellite); +} + +#[test] +fn a_valid_notification_from_the_service_pid_satisfies_readiness() { + let fixture = Fixture::new("ready"); + fixture.script(&[("FAKE_SATELLITE_READY", "1")]); + let (satellite, socket) = fixture.spawn(1); + let pid = satellite.id(); + assert!( + read_ready(&socket, pid), + "a READY=1 from the service PID must be accepted" + ); + reap(satellite); +} + +#[test] +fn a_delayed_notification_is_still_accepted() { + let fixture = Fixture::new("ready-delay"); + fixture.script(&[("FAKE_SATELLITE_READY", "1"), ("FAKE_SATELLITE_READY_DELAY", "0.3")]); + let (satellite, socket) = fixture.spawn(1); + let pid = satellite.id(); + assert!(read_ready(&socket, pid), "a delayed READY=1 must be accepted"); + reap(satellite); +} + +#[test] +fn a_notification_from_a_child_pid_is_rejected() { + let fixture = Fixture::new("ready-child"); + fixture.script(&[("FAKE_SATELLITE_FORK_NOTIFY", "1")]); + let (satellite, socket) = fixture.spawn(1); + let pid = satellite.id(); + + assert!(!read_ready(&socket, pid), "a foreign PID's READY=1 must be rejected"); + reap(satellite); +} + +#[test] +fn malformed_and_truncated_notifications_are_rejected() { + for payload in ["garbage", "truncated"] { + let fixture = Fixture::new(&format!("ready-{payload}")); + fixture.script(&[("FAKE_SATELLITE_READY", payload)]); + let (satellite, socket) = fixture.spawn(1); + let pid = satellite.id(); + assert!(!read_ready(&socket, pid), "{payload} must not satisfy readiness"); + reap(satellite); + } +} + +#[test] +fn a_service_that_exits_before_readiness_leaves_no_running_process() { + let fixture = Fixture::new("exit-early"); + fixture.script(&[ + ("FAKE_SATELLITE_EXIT_BEFORE_READY", "1"), + ("FAKE_SATELLITE_EXIT_CODE", "17"), + ]); + let (mut satellite, socket) = fixture.spawn(1); + let pid = satellite.id(); + assert!(!read_ready(&socket, pid), "an early exit must not satisfy readiness"); + let status = wait_until_child(&mut satellite); + assert_eq!(status.code(), Some(17)); + assert!( + wait_until(|| !process_exists(pid)), + "the early-exiting service must be gone" + ); +} + +fn wait_until_child(child: &mut Child) -> std::process::ExitStatus { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Some(status) = child.try_wait().expect("poll the child") { + return status; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("the child did not exit in time"); +} + +#[test] +fn shutdown_cleans_the_whole_owned_process_group() { + const PATH: &str = "xwayland::integration_tests::shutdown_cleans_the_whole_owned_process_group"; + if !run_isolated(PATH) { + return; + } + + let fixture = Fixture::new("group"); + + fixture.script(&[ + ("FAKE_SATELLITE_HOLD_FDS", "1"), + ("FAKE_SATELLITE_READY", "1"), + ("FAKE_SATELLITE_LINGER", "30"), + ]); + let (satellite, socket) = fixture.spawn(1); + let pid = satellite.id(); + assert!(read_ready(&socket, pid), "the group fixture must reach readiness first"); + + let group_children = process::group_members(pid); + assert!( + group_children.len() >= 2, + "the fake must have a descendant holding the fds" + ); + + // Ownership, not liveness: the group must hold the endpoints it was handed. + let inodes = listener_inodes(&fixture.reservation); + assert!(!inodes.is_empty(), "the reserved listeners must be identifiable"); + let holders_before = process::socket_holders(&inodes); + assert!( + holders_before.iter().any(|holder| *holder != std::process::id()), + "a service descendant must hold the X11 listeners before shutdown: {holders_before:?}" + ); + + let mut group = child::OwnedProcessGroup::adopt(satellite); + let status = group + .terminate(Duration::from_secs(5)) + .expect("the owned process group must terminate"); + assert!(status.is_some(), "terminate() must reap the service leader"); + + assert!( + wait_until(|| group_children.iter().all(|member| !process_exists(*member))), + "every member of the owned process group must be gone: {group_children:?}" + ); + assert!(group.group_is_empty(), "the group must report itself empty"); + + let holders_after = process::socket_holders(&inodes); + assert_eq!( + holders_after, + vec![std::process::id()], + "no service process may still hold the listeners after shutdown: {holders_after:?}" + ); +} + +/// Socket inodes behind the listeners this process reserved. +fn listener_inodes(reservation: &Reservation) -> Vec { + reservation + .listeners() + .iter() + .filter_map(|listener| { + let target = std::fs::read_link(format!("/proc/self/fd/{}", listener.as_raw_fd())).ok()?; + let text = target.to_string_lossy().into_owned(); + text.strip_prefix("socket:[") + .and_then(|rest| rest.strip_suffix(']')) + .map(str::to_owned) + }) + .collect() +} + +mod process { + /// PIDs that currently hold any of `inodes`. + pub(super) fn socket_holders(inodes: &[String]) -> Vec { + let mut holders = Vec::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return holders; + }; + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Ok(pid): Result = name.parse() else { + continue; + }; + let Ok(fds) = std::fs::read_dir(entry.path().join("fd")) else { + continue; + }; + for fd in fds.flatten() { + let Ok(target) = std::fs::read_link(fd.path()) else { + continue; + }; + let text = target.to_string_lossy(); + let Some(number) = text.strip_prefix("socket:[").and_then(|rest| rest.strip_suffix(']')) else { + continue; + }; + if inodes.iter().any(|inode| inode == number) { + holders.push(pid); + break; + } + } + } + holders.sort_unstable(); + holders.dedup(); + holders + } + + pub(super) fn group_members(leader: u32) -> Vec { + let Ok(entries) = std::fs::read_dir("/proc") else { + return Vec::new(); + }; + + entries + .filter_map(|entry| { + let name = entry.ok()?.file_name(); + let pid: u32 = name.to_str()?.parse().ok()?; + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + + let closing = stat.rfind(')')?; + let fields: Vec<&str> = stat[closing + 1..].split_whitespace().collect(); + let pgrp: u32 = fields.get(2)?.parse().ok()?; + (pgrp == leader).then_some(pid) + }) + .collect() + } +} + +#[test] +#[ignore = "requires a real Satellite, Xwayland, and a live Wayland session"] +fn real_satellite_reports_verified_readiness_and_serves_authorized_x_clients() { + let executable = live_dependencies(); + + let fixture = Fixture::new_live("real"); + let (mut satellite, socket) = fixture.spawn_real(1, &executable); + let pid = satellite.id(); + + let started = Instant::now(); + let deadline = started + Duration::from_secs(30); + let mut accepted = false; + while Instant::now() < deadline { + match socket.recv_ready(pid) { + Ok(true) => { + accepted = true; + break; + } + Ok(false) => panic!("the real Satellite sent a notification that was rejected"), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if satellite.try_wait().expect("poll Satellite").is_some() { + panic!("the real Satellite exited before reporting readiness"); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(error) => panic!("readiness read failed: {error}"), + } + } + let elapsed = started.elapsed(); + assert!(accepted, "the installed Satellite never sent a usable READY=1"); + println!("real Satellite reported readiness in {elapsed:?} (production budget 10s)"); + assert!( + elapsed <= Duration::from_secs(10), + "readiness took {elapsed:?}, which exceeds the production startup budget" + ); + + let output = Command::new("xdpyinfo") + .arg("-display") + .arg(fixture.reservation.display_name()) + .env("XAUTHORITY", fixture.authority.path()) + .output(); + match output { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("X.Org"), + "an authorized X client connected, but this is not an X.Org server:\n{text}" + ); + } + Ok(output) => panic!( + "an authorized X11 client could not use the managed display: {}\n{}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ), + Err(error) => panic!("xdpyinfo is unavailable, so the endpoint is unverified: {error}"), + } + + let mut group = child::OwnedProcessGroup::adopt(satellite); + let status = group + .terminate(Duration::from_secs(10)) + .expect("the real service group must terminate"); + assert!(status.is_some(), "terminate() must reap the real Satellite"); + assert!( + group.group_is_empty(), + "the real service group must be empty after shutdown" + ); + assert!(wait_until(|| !process_exists(pid)), "the real Satellite must be gone"); +} + +/// Everything the live smoke test cannot supply for itself, declared up front. +fn live_dependencies() -> PathBuf { + let Some(executable) = ["xwayland-satellite", "/usr/bin/xwayland-satellite"] + .into_iter() + .find_map(|candidate| { + let path = PathBuf::from(candidate); + path.is_file().then_some(path) + }) + else { + panic!("dependency missing: xwayland-satellite is not installed"); + }; + + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + panic!("dependency missing: a live Wayland session (WAYLAND_DISPLAY is unset)"); + } + + let on_path = |program: &str| { + std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|directory| directory.join(program).is_file())) + }; + if !on_path("xdpyinfo") { + panic!("dependency missing: xdpyinfo (x11-utils) is required to prove an X client can connect"); + } + + match fs::symlink_metadata("/tmp/.X11-unix") { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => panic!("dependency missing: /tmp/.X11-unix exists but is not a directory"), + Err(error) => panic!("dependency missing: the shared X11 socket directory /tmp/.X11-unix: {error}"), + } + + executable +} + +mod manager_loop { + use super::*; + use crate::Ferese; + use crate::config::{XwaylandConfig, XwaylandStartup}; + use crate::xwayland; + use crate::xwayland::lifecycle::Phase; + use crate::xwayland::test_hooks; + use ferese_ipc::xwayland::{Readiness, State}; + use smithay::reexports::calloop::EventLoop; + use smithay::reexports::wayland_server::Display; + + fn wrapper(directory: &Path, assignments: &[(&str, &str)]) -> PathBuf { + let fake = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scripts/tests/fake-xwayland-satellite.py"); + let script = directory.join("fake-satellite"); + let mut body = String::from("#!/bin/sh\n"); + for (key, value) in assignments { + body.push_str(&format!("{key}='{value}'\nexport {key}\n")); + } + body.push_str(&format!("exec '{}' \"$@\"\n", fake.display())); + fs::write(&script, body).expect("write the wrapper"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("make the wrapper executable"); + script + } + + fn x11_socket_path(directory: &Path, display: &str) -> PathBuf { + directory + .join("x11") + .join(format!("X{}", display.trim_start_matches(':'))) + } + + /// A `Ferese` wired to a real event loop, ready to run the manager. + fn session(directory: &Path, path: PathBuf) -> (EventLoop<'static, Ferese>, Ferese) { + let (_, runtime, _) = crate::theme::prepare("xwayland { }", directory).expect("prepare configuration"); + // Owned inputs, so this test never competes for a real display slot. + let allocator = directory.join("x11"); + fs::create_dir_all(&allocator).expect("create the allocator directory"); + fs::set_permissions(&allocator, fs::Permissions::from_mode(0o700)).expect("make the allocator private"); + test_hooks::allocator_root(allocator); + let mut event_loop = EventLoop::try_new().expect("create the event loop"); + let display = Display::new().expect("create the Wayland display"); + let mut state = Ferese::new(&mut event_loop, display, runtime).expect("create the session"); + state.xwayland_config = XwaylandConfig { + enabled: true, + startup: XwaylandStartup::OnDemand, + path, + }; + (event_loop, state) + } + + fn dispatch_until( + event_loop: &mut EventLoop<'static, Ferese>, + state: &mut Ferese, + mut condition: impl FnMut(&Ferese) -> bool, + ) { + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + event_loop + .dispatch(Some(Duration::from_millis(25)), state) + .expect("dispatch the event loop"); + if condition(state) { + return; + } + } + panic!( + "the condition was never met; last status: {:?}", + xwayland::status_snapshot(state) + ); + } + + #[test] + fn the_manager_reaches_running_and_verifies_readiness_through_the_loop() { + if !run_isolated( + "xwayland::integration_tests::manager_loop::the_manager_reaches_running_and_verifies_readiness_through_the_loop", + ) { + return; + } + let directory = std::env::temp_dir().join(format!("ferese-manager-run-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session(&directory, wrapper(&directory, &[("FAKE_SATELLITE_LINGER", "60")])); + xwayland::initialize(&mut state); + + let generation = xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + assert!(generation > 0); + + dispatch_until(&mut event_loop, &mut state, |state| { + xwayland::status_snapshot(state).state == State::Running + }); + + let status = xwayland::status_snapshot(&state); + assert_eq!( + status.readiness, + Readiness::Verified, + "readiness is earned, not assumed" + ); + assert_eq!(status.state, State::Running); + assert!(!status.restart_required, "the live service matches the configured one"); + let pid = status.satellite_pid.expect("a running service reports its pid"); + assert!(process_exists(pid), "the service must be alive"); + + xwayland::shutdown_after_loop(&mut state); + assert!(!process_exists(pid), "shutdown must stop and reap the service"); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn a_crashed_service_is_stopped_before_anything_can_start_again() { + if !run_isolated( + "xwayland::integration_tests::manager_loop::a_crashed_service_is_stopped_before_anything_can_start_again", + ) { + return; + } + let directory = std::env::temp_dir().join(format!("ferese-manager-crash-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session( + &directory, + wrapper( + &directory, + &[("FAKE_SATELLITE_EXIT_AFTER_READY", "1"), ("FAKE_SATELLITE_LINGER", "0")], + ), + ); + xwayland::initialize(&mut state); + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + // While a service process is alive, no dispatch may make it startable again. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + if let Some(pid) = status.satellite_pid + && process_exists(pid) + { + assert!( + xwayland::request_start(&mut state).is_none(), + "a live service must never be startable again; status: {status:?}" + ); + } + // `Stopped` also covers an in-flight stop, where the process is still alive. + if matches!(status.state, State::Backoff | State::Failed | State::Idle) { + break; + } + assert!( + Instant::now() < deadline, + "the crashed service never reached a stopped phase; status: {status:?}" + ); + } + + let status = xwayland::status_snapshot(&state); + assert!( + matches!(status.state, State::Backoff | State::Stopped | State::Failed), + "a crash must land in a stopped phase, not {:?}", + status.state + ); + if let Some(pid) = status.satellite_pid { + assert!( + !process_exists(pid), + "the crashed service must be reaped before publishing" + ); + } else { + // The owner was cleared by cleanup, which is also proof it ran. + assert!( + xwayland::request_start(&mut state).is_none(), + "a fresh start must wait for the retry window" + ); + } + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn a_readiness_timeout_stops_the_live_service_before_anything_starts_again() { + const PATH: &str = "xwayland::integration_tests::manager_loop::a_readiness_timeout_stops_the_live_service_before_anything_starts_again"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + test_hooks::STARTUP_DEADLINE_MS.store(600, std::sync::atomic::Ordering::SeqCst); + + let directory = std::env::temp_dir().join(format!("ferese-manager-timeout-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + let record = directory.join("starts.jsonl"); + let record = record.display().to_string(); + + let (mut event_loop, mut state) = session( + &directory, + wrapper( + &directory, + &[ + ("FAKE_SATELLITE_RECORD", record.as_str()), + // Never reports readiness; a descendant keeps the listeners open. + ("FAKE_SATELLITE_READY", "0"), + ("FAKE_SATELLITE_HOLD_FDS", "1"), + ("FAKE_SATELLITE_LINGER", "60"), + ], + ), + ); + xwayland::initialize(&mut state); + let first_generation = + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + let display = xwayland::status_snapshot(&state) + .display + .expect("a display is reserved"); + let socket_path = x11_socket_path(&directory, &display); + // The kernel queues the connection, so a re-enabled listener could claim it. + std::os::unix::net::UnixStream::connect(&socket_path).expect("the reserved socket accepts a queued connection"); + + let mut first_pid = None; + let mut observed_pids = Vec::new(); + let mut highest_generation = first_generation; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + highest_generation = highest_generation.max(status.generation.unwrap_or(0)); + if let Some(pid) = status.satellite_pid { + if first_pid.is_none() { + first_pid = Some(pid); + } + observed_pids.push(pid); + assert!( + xwayland::request_start(&mut state).is_none(), + "a live service must never be startable again; status: {status:?}" + ); + } + if status.state == State::Failed { + break; + } + assert!( + Instant::now() < deadline, + "the timeout never opened the circuit; last status: {status:?}" + ); + } + + let status = xwayland::status_snapshot(&state); + assert_eq!(status.state, State::Failed, "three silent starts must open the circuit"); + assert!( + status + .last_error + .as_deref() + .is_some_and(|error| error.contains("reported no readiness")), + "the timeout must be reported as a broken readiness contract, got {status:?}" + ); + + let first_pid = first_pid.expect("a service was spawned"); + assert!( + wait_until(|| !process_exists(first_pid)), + "the timed-out service leader must be reaped, pid {first_pid} still exists" + ); + assert!( + observed_pids.iter().all(|pid| wait_until(|| !process_exists(*pid))), + "every generation started by the queued connection must be reaped" + ); + + let holder = fs::read_to_string(&record) + .expect("the fake recorded its start") + .lines() + .next() + .map(|line| serde_json::from_str::(line).expect("valid JSON")["holder_pid"].clone()) + .and_then(|value| value.as_u64()) + .map(|pid| pid as u32) + .expect("the fake reports the descriptor holder"); + assert!( + wait_until(|| !process_exists(holder)), + "the descendant holding the listeners must be killed, pid {holder} still exists" + ); + + assert!( + !socket_path.exists(), + "the socket path must be released on terminal failure" + ); + assert!( + xwayland::request_start(&mut state).is_none(), + "an open circuit must not accept a start request" + ); + assert!( + highest_generation >= 2, + "the queued connection must have driven retries after cleanup, saw generation {highest_generation}" + ); + let _ = fs::remove_dir_all(&directory); + } + + /// A stop that cannot prove its group is gone must not publish a stop at + /// all: publishing drops the only handle on survivors that may still hold + /// the X11 listeners, and a retry would then hand the display to a + /// replacement generation competing with them. + #[test] + fn an_unconfirmed_cleanup_keeps_the_endpoint_closed_until_the_group_is_reclaimed() { + const PATH: &str = "xwayland::integration_tests::manager_loop::an_unconfirmed_cleanup_keeps_the_endpoint_closed_until_the_group_is_reclaimed"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-cleanup-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + let record = directory.join("starts.jsonl"); + let record = record.display().to_string(); + + let (mut event_loop, mut state) = session( + &directory, + wrapper( + &directory, + &[ + ("FAKE_SATELLITE_RECORD", record.as_str()), + ("FAKE_SATELLITE_EXIT_AFTER_READY", "1"), + ("FAKE_SATELLITE_LINGER", "0"), + ], + ), + ); + xwayland::initialize(&mut state); + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + // Whatever the stop asks, the group never reports itself reclaimed, so + // the kill grace expires with no evidence that the resources are gone. + test_hooks::FORCE_GROUP_NOT_EMPTY.store(true, std::sync::atomic::Ordering::SeqCst); + + let mut retired_pid = None; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + if let Some(pid) = status.satellite_pid { + retired_pid = Some(pid); + } + if status.state == State::Failed { + break; + } + assert!( + Instant::now() < deadline, + "the cleanup never became unconfirmed: {status:?}" + ); + } + + let parked = xwayland::status_snapshot(&state); + let retired_pid = retired_pid.expect("a service was spawned"); + let generation = parked.generation.expect("the parked generation is reported"); + let display = parked.display.clone().expect("the display stays reserved"); + assert_eq!( + parked.satellite_pid, + Some(retired_pid), + "the cleanup record must stay owned while its group may still exist" + ); + { + let manager = state.xwayland.as_ref().expect("the manager stays published"); + assert!( + matches!(manager.lifecycle.phase, Phase::CleanupFailed { .. }), + "the expired deadline must park the stop, found {:?}", + manager.lifecycle.phase + ); + assert!(manager.process.is_some(), "the record still owns the group"); + } + + // No replacement may start while the old group may still hold the + // display: not from a queued connection, not from an explicit retry, + // and not by republishing an endpoint nobody is consuming. + assert!( + xwayland::request_start(&mut state).is_none(), + "a parked cleanup must refuse a replacement generation" + ); + let error = xwayland::retry(&mut state).expect_err("a retry must be refused while cleanup is unconfirmed"); + assert!( + error.contains("still holds the X11 display"), + "the refusal must name what is still holding the display: {error}" + ); + assert!( + !x11_socket_path(&directory, &display).exists(), + "the endpoint must not be republished while cleanup is unconfirmed" + ); + assert_eq!( + fs::read_to_string(&record) + .map(|contents| contents.lines().count()) + .unwrap_or(0), + 1, + "no replacement process may have been spawned" + ); + assert_eq!( + xwayland::status_snapshot(&state).state, + State::Failed, + "the refused retry must not move the service" + ); + + // The group really was reclaimed all along: with the lie removed, the + // same retry must recover on the same display. + test_hooks::FORCE_GROUP_NOT_EMPTY.store(false, std::sync::atomic::Ordering::SeqCst); + wrapper( + &directory, + &[ + ("FAKE_SATELLITE_RECORD", record.as_str()), + ("FAKE_SATELLITE_LINGER", "60"), + ], + ); + xwayland::retry(&mut state).expect("a confirmed cleanup makes a retry legal again"); + dispatch_until(&mut event_loop, &mut state, |state| { + let status = xwayland::status_snapshot(state); + status.state == State::Running && status.generation.is_some_and(|value| value > generation) + }); + + let recovered = xwayland::status_snapshot(&state); + assert_eq!(recovered.state, State::Running, "{recovered:?}"); + assert!( + recovered.display.as_deref() == Some(display.as_str()), + "recovery must stay on the same display: {recovered:?}" + ); + assert_eq!( + fs::read_to_string(&record) + .map(|contents| contents.lines().count()) + .unwrap_or(0), + 2, + "a confirmed cleanup is followed by exactly one replacement" + ); + + xwayland::shutdown_after_loop(&mut state); + test_hooks::reset(); + let _ = fs::remove_dir_all(&directory); + } + + /// The leader of a parked cleanup has already been reaped, so its group + /// number may name an unrelated group. Shutdown must resume by waiting for + /// the group, never by signalling that stale number. + #[test] + fn shutdown_of_an_unconfirmed_cleanup_issues_no_stale_group_signals() { + const PATH: &str = "xwayland::integration_tests::manager_loop::shutdown_of_an_unconfirmed_cleanup_issues_no_stale_group_signals"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-cleanup-stop-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session( + &directory, + wrapper( + &directory, + &[("FAKE_SATELLITE_EXIT_AFTER_READY", "1"), ("FAKE_SATELLITE_LINGER", "0")], + ), + ); + xwayland::initialize(&mut state); + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + test_hooks::FORCE_GROUP_NOT_EMPTY.store(true, std::sync::atomic::Ordering::SeqCst); + + let mut retired_pid = None; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + if let Some(pid) = status.satellite_pid { + retired_pid = Some(pid); + } + if status.state == State::Failed { + break; + } + assert!( + Instant::now() < deadline, + "the cleanup never became unconfirmed: {status:?}" + ); + } + + let retired_pid = retired_pid.expect("a service was spawned"); + let display = xwayland::status_snapshot(&state) + .display + .expect("the display stays reserved"); + { + let manager = state.xwayland.as_ref().expect("the manager stays published"); + assert!( + matches!(manager.lifecycle.phase, Phase::CleanupFailed { .. }), + "the stop must be parked as unconfirmed, found {:?}", + manager.lifecycle.phase + ); + assert_eq!( + manager.process.as_ref().map(|process| process.pid()), + Some(retired_pid), + "the parked record still owns the group" + ); + } + assert!( + !process_exists(retired_pid), + "the leader was reaped, so only its group number could still be signalled" + ); + + let before = child::test_hook::group_signals(); + xwayland::shutdown_after_loop(&mut state); + + assert_eq!( + child::test_hook::group_signals(), + before, + "shutdown must resume an unconfirmed cleanup by waiting, not by signalling a \ + group number whose leader was reaped" + ); + assert!(state.xwayland.is_none(), "shutdown releases the manager"); + assert!( + !x11_socket_path(&directory, &display).exists(), + "shutdown must release the socket path" + ); + test_hooks::reset(); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn an_unavailable_pidfd_falls_back_to_the_bounded_exit_poll() { + const PATH: &str = + "xwayland::integration_tests::manager_loop::an_unavailable_pidfd_falls_back_to_the_bounded_exit_poll"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + test_hooks::PIDFD_UNAVAILABLE.store(true, std::sync::atomic::Ordering::SeqCst); + + let directory = std::env::temp_dir().join(format!("ferese-manager-poll-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session( + &directory, + wrapper( + &directory, + &[ + ("FAKE_SATELLITE_EXIT_AFTER_READY", "1"), + ("FAKE_SATELLITE_LINGER", "60"), + ], + ), + ); + xwayland::initialize(&mut state); + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + let mut pid = None; + let mut verified_readiness = false; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + if let Some(observed) = status.satellite_pid { + pid = Some(observed); + } + if status.readiness == Readiness::Verified { + verified_readiness = true; + } + if pid.is_some() && status.state == State::Backoff { + break; + } + assert!( + Instant::now() < deadline, + "the fallback watcher never noticed the exit; last status: {status:?}" + ); + } + + let pid = pid.expect("a service was spawned"); + assert!( + wait_until(|| !process_exists(pid)), + "the observed service must be reaped without a pidfd" + ); + assert!(verified_readiness, "the service reported readiness before it exited"); + let status = xwayland::status_snapshot(&state); + assert_eq!(status.satellite_pid, None, "cleanup released the process owner"); + test_hooks::reset(); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn a_stale_exit_callback_cannot_stop_a_replacement_service() { + const PATH: &str = + "xwayland::integration_tests::manager_loop::a_stale_exit_callback_cannot_stop_a_replacement_service"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-stale-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session( + &directory, + wrapper(&directory, &[("FAKE_SATELLITE_EXIT_BEFORE_READY", "1")]), + ); + xwayland::initialize(&mut state); + let first_generation = + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + let mut retired_pid = None; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + if let Some(pid) = status.satellite_pid { + retired_pid = Some(pid); + } + if status.state == State::Failed { + break; + } + assert!(Instant::now() < deadline, "the early failure never settled: {status:?}"); + } + let retired_pid = retired_pid.expect("the first generation was spawned"); + let retired_status = xwayland::status_snapshot(&state); + assert!( + !retired_status.state.is_inactive(), + "an early failure must leave the circuit open: {retired_status:?}" + ); + + wrapper(&directory, &[("FAKE_SATELLITE_LINGER", "60")]); + xwayland::retry(&mut state).expect("an explicit retry rebuilds an unavailable service"); + dispatch_until(&mut event_loop, &mut state, |state| { + xwayland::status_snapshot(state).state == State::Running + }); + let live = xwayland::status_snapshot(&state); + let live_pid = live.satellite_pid.expect("the replacement reports its pid"); + let live_generation = live.generation.expect("the replacement reports its generation"); + assert_ne!(live_pid, retired_pid, "the replacement must be a different process"); + + let handle = state.loop_handle.clone(); + let stale_generation = first_generation; + { + let manager = state.xwayland.as_mut().expect("the service exists"); + assert!( + !manager.owns(stale_generation, retired_pid), + "the retired process is no longer owned" + ); + manager.handle_exit(&handle, stale_generation, retired_pid); + manager.fail(&handle, stale_generation, "stale failure", true); + } + + let after = xwayland::status_snapshot(&state); + assert_eq!( + after.state, + State::Running, + "a stale callback must not move the live service" + ); + assert_eq!(after.satellite_pid, Some(live_pid), "the live process must stay owned"); + assert_eq!( + after.generation, + Some(live_generation), + "the live generation must survive" + ); + assert!( + process_exists(live_pid), + "the live service must not be signalled by a stale callback" + ); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn shutdown_while_starting_stops_the_service_it_owns() { + const PATH: &str = + "xwayland::integration_tests::manager_loop::shutdown_while_starting_stops_the_service_it_owns"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-shutdown-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + let (mut event_loop, mut state) = + session(&directory, wrapper(&directory, &[("FAKE_SATELLITE_READY_DELAY", "30")])); + xwayland::initialize(&mut state); + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + for _ in 0..8 { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + } + let status = xwayland::status_snapshot(&state); + let display = status.display.clone().expect("a display is reserved"); + let pid = status.satellite_pid.expect("the service was spawned"); + assert_eq!( + status.state, + State::Starting, + "shutdown must happen mid-start: {status:?}" + ); + + xwayland::shutdown_after_loop(&mut state); + assert!(state.xwayland.is_none(), "shutdown releases the manager"); + assert!( + wait_until(|| !process_exists(pid)), + "the starting service must be stopped and reaped, pid {pid} still exists" + ); + + event_loop + .dispatch(Some(Duration::from_millis(50)), &mut state) + .expect("dispatch after shutdown"); + assert!( + state.xwayland.is_none(), + "a stale callback must not resurrect the manager" + ); + assert!( + !x11_socket_path(&directory, &display).exists(), + "shutdown must release the socket path" + ); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn a_failed_rebind_is_reported_and_the_next_retry_starts_a_service() { + const PATH: &str = "xwayland::integration_tests::manager_loop::a_failed_rebind_is_reported_and_the_next_retry_starts_a_service"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-rebind-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session( + &directory, + wrapper(&directory, &[("FAKE_SATELLITE_EXIT_BEFORE_READY", "1")]), + ); + xwayland::initialize(&mut state); + let first_generation = + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + if xwayland::status_snapshot(&state).state == State::Failed { + break; + } + assert!(Instant::now() < deadline, "the service never became failed"); + } + + test_hooks::FAIL_REBIND_AFTER.store(0, std::sync::atomic::Ordering::SeqCst); + let error = xwayland::retry(&mut state).expect_err("an injected rebind failure must be reported"); + assert!( + error.contains("rebind X11 display"), + "the reported error must name the rebind step: {error}" + ); + let failed = xwayland::status_snapshot(&state); + assert_eq!( + failed.state, + State::Failed, + "a failed rebind must stay retryable: {failed:?}" + ); + assert!( + xwayland::request_start(&mut state).is_none(), + "the circuit is still open" + ); + + test_hooks::FAIL_REBIND_AFTER.store(usize::MAX, std::sync::atomic::Ordering::SeqCst); + wrapper(&directory, &[("FAKE_SATELLITE_LINGER", "60")]); + xwayland::retry(&mut state).expect("a retry after a failed rebind must succeed"); + dispatch_until(&mut event_loop, &mut state, |state| { + xwayland::status_snapshot(state).state == State::Running + && xwayland::status_snapshot(state) + .generation + .is_some_and(|generation| generation > first_generation) + }); + let status = xwayland::status_snapshot(&state); + assert!( + status + .generation + .is_some_and(|generation| generation > first_generation), + "the successful retry must start a real generation: {status:?}" + ); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn a_listener_registration_failure_leaves_a_retryable_endpoint() { + const PATH: &str = + "xwayland::integration_tests::manager_loop::a_listener_registration_failure_leaves_a_retryable_endpoint"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-register-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session( + &directory, + wrapper(&directory, &[("FAKE_SATELLITE_EXIT_BEFORE_READY", "1")]), + ); + // Fail the second listener source: the first is already registered. + test_hooks::FAIL_LISTENER_REGISTRATION_AFTER.store(1, std::sync::atomic::Ordering::SeqCst); + xwayland::initialize(&mut state); + let diagnostic = state.x11_diagnostic.clone().expect("the failure must be visible"); + assert!( + diagnostic.contains("injected X11 listener registration failure"), + "the diagnostic must name the failure: {diagnostic}" + ); + assert!( + state.xwayland.is_none(), + "a half-registered endpoint must not be published" + ); + + test_hooks::FAIL_LISTENER_REGISTRATION_AFTER.store(usize::MAX, std::sync::atomic::Ordering::SeqCst); + xwayland::retry(&mut state).expect("the service becomes available once registration works"); + + let mut first_generation = None; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + event_loop + .dispatch(Some(Duration::from_millis(25)), &mut state) + .expect("dispatch the event loop"); + let status = xwayland::status_snapshot(&state); + if status.generation.is_some() { + first_generation = status.generation; + } + if status.state == State::Failed { + break; + } + assert!(Instant::now() < deadline, "the service never became failed: {status:?}"); + } + let first_generation = first_generation.expect("a generation was started"); + + test_hooks::FAIL_LISTENER_REGISTRATION_AFTER.store(1, std::sync::atomic::Ordering::SeqCst); + let error = xwayland::retry(&mut state).expect_err("registration failure must be reported"); + assert!( + error.contains("injected X11 listener registration failure"), + "the reported error must name the failure: {error}" + ); + let failed = xwayland::status_snapshot(&state); + assert_eq!( + failed.state, + State::Failed, + "the endpoint must stay in a retryable failed state: {failed:?}" + ); + assert!( + xwayland::request_start(&mut state).is_none(), + "the circuit is still open" + ); + let display = failed.display.expect("the display is still reserved"); + assert!( + !x11_socket_path(&directory, &display).exists(), + "the half-registered listeners must be closed" + ); + + test_hooks::FAIL_LISTENER_REGISTRATION_AFTER.store(usize::MAX, std::sync::atomic::Ordering::SeqCst); + wrapper(&directory, &[("FAKE_SATELLITE_LINGER", "60")]); + xwayland::retry(&mut state).expect("the endpoint recovers once registration works"); + dispatch_until(&mut event_loop, &mut state, |state| { + xwayland::status_snapshot(state).state == State::Running + && xwayland::status_snapshot(state) + .generation + .is_some_and(|generation| generation > first_generation) + }); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn an_eager_service_that_could_not_start_retries_exactly_once() { + const PATH: &str = + "xwayland::integration_tests::manager_loop::an_eager_service_that_could_not_start_retries_exactly_once"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-eager-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session(&directory, wrapper(&directory, &[("FAKE_SATELLITE_LINGER", "60")])); + state.xwayland_config.startup = XwaylandStartup::Eager; + + test_hooks::FAIL_LISTENER_REGISTRATION_AFTER.store(0, std::sync::atomic::Ordering::SeqCst); + xwayland::initialize(&mut state); + assert!(state.xwayland.is_none(), "an unavailable service publishes no endpoint"); + assert!( + state.x11_diagnostic.is_some(), + "the unavailable start must leave a diagnostic" + ); + test_hooks::FAIL_LISTENER_REGISTRATION_AFTER.store(usize::MAX, std::sync::atomic::Ordering::SeqCst); + + // Eager startup must still issue exactly one start request. + xwayland::retry(&mut state).expect("an eager retry of an unavailable service must succeed"); + assert!( + state.x11_diagnostic.is_none(), + "a successful retry clears the diagnostic" + ); + + dispatch_until(&mut event_loop, &mut state, |state| { + xwayland::status_snapshot(state).state == State::Running + }); + let status = xwayland::status_snapshot(&state); + assert_eq!( + status.generation, + Some(1), + "exactly one generation was requested: {status:?}" + ); + assert_eq!(status.readiness, Readiness::Verified, "readiness is earned: {status:?}"); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn restart_required_tracks_desired_configuration_only() { + const PATH: &str = + "xwayland::integration_tests::manager_loop::restart_required_tracks_desired_configuration_only"; + if !run_isolated(PATH) { + return; + } + test_hooks::reset(); + + let directory = std::env::temp_dir().join(format!("ferese-manager-reload-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create the test directory"); + + let (mut event_loop, mut state) = session(&directory, wrapper(&directory, &[("FAKE_SATELLITE_LINGER", "60")])); + xwayland::initialize(&mut state); + xwayland::request_start(&mut state).expect("an on-demand service accepts a start request"); + dispatch_until(&mut event_loop, &mut state, |state| { + xwayland::status_snapshot(state).state == State::Running + }); + + let effective = xwayland::status_snapshot(&state); + let display = effective.display.clone().expect("a display is reserved"); + let pid = effective.satellite_pid.expect("a running service reports its pid"); + let authority = state + .xwayland + .as_ref() + .and_then(|manager| manager.x11_environment().authority.to_str().map(str::to_owned)) + .expect("an authority path is published"); + assert!( + !effective.restart_required, + "the live service matches the session configuration" + ); + + // Every restart-only field, changed one reload at a time. + let changes = [ + "xwayland { enabled #true; startup \"eager\"; }", + "xwayland { enabled #true; path \"/nonexistent/satellite\"; }", + "xwayland { enabled #false; }", + ]; + for source in changes { + state + .reload_config_source(source.into()) + .unwrap_or_else(|error| panic!("the reload must be accepted: {error}")); + let status = xwayland::status_snapshot(&state); + assert!( + status.restart_required, + "changing the configuration must request a restart: {source} => {status:?}" + ); + assert_eq!( + status.state, + State::Running, + "a reload must not disturb the live service" + ); + assert_eq!( + status.display.as_deref(), + Some(display.as_str()), + "the display must not move" + ); + assert_eq!(status.satellite_pid, Some(pid), "the service must not be restarted"); + let authority_now = state + .xwayland + .as_ref() + .and_then(|manager| manager.x11_environment().authority.to_str().map(str::to_owned)) + .expect("an authority path is published"); + assert_eq!(authority_now, authority, "the authority file must not be replaced"); + } + + // Reverting to the session configuration clears the indication. + state + .reload_config_source( + "xwayland { enabled #true; startup \"on-demand\"; path \"".to_owned() + + &wrapper(&directory, &[]).display().to_string() + + "\"; }", + ) + .expect("the reload must be accepted"); + let reverted = xwayland::status_snapshot(&state); + assert!( + !reverted.restart_required, + "reverting to the effective configuration must clear restart_required: {reverted:?}" + ); + + // A rejected reload changes nothing. + let error = state + .reload_config_source("xwayland { enabled #true; startup \"whenever\"; }".into()) + .expect_err("an invalid startup mode must be rejected"); + assert!(!error.is_empty(), "the rejection is explained"); + let unchanged = xwayland::status_snapshot(&state); + assert!( + !unchanged.restart_required, + "a rejected reload must not change desired state: {unchanged:?}" + ); + assert_eq!( + unchanged.satellite_pid, + Some(pid), + "a rejected reload must not touch the service" + ); + let _ = fs::remove_dir_all(&directory); + } +} diff --git a/compositor/src/xwayland/lifecycle.rs b/compositor/src/xwayland/lifecycle.rs new file mode 100644 index 0000000..5456777 --- /dev/null +++ b/compositor/src/xwayland/lifecycle.rs @@ -0,0 +1,620 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +const FAILURE_WINDOW: Duration = Duration::from_secs(30); + +const MAX_FAILURES: usize = 3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AfterStop { + /// Schedule a bounded retry. + Backoff, + /// Open the circuit: fail fast until an explicit retry. + Failed, + /// The session is shutting down. + Stopped, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Phase { + Idle, + + Starting(u64), + + Spawned(u64), + Running(u64), + Stopping { + generation: u64, + after_stop: AfterStop, + }, + Backoff { + generation: u64, + until: Instant, + }, + + /// A stop whose cleanup could not be confirmed. + CleanupFailed { + generation: u64, + }, + + Failed, + Stopped, +} + +impl Phase { + pub fn generation(self) -> Option { + match self { + Phase::Starting(generation) + | Phase::Spawned(generation) + | Phase::Running(generation) + | Phase::Stopping { generation, .. } + | Phase::Backoff { generation, .. } + | Phase::CleanupFailed { generation } => Some(generation), + Phase::Idle | Phase::Failed | Phase::Stopped => None, + } + } + + pub fn is_running(self) -> bool { + matches!(self, Phase::Running(_)) + } + pub fn can_start(self) -> bool { + matches!(self, Phase::Idle) + } + + /// True while a generation's process and cleanup sources are still owned. + #[cfg(test)] + pub fn is_stopping(self) -> bool { + matches!(self, Phase::Stopping { .. }) + } +} + +// Process resources belong to the manager, not this state model. +pub(crate) struct Lifecycle { + pub phase: Phase, + next_generation: u64, + failures: VecDeque, +} + +impl Default for Lifecycle { + fn default() -> Self { + Self { + phase: Phase::Idle, + next_generation: 0, + failures: VecDeque::new(), + } + } +} + +impl Lifecycle { + pub fn request_start(&mut self) -> Option { + if !self.phase.can_start() { + return None; + } + let Some(generation) = self.next_generation.checked_add(1) else { + self.phase = Phase::Failed; + return None; + }; + self.next_generation = generation; + self.phase = Phase::Starting(generation); + + Some(generation) + } + + pub fn spawned(&mut self, generation: u64) -> bool { + if self.phase != Phase::Starting(generation) { + return false; + } + self.phase = Phase::Spawned(generation); + + true + } + + pub fn ready(&mut self, generation: u64) -> bool { + if self.phase != Phase::Spawned(generation) { + return false; + } + self.phase = Phase::Running(generation); + + true + } + + pub fn begin_stop_after_failure(&mut self, generation: u64, now: Instant, retryable: bool) -> Option { + let current = matches!( + self.phase, + Phase::Starting(current) + | Phase::Spawned(current) + | Phase::Running(current) + if current == generation + ); + if !current { + return None; + } + + while self + .failures + .front() + .is_some_and(|at| now.saturating_duration_since(*at) > FAILURE_WINDOW) + { + self.failures.pop_front(); + } + self.failures.push_back(now); + + let after_stop = if !retryable || self.failures.len() >= MAX_FAILURES { + AfterStop::Failed + } else { + AfterStop::Backoff + }; + self.phase = Phase::Stopping { generation, after_stop }; + + Some(after_stop) + } + + pub fn backoff_delay(&self) -> Duration { + if self.failures.len() <= 1 { + Duration::from_millis(250) + } else { + Duration::from_secs(1) + } + } + + pub fn finish_stop(&mut self, generation: u64, now: Instant) -> Option { + let Phase::Stopping { + generation: current, + after_stop, + } = self.phase + else { + return None; + }; + if current != generation { + return None; + } + + self.phase = match after_stop { + AfterStop::Backoff => Phase::Backoff { + generation, + until: now + self.backoff_delay(), + }, + AfterStop::Failed => Phase::Failed, + AfterStop::Stopped => Phase::Stopped, + }; + + Some(after_stop) + } + + pub fn park_cleanup_failure(&mut self, generation: u64) -> bool { + let Phase::Stopping { + generation: current, .. + } = self.phase + else { + return false; + }; + if current != generation { + return false; + } + self.phase = Phase::CleanupFailed { generation }; + + true + } + + pub fn confirm_cleanup(&mut self, generation: u64) -> bool { + if self.phase != (Phase::CleanupFailed { generation }) { + return false; + } + self.phase = Phase::Failed; + + true + } + + pub fn finish_backoff(&mut self, generation: u64, now: Instant) -> bool { + match self.phase { + Phase::Backoff { + generation: current, + until, + } if current == generation && now >= until => { + self.phase = Phase::Idle; + true + } + _ => false, + } + } + + pub fn can_explicit_retry(&self) -> bool { + self.phase == Phase::Failed + } + + pub fn commit_explicit_retry(&mut self) -> bool { + if !self.can_explicit_retry() { + return false; + } + self.failures.clear(); + self.phase = Phase::Idle; + + true + } + + pub fn begin_shutdown_stop(&mut self) -> Option { + if self.phase == Phase::Stopped { + return None; + } + let generation = self.phase.generation().unwrap_or(0); + self.phase = Phase::Stopping { + generation, + after_stop: AfterStop::Stopped, + }; + + Some(generation) + } + + pub fn recent_failures(&self) -> usize { + self.failures.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn start(lifecycle: &mut Lifecycle) -> u64 { + lifecycle.request_start().expect("Idle must yield a generation ticket") + } + + /// Fail a generation the way the manager does, then complete the stop. + /// + /// Cleanup completing is what publishes the next phase, so a test that + /// needs the next generation must say so explicitly. + fn fail_and_stop(lifecycle: &mut Lifecycle, generation: u64, now: Instant, retryable: bool) -> AfterStop { + let after_stop = lifecycle + .begin_stop_after_failure(generation, now, retryable) + .expect("the current generation must accept a failure"); + assert_eq!( + lifecycle.finish_stop(generation, now), + Some(after_stop), + "cleanup completion publishes the chosen outcome" + ); + after_stop + } + + fn start_after_backoff(lifecycle: &mut Lifecycle) -> u64 { + let now = Instant::now(); + let Phase::Backoff { generation, until } = lifecycle.phase else { + panic!("expected a backoff, found {:?}", lifecycle.phase); + }; + assert!(lifecycle.finish_backoff(generation, until.max(now))); + start(lifecycle) + } + + #[test] + fn starts_from_idle_and_claims_one_generation_at_a_time() { + let mut lifecycle = Lifecycle::default(); + assert_eq!(lifecycle.phase, Phase::Idle); + + let generation = start(&mut lifecycle); + assert_eq!(generation, 1); + assert_eq!(lifecycle.phase, Phase::Starting(1)); + + assert_eq!(lifecycle.request_start(), None); + assert_eq!(lifecycle.phase, Phase::Starting(1)); + } + + #[test] + fn generations_are_monotonic() { + let mut lifecycle = Lifecycle::default(); + let first = start(&mut lifecycle); + lifecycle.ready(first); + fail_and_stop(&mut lifecycle, first, Instant::now(), true); + let second = start_after_backoff(&mut lifecycle); + + assert!(second > first); + } + + #[test] + fn spawn_then_readiness_reaches_running() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + + assert!(lifecycle.spawned(generation)); + assert_eq!(lifecycle.phase, Phase::Spawned(generation)); + assert!(lifecycle.ready(generation)); + assert_eq!(lifecycle.phase, Phase::Running(generation)); + assert!(lifecycle.phase.is_running()); + } + + #[test] + fn stale_generations_cannot_advance_the_phase() { + let mut lifecycle = Lifecycle::default(); + let stale = start(&mut lifecycle); + fail_and_stop(&mut lifecycle, stale, Instant::now(), true); + let current = start_after_backoff(&mut lifecycle); + + assert!(!lifecycle.spawned(stale)); + assert!(!lifecycle.ready(stale)); + assert_eq!(lifecycle.begin_stop_after_failure(stale, Instant::now(), true), None); + assert_eq!(lifecycle.phase, Phase::Starting(current)); + } + + #[test] + fn readiness_cannot_be_claimed_before_exec() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + + assert!(!lifecycle.ready(generation)); + assert_eq!(lifecycle.phase, Phase::Starting(generation)); + } + + #[test] + fn a_non_retryable_failure_opens_the_circuit_immediately() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + + assert_eq!( + fail_and_stop(&mut lifecycle, generation, Instant::now(), false), + AfterStop::Failed + ); + assert_eq!(lifecycle.phase, Phase::Failed); + assert!(!lifecycle.phase.can_start()); + } + + #[test] + fn backoff_is_bounded_and_then_opens_the_circuit() { + let mut lifecycle = Lifecycle::default(); + + let now = Instant::now(); + let first = start(&mut lifecycle); + assert_eq!(fail_and_stop(&mut lifecycle, first, now, true), AfterStop::Backoff); + let Phase::Backoff { until, .. } = lifecycle.phase else { + panic!("first failure must back off"); + }; + assert_eq!(until, now + Duration::from_millis(250)); + + let second = start_after_backoff(&mut lifecycle); + let now = Instant::now(); + assert_eq!(fail_and_stop(&mut lifecycle, second, now, true), AfterStop::Backoff); + let Phase::Backoff { until, .. } = lifecycle.phase else { + panic!("second failure must back off"); + }; + assert_eq!(until, now + Duration::from_secs(1)); + + let third = start_after_backoff(&mut lifecycle); + assert_eq!( + fail_and_stop(&mut lifecycle, third, Instant::now(), true), + AfterStop::Failed, + "reaching the failure limit must open the circuit instead of backing off" + ); + assert_eq!(lifecycle.phase, Phase::Failed); + assert_eq!(lifecycle.recent_failures(), MAX_FAILURES); + } + + #[test] + fn backoff_releases_to_idle_only_after_the_window() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + let now = Instant::now(); + fail_and_stop(&mut lifecycle, generation, now, true); + + let Phase::Backoff { until, .. } = lifecycle.phase else { + panic!("must be backing off"); + }; + + assert!(!lifecycle.finish_backoff(generation, until - Duration::from_millis(1))); + assert!(lifecycle.finish_backoff(generation, until)); + assert_eq!(lifecycle.phase, Phase::Idle); + } + + #[test] + fn a_successful_spawn_does_not_reset_the_failure_window() { + let mut lifecycle = Lifecycle::default(); + let first = start(&mut lifecycle); + fail_and_stop(&mut lifecycle, first, Instant::now(), true); + let second = start_after_backoff(&mut lifecycle); + + lifecycle.spawned(second); + lifecycle.ready(second); + + assert_eq!(lifecycle.recent_failures(), 1); + } + + #[test] + fn failures_outside_the_window_are_discarded() { + let mut lifecycle = Lifecycle::default(); + let start_time = Instant::now(); + let generation = start(&mut lifecycle); + fail_and_stop(&mut lifecycle, generation, Instant::now(), true); + assert_eq!(lifecycle.recent_failures(), 1); + + let second = start_after_backoff(&mut lifecycle); + let later = start_time + FAILURE_WINDOW + Duration::from_secs(1); + fail_and_stop(&mut lifecycle, second, later, true); + + assert_eq!(lifecycle.recent_failures(), 1); + } + + #[test] + fn explicit_retry_clears_the_circuit_but_a_connection_cannot() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + fail_and_stop(&mut lifecycle, generation, Instant::now(), false); + assert_eq!(lifecycle.phase, Phase::Failed); + + assert_eq!(lifecycle.request_start(), None); + + assert!(lifecycle.can_explicit_retry()); + assert!(lifecycle.commit_explicit_retry()); + assert_eq!(lifecycle.phase, Phase::Idle); + assert_eq!(lifecycle.recent_failures(), 0); + assert!(lifecycle.request_start().is_some()); + } + + #[test] + fn an_unconfirmed_cleanup_is_not_retryable_until_it_is_confirmed() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + let now = Instant::now(); + lifecycle + .begin_stop_after_failure(generation, now, true) + .expect("the current generation accepts a failure"); + + assert!(lifecycle.park_cleanup_failure(generation), "an in-flight stop parks"); + assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation }); + + // The deadline proved nothing, so the service must refuse to act on it. + assert!(!lifecycle.phase.can_start()); + assert_eq!(lifecycle.request_start(), None); + assert!( + !lifecycle.can_explicit_retry(), + "an unconfirmed cleanup is not retryable" + ); + assert!(!lifecycle.commit_explicit_retry()); + assert_eq!( + lifecycle.finish_stop(generation, Instant::now()), + None, + "a parked cleanup publishes nothing, not even the stop it replaced" + ); + assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation }); + + // Only evidence of a reclaimed group reopens the circuit. + assert!(lifecycle.confirm_cleanup(generation)); + assert_eq!(lifecycle.phase, Phase::Failed); + assert!(lifecycle.can_explicit_retry()); + assert!(lifecycle.commit_explicit_retry()); + assert_eq!(lifecycle.phase, Phase::Idle); + } + + #[test] + fn a_parked_cleanup_of_another_generation_is_never_confirmed() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + lifecycle + .begin_stop_after_failure(generation, Instant::now(), false) + .expect("the current generation accepts a failure"); + assert!(lifecycle.park_cleanup_failure(generation)); + + assert!(!lifecycle.confirm_cleanup(generation.wrapping_add(1))); + assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation }); + assert!(!lifecycle.can_explicit_retry()); + } + + #[test] + fn only_an_in_flight_stop_can_park_a_cleanup() { + let mut lifecycle = Lifecycle::default(); + assert!( + !lifecycle.park_cleanup_failure(1), + "an idle service has no cleanup to park" + ); + + let generation = start(&mut lifecycle); + assert!( + !lifecycle.park_cleanup_failure(generation), + "a start in flight is not a stop, so it cannot park" + ); + assert_eq!(lifecycle.phase, Phase::Starting(generation)); + + assert!( + !lifecycle.confirm_cleanup(generation), + "a confirmation outside a park must change nothing" + ); + assert_eq!(lifecycle.phase, Phase::Starting(generation)); + } + + #[test] + fn explicit_retry_is_refused_outside_the_failed_phase() { + let mut lifecycle = Lifecycle::default(); + assert!(!lifecycle.can_explicit_retry()); + assert!(!lifecycle.commit_explicit_retry()); + + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + assert!(!lifecycle.can_explicit_retry()); + assert!(!lifecycle.commit_explicit_retry()); + } + + #[test] + fn a_failed_generation_is_not_startable_until_cleanup_completes() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + + let now = Instant::now(); + let after_stop = lifecycle + .begin_stop_after_failure(generation, now, true) + .expect("the current generation accepts a failure"); + + // The process is still alive and still holds the listeners, so the + // lifecycle must not be startable and must not look like a failure + // that a queued connection could act on. + assert!(lifecycle.phase.is_stopping()); + assert!(!lifecycle.phase.can_start()); + assert_eq!(lifecycle.request_start(), None); + assert!(!lifecycle.can_explicit_retry()); + + // Only cleanup finishing publishes the retryable outcome. + assert_eq!( + lifecycle.finish_stop(generation, now), + Some(after_stop), + "cleanup completion publishes the outcome" + ); + assert!(matches!(lifecycle.phase, Phase::Backoff { .. })); + assert!(!lifecycle.phase.can_start(), "backoff still blocks a start"); + } + + #[test] + fn an_abandoned_cleanup_cannot_publish_a_newer_generation() { + let mut lifecycle = Lifecycle::default(); + let generation = start(&mut lifecycle); + lifecycle.spawned(generation); + + lifecycle + .begin_stop_after_failure(generation, Instant::now(), false) + .expect("the current generation accepts a failure"); + assert_eq!(lifecycle.finish_stop(generation.wrapping_add(1), Instant::now()), None); + + // A stale completion leaves the real transition in flight. + assert!(lifecycle.phase.is_stopping()); + assert!(lifecycle.finish_stop(generation, Instant::now()).is_some()); + assert_eq!(lifecycle.phase, Phase::Failed); + } + + #[test] + fn stopping_invalidates_scheduled_starts() { + let mut lifecycle = Lifecycle::default(); + let _generation = start(&mut lifecycle); + + let generation = lifecycle.begin_shutdown_stop().expect("a stop is required"); + assert!(lifecycle.phase.is_stopping()); + assert_eq!( + lifecycle.finish_stop(generation, Instant::now()), + Some(AfterStop::Stopped) + ); + assert_eq!(lifecycle.phase, Phase::Stopped); + assert_eq!(lifecycle.request_start(), None); + assert!(!lifecycle.spawned(generation)); + assert!(!lifecycle.ready(generation)); + + // Shutdown is idempotent once stopped. + assert_eq!(lifecycle.begin_shutdown_stop(), None); + assert_eq!(lifecycle.phase, Phase::Stopped); + } + + #[test] + fn phase_reports_its_generation() { + assert_eq!(Phase::Starting(4).generation(), Some(4)); + assert_eq!( + Phase::Backoff { + generation: 4, + until: Instant::now() + } + .generation(), + Some(4) + ); + assert_eq!(Phase::Idle.generation(), None); + assert_eq!(Phase::Failed.generation(), None); + assert_eq!( + Phase::CleanupFailed { generation: 4 }.generation(), + Some(4), + "a parked cleanup names the generation it still owns" + ); + } +} diff --git a/compositor/src/xwayland/mod.rs b/compositor/src/xwayland/mod.rs new file mode 100644 index 0000000..72cd227 --- /dev/null +++ b/compositor/src/xwayland/mod.rs @@ -0,0 +1,1291 @@ +mod auth; +mod child; +#[cfg(test)] +mod integration_tests; +mod lifecycle; +mod readiness; +mod sockets; + +use std::ffi::OsString; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +use std::time::Duration; + +use calloop::generic::Generic; +use calloop::timer::{TimeoutAction, Timer}; +use calloop::{Interest, LoopHandle, Mode, PostAction, RegistrationToken}; +use tracing::{info, warn}; + +use crate::Ferese; +use crate::config::{XwaylandConfig, XwaylandStartup}; + +use ferese_ipc::xwayland::Readiness; + +use self::auth::AuthorityFile; +use self::child::{Observation, OwnedProcessGroup}; +use self::lifecycle::{AfterStop, Lifecycle, Phase}; +use self::readiness::ReadinessSocket; +use self::sockets::Reservation; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const READINESS_DRAIN_BUDGET: usize = 16; +const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(250); +const STOP_TIMEOUT: Duration = Duration::from_secs(2); + +type Loop = LoopHandle<'static, Ferese>; + +#[cfg(test)] +pub(crate) mod test_hooks { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::Duration; + + pub(crate) static PIDFD_UNAVAILABLE: AtomicBool = AtomicBool::new(false); + pub(crate) static FAIL_LISTENER_REGISTRATION_AFTER: AtomicUsize = AtomicUsize::new(usize::MAX); + pub(crate) static FAIL_REBIND_AFTER: AtomicUsize = AtomicUsize::new(usize::MAX); + pub(crate) static STARTUP_DEADLINE_MS: AtomicUsize = AtomicUsize::new(0); + /// Pretend the previous service group can never be reclaimed, so a test can + /// reach the unconfirmed-cleanup state without an unkillable survivor. + pub(crate) static FORCE_GROUP_NOT_EMPTY: AtomicBool = AtomicBool::new(false); + + pub(crate) fn force_group_not_empty() -> bool { + FORCE_GROUP_NOT_EMPTY.load(Ordering::SeqCst) + } + + /// True when this call must fail instead of performing its operation. + /// + /// `usize::MAX` is idle; smaller values count allowed calls, then keep firing. + pub(crate) fn fire(counter: &AtomicUsize) -> bool { + loop { + let current = counter.load(Ordering::SeqCst); + if current == usize::MAX { + return false; + } + if current == 0 { + return true; + } + if counter + .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return false; + } + } + } + + pub(crate) fn startup_deadline() -> Option { + match STARTUP_DEADLINE_MS.load(Ordering::SeqCst) { + 0 => None, + milliseconds => Some(Duration::from_millis(milliseconds as u64)), + } + } + + // Directory the display allocator must use instead of `/tmp/.X11-unix`; + // thread-local so parallel tests keep their own inputs. + thread_local! { + static ALLOCATOR_ROOT: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + } + + /// Point the display allocator at an owned directory for one test. + pub(crate) fn allocator_root(path: std::path::PathBuf) { + allocator_root_in(path.clone(), path); + } + + /// Point the display allocator at explicit socket and lock directories. + /// + /// An X client resolves `DISPLAY` against `/tmp/.X11-unix`, so the live test names it. + pub(crate) fn allocator_root_in(sockets: std::path::PathBuf, locks: std::path::PathBuf) { + ALLOCATOR_ROOT.with(|slot| *slot.borrow_mut() = Some((sockets, locks))); + } + + pub(crate) fn allocator_root_for_reservation() -> Option<(std::path::PathBuf, std::path::PathBuf)> { + ALLOCATOR_ROOT.with(|slot| slot.borrow().clone()) + } + + /// Owned display-slot inputs for a test that never named a root: an unset + /// root falls back to a private per-thread directory, never `/tmp/.X11-unix`. + pub(crate) fn default_allocator_root() -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "ferese-x11-sockets-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::create_dir_all(&root); + let _ = std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)); + root + } + + pub(crate) fn reset() { + PIDFD_UNAVAILABLE.store(false, Ordering::SeqCst); + FAIL_LISTENER_REGISTRATION_AFTER.store(usize::MAX, Ordering::SeqCst); + FAIL_REBIND_AFTER.store(usize::MAX, Ordering::SeqCst); + STARTUP_DEADLINE_MS.store(0, Ordering::SeqCst); + FORCE_GROUP_NOT_EMPTY.store(false, Ordering::SeqCst); + ALLOCATOR_ROOT.with(|slot| *slot.borrow_mut() = None); + } +} + +#[cfg(test)] +fn startup_deadline() -> Duration { + test_hooks::startup_deadline().unwrap_or(STARTUP_TIMEOUT) +} + +#[cfg(not(test))] +fn startup_deadline() -> Duration { + STARTUP_TIMEOUT +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct XwaylandStatus { + pub enabled: bool, + pub effective_startup: String, + pub phase: &'static str, + pub display: Option, + pub satellite_pid: Option, + pub generation: Option, + pub readiness: ferese_ipc::xwayland::Readiness, + pub recent_failures: u32, + pub last_error: Option, +} + +#[derive(Default)] +struct GenerationSources { + readiness_socket: Option, + readiness: Option, + startup_deadline: Option, + exit_watch: Option, + stop_poll: Option, +} + +impl GenerationSources { + fn is_empty(&self) -> bool { + self.readiness_socket.is_none() + && self.readiness.is_none() + && self.startup_deadline.is_none() + && self.exit_watch.is_none() + && self.stop_poll.is_none() + } + + fn release(&mut self, loop_handle: &Loop) { + self.readiness_socket = None; + if let Some(token) = self.readiness.take() { + loop_handle.remove(token); + } + if let Some(token) = self.startup_deadline.take() { + loop_handle.remove(token); + } + if let Some(token) = self.exit_watch.take() { + loop_handle.remove(token); + } + if let Some(token) = self.stop_poll.take() { + loop_handle.remove(token); + } + } +} + +pub(crate) struct XwaylandManager { + lifecycle: Lifecycle, + config: XwaylandConfig, + reservation: Reservation, + authority: AuthorityFile, + notify_directory: tempfile::TempDir, + display: String, + process: Option, + sources: GenerationSources, + backoff_token: Option, + stop_kill_deadline: Option, + stop_kill_grace: Option, + listener_tokens: Vec, + last_error: Option, + readiness_verdict: Readiness, +} + +impl XwaylandManager { + pub fn status(&self) -> XwaylandStatus { + XwaylandStatus { + enabled: self.config.enabled, + effective_startup: match self.config.startup { + XwaylandStartup::OnDemand => "on-demand", + XwaylandStartup::Eager => "eager", + } + .to_owned(), + phase: phase_name(self.lifecycle.phase), + display: Some(self.display.clone()), + satellite_pid: self.process.as_ref().map(OwnedProcessGroup::pid), + generation: self.lifecycle.phase.generation(), + readiness: self.readiness_verdict, + recent_failures: self.lifecycle.recent_failures() as u32, + last_error: self.last_error.clone(), + } + } + + pub fn config(&self) -> &XwaylandConfig { + &self.config + } + + fn owns_generation(&self, generation: u64) -> bool { + self.lifecycle.phase.generation() == Some(generation) + } + + fn projected_state(&self) -> ferese_ipc::xwayland::State { + use ferese_ipc::xwayland::State; + match self.lifecycle.phase { + Phase::Idle => State::Idle, + // Exec succeeded but the service has not reported readiness, so it + // is not running yet: children must not be told otherwise. + Phase::Starting(_) | Phase::Spawned(_) => State::Starting, + Phase::Running(_) => State::Running, + // Cleanup owns the previous service; the endpoint is not usable. + Phase::Stopping { .. } | Phase::Stopped => State::Stopped, + Phase::Backoff { .. } => State::Backoff, + // Failed and still failed: a parked cleanup refuses a retry until + // the group it owns is reclaimed, which `last_error` explains. + Phase::CleanupFailed { .. } | Phase::Failed => State::Failed, + } + } + + fn x11_environment(&self) -> crate::X11Environment { + crate::X11Environment { + display: OsString::from(&self.display), + authority: self.authority.path().to_owned(), + } + } + + fn disable_listeners(&self, loop_handle: &Loop) { + for token in &self.listener_tokens { + let _ = loop_handle.disable(token); + } + } + + fn enable_listeners(&self, loop_handle: &Loop) { + for token in &self.listener_tokens { + let _ = loop_handle.enable(token); + } + } + + // Disabling a source leaves its listener clone open; removal releases it. + fn remove_listener_sources(&mut self, loop_handle: &Loop) { + for token in self.listener_tokens.drain(..) { + loop_handle.remove(token); + } + } + + fn release_generation_sources(&mut self, loop_handle: &Loop) { + self.sources.release(loop_handle); + } + + fn remove_backoff_timer(&mut self, loop_handle: &Loop) { + if let Some(token) = self.backoff_token.take() { + loop_handle.remove(token); + } + } + + fn note_verified_readiness(&mut self) { + self.readiness_verdict = Readiness::Verified; + } + + fn finish_ready(&mut self, loop_handle: &Loop) { + self.note_verified_readiness(); + + if let Some(token) = self.sources.readiness.take() { + loop_handle.remove(token); + } + + if let Some(token) = self.sources.startup_deadline.take() { + loop_handle.remove(token); + } + + self.sources.readiness_socket = None; + self.last_error = None; + } + + fn install_exit_watch(&mut self, loop_handle: &Loop, generation: u64, pid: u32) -> Result<(), String> { + if let Ok(fd) = crate::process::pidfd(pid) { + match loop_handle.insert_source(Generic::new(fd, Interest::READ, Mode::Level), move |_, _, state| { + let handle = state.loop_handle.clone(); + if let Some(manager) = state.xwayland.as_mut() { + manager.handle_exit(&handle, generation, pid); + } + Ok(PostAction::Remove) + }) { + Ok(token) => { + self.sources.exit_watch = Some(token); + return Ok(()); + } + Err(error) => warn!(%error, pid, "cannot register the X11 service exit source"), + } + } else { + warn!(pid, "pidfd unavailable; using a bounded poll to watch the X11 service"); + } + + self.install_exit_poll(loop_handle, generation, pid) + } + + fn install_exit_poll(&mut self, loop_handle: &Loop, generation: u64, pid: u32) -> Result<(), String> { + let timer = Timer::from_duration(CHILD_POLL_INTERVAL); + let token = loop_handle + .insert_source(timer, move |_, _, state| { + let handle = state.loop_handle.clone(); + let observed = state + .xwayland + .as_mut() + .filter(|manager| manager.owns(generation, pid)) + .and_then(|manager| manager.process.as_mut()) + .map(|process| process.observe()); + match observed { + Some(Observation::Running) => return TimeoutAction::ToDuration(CHILD_POLL_INTERVAL), + Some(Observation::Exited) | Some(Observation::Failed(_)) | None => {} + } + if let Some(manager) = state.xwayland.as_mut() { + manager.handle_exit(&handle, generation, pid); + } + TimeoutAction::Drop + }) + .map_err(|error| format!("cannot schedule the X11 service exit poll: {error}"))?; + self.sources.exit_watch = Some(token); + + Ok(()) + } + + fn owns(&self, generation: u64, pid: u32) -> bool { + self.lifecycle.phase.generation() == Some(generation) + && self.process.as_ref().is_some_and(|process| process.pid() == pid) + } + + fn handle_exit(&mut self, loop_handle: &Loop, generation: u64, pid: u32) { + if !self.owns(generation, pid) { + return; + } + + let retryable = self.lifecycle.phase.is_running(); + let detail = "X11 service exited".to_owned(); + self.fail(loop_handle, generation, &detail, retryable); + } + + fn fail(&mut self, loop_handle: &Loop, generation: u64, error: &str, retryable: bool) { + self.last_error = Some(error.to_owned()); + // The verdict describes the generation that just ended. + self.readiness_verdict = Readiness::NotAttempted; + let Some(after_stop) = + self.lifecycle + .begin_stop_after_failure(generation, std::time::Instant::now(), retryable) + else { + return; + }; + + warn!(generation, %error, "X11 service generation failed"); + self.begin_stop(loop_handle, generation, after_stop); + } + + fn begin_stop(&mut self, loop_handle: &Loop, generation: u64, after_stop: AfterStop) { + if let Some(token) = self.sources.readiness.take() { + loop_handle.remove(token); + } + + if let Some(token) = self.sources.startup_deadline.take() { + loop_handle.remove(token); + } + + if let Some(token) = self.sources.exit_watch.take() { + loop_handle.remove(token); + } + + self.sources.readiness_socket = None; + self.disable_listeners(loop_handle); + debug_assert!(self.sources.is_empty(), "a stop must release every source it owns"); + + let Some(process) = self.process.as_mut() else { + // Nothing survived, so cleanup is already complete. + self.complete_stop(loop_handle, generation, after_stop); + return; + }; + + // Ask the whole group to exit, then bound how long a cooperative group + // may take before SIGKILL. The leader stays unreaped throughout, so the + // process-group identifier cannot be recycled under us. + process.signal_group(libc::SIGTERM); + self.stop_kill_deadline = Some(std::time::Instant::now() + STOP_TIMEOUT); + + match self.process.as_mut().map(OwnedProcessGroup::observe) { + // The group, not just the leader, has to be gone. + Some(Observation::Exited) | None => { + self.wait_for_empty_group(loop_handle, generation, after_stop); + if self.process.is_none() || !matches!(self.lifecycle.phase, Phase::Stopping { .. }) { + // Cleanup finished, or was parked as unconfirmed, inside + // the helper: either way no poll belongs to this stop. + return; + } + } + Some(Observation::Failed(error)) => { + self.note_cleanup_failure(loop_handle, generation, error); + return; + } + Some(Observation::Running) => {} + } + + self.schedule_stop_poll(loop_handle, generation, after_stop); + } + + fn schedule_stop_poll(&mut self, loop_handle: &Loop, generation: u64, after_stop: AfterStop) { + let timer = Timer::from_duration(CHILD_POLL_INTERVAL); + match loop_handle.insert_source(timer, move |_, _, state| { + let handle = state.loop_handle.clone(); + let Some(manager) = state.xwayland.as_mut() else { + return TimeoutAction::Drop; + }; + + if !manager.owns(generation, manager.process.as_ref().map_or(0, OwnedProcessGroup::pid)) { + return TimeoutAction::Drop; + } + + let observed = manager.process.as_mut().map(OwnedProcessGroup::observe); + match observed { + Some(Observation::Running) => { + let overdue = manager + .stop_kill_deadline + .is_some_and(|deadline| std::time::Instant::now() >= deadline); + if overdue && let Some(process) = manager.process.as_mut() { + process.signal_group(libc::SIGKILL); + } + TimeoutAction::ToDuration(CHILD_POLL_INTERVAL) + } + Some(Observation::Exited) => { + manager.wait_for_empty_group(&handle, generation, after_stop); + match manager.sources.stop_poll { + Some(_) => TimeoutAction::ToDuration(CHILD_POLL_INTERVAL), + None => TimeoutAction::Drop, + } + } + Some(Observation::Failed(error)) => { + manager.note_cleanup_failure(&handle, generation, error); + TimeoutAction::Drop + } + None => { + manager.complete_stop(&handle, generation, after_stop); + TimeoutAction::Drop + } + } + }) { + Ok(token) => self.sources.stop_poll = Some(token), + Err(error) => { + // Without a poll nothing would ever reap this process. Fall back + // to one bounded synchronous stop rather than forget it. + warn!(%error, generation, "cannot schedule X11 cleanup; stopping synchronously"); + self.complete_stop(loop_handle, generation, after_stop); + } + } + } + + fn wait_for_empty_group(&mut self, loop_handle: &Loop, generation: u64, after_stop: AfterStop) { + let Some(process) = self.process.as_mut() else { + self.complete_stop(loop_handle, generation, after_stop); + return; + }; + + if !process.is_reaped() { + // The leader still owns the group identifier, so signal first and + // reap second. A zombie holds no descriptors, but it would keep the + // group present for ever and must not be part of the emptiness test. + process.signal_group(libc::SIGKILL); + if let Err(error) = process.reap() { + self.note_cleanup_failure(loop_handle, generation, error); + return; + } + } + + if self.process.as_ref().is_some_and(OwnedProcessGroup::group_is_empty) { + self.complete_stop(loop_handle, generation, after_stop); + return; + } + + // Only a survivor of SIGKILL gets here. Bound the wait, then report it + // rather than wedge shutdown or pretend the group was reclaimed. + let now = std::time::Instant::now(); + match self.stop_kill_grace { + None => { + warn!(generation, "the X11 service left survivors after SIGKILL; waiting"); + self.stop_kill_grace = Some(now + STOP_TIMEOUT); + } + Some(grace) if now >= grace => { + let detail = "the X11 service group survived SIGKILL".to_owned(); + warn!(generation, %detail, "cannot reclaim the X11 service group; keeping it under ownership"); + self.last_error = Some(detail); + // A deadline is not proof that the group is gone, so this must + // not publish a stop that would drop the only handle on it. + self.park_cleanup_failure(loop_handle, generation); + } + Some(_) => {} + } + } + + fn note_cleanup_failure(&mut self, loop_handle: &Loop, generation: u64, error: String) { + let detail = format!("cannot clean up the X11 service: {error}"); + warn!(generation, %detail, "X11 cleanup is incomplete"); + self.last_error = Some(detail.clone()); + if let Some(process) = self.process.as_mut() { + process.note_cleanup_error(detail); + } + + // An incomplete cleanup must never leave the service startable, and it + // must not be published as if the resources were gone. + self.park_cleanup_failure(loop_handle, generation); + } + + /// Keep an unconfirmed cleanup under ownership instead of publishing it. + /// + /// The stop is over as far as the event loop is concerned, but the record + /// it was cleaning up is not: survivors may still hold the X11 listeners. + /// The record stays owned, the endpoint stays closed, and every start + /// stays refused until [`Self::confirm_cleanup`] proves the group is + /// empty. + fn park_cleanup_failure(&mut self, loop_handle: &Loop, generation: u64) { + if let Some(token) = self.sources.stop_poll.take() { + loop_handle.remove(token); + } + self.stop_kill_deadline = None; + self.stop_kill_grace = None; + + // Reap here, while the process-group identity is still the one we own. + if let Some(process) = self.process.as_mut() + && let Err(error) = process.reap() + { + process.note_cleanup_error(error.clone()); + self.last_error = Some(error); + } + + if !self.lifecycle.park_cleanup_failure(generation) { + // Nothing current is waiting on this record, so it must not be + // mistaken for the cleanup of whatever the lifecycle moved on to. + warn!( + generation, + "an unconfirmed cleanup arrived for a generation that is no longer current" + ); + return; + } + debug_assert!( + self.process.is_some(), + "a parked cleanup keeps the record that still owns the group" + ); + + // An open listening socket with no consumer would strand clients, and + // the path must not exist while survivors may still own the old one. + self.remove_listener_sources(loop_handle); + self.reservation.close_listeners(); + } + + /// Prove a parked cleanup is actually finished, so a retry may proceed. + /// + /// Only an empty group counts. The deadline that parked the stop said + /// nothing about the group, so the circuit reopens on evidence alone. + fn confirm_cleanup(&mut self) -> Result<(), String> { + let Phase::CleanupFailed { generation } = self.lifecycle.phase else { + return Ok(()); + }; + + let confirmed = match self.process.as_mut() { + // Nothing is owned any more, so there is nothing left to reclaim. + None => true, + Some(process) => match process.reap() { + Ok(_) => process.group_is_empty(), + Err(error) => { + let error = format!("cannot reap the previous X11 service: {error}"); + self.last_error = Some(error.clone()); + return Err(error); + } + }, + }; + + if !confirmed { + let error = "the previous X11 service group still holds the X11 display; \ + cleanup has not finished, so X11 cannot be retried yet" + .to_owned(); + self.last_error = Some(error.clone()); + return Err(error); + } + + self.process = None; + if !self.lifecycle.confirm_cleanup(generation) { + return Err("the X11 service left the unconfirmed cleanup state while it was checked".to_owned()); + } + info!( + generation, + "reclaimed the previous X11 service group; retrying is allowed again" + ); + + Ok(()) + } + + fn complete_stop(&mut self, loop_handle: &Loop, generation: u64, after_stop: AfterStop) { + if let Some(token) = self.sources.stop_poll.take() { + loop_handle.remove(token); + } + + self.stop_kill_deadline = None; + self.stop_kill_grace = None; + + // Reap here, while the process-group identity is still the one we own. + if let Some(process) = self.process.as_mut() + && let Err(error) = process.reap() + { + process.note_cleanup_error(error.clone()); + self.last_error = Some(error); + } + // A leader that could not be reaped may still hold the display, so the + // stop must not publish the outcome it was scheduled to publish. + if self + .process + .as_ref() + .is_some_and(|process| process.cleanup_error().is_some()) + { + self.park_cleanup_failure(loop_handle, generation); + return; + } + // Safe now: the leader has been reaped, so Drop has nothing to signal. + self.process = None; + + let Some(published) = self.lifecycle.finish_stop(generation, std::time::Instant::now()) else { + return; + }; + debug_assert!( + published == after_stop || published == AfterStop::Failed, + "cleanup may only strengthen the stop result: requested {after_stop:?}, published {published:?}" + ); + + match published { + AfterStop::Backoff => { + let delay = self.lifecycle.backoff_delay(); + let timer = Timer::from_duration(delay); + match loop_handle.insert_source(timer, move |_, _, state| { + let handle = state.loop_handle.clone(); + if let Some(manager) = state.xwayland.as_mut() { + // Idle again: a still-queued connection can wake us. + if manager.lifecycle.finish_backoff(generation, std::time::Instant::now()) { + manager.enable_listeners(&handle); + } + } + TimeoutAction::Drop + }) { + Ok(token) => self.backoff_token = Some(token), + Err(error) => warn!(%error, "cannot schedule the X11 backoff retry"), + } + } + AfterStop::Failed => { + // The circuit is open. An open listening socket with no consumer + // would strand clients, so fail fast instead. + warn!("X11 endpoint disabled after repeated failures; waiting for an explicit retry"); + self.remove_listener_sources(loop_handle); + self.reservation.close_listeners(); + } + AfterStop::Stopped => {} + } + } + + pub fn explicit_retry(&mut self, loop_handle: &Loop) -> Result<(), String> { + // The previous group may still hold the display. Nothing below may run + // until that is proven false, or a replacement would be handed an + // endpoint an unknown survivor still owns. + if matches!(self.lifecycle.phase, Phase::CleanupFailed { .. }) { + self.confirm_cleanup()?; + } + + // A precondition check, not a transition: preparation happens first so + // a failure cannot leave the service in a state that rejects its own + // retry or advertises listeners nobody is watching. + if !self.lifecycle.can_explicit_retry() { + return Err("X11 is not in a failed state, so there is nothing to retry".to_owned()); + } + if self.process.is_some() { + return Err("X11 is still stopping its previous service; retry once it has finished".to_owned()); + } + + // Rebind the **same** display so launchers that already learned the + // display number keep working. Never silently switch displays. + if let Err(error) = self.reservation.bind_same_display() { + let error = format!("cannot rebind X11 display {}: {error}", self.display); + // Still in `Failed`, so another explicit retry remains allowed. + self.last_error = Some(error.clone()); + return Err(error); + } + + // Re-register activation sources. On failure, close what was just bound + // and stay in `Failed` so the endpoint keeps failing fast. + self.remove_listener_sources(loop_handle); + if let Err(error) = register_listeners(loop_handle, self) { + let error = format!("cannot watch the X11 listeners: {error}"); + self.remove_listener_sources(loop_handle); + self.reservation.close_listeners(); + self.last_error = Some(error.clone()); + return Err(error); + } + + if !self.lifecycle.commit_explicit_retry() { + return Err("X11 left the failed state while it was being prepared".to_owned()); + } + self.last_error = None; + info!(display = %self.display, "retrying the X11 service on the same display"); + + Ok(()) + } +} + +fn phase_name(phase: Phase) -> &'static str { + match phase { + Phase::Idle => "idle", + Phase::Starting(_) => "starting", + Phase::Spawned(_) => "starting", + Phase::Running(_) => "running", + Phase::Stopping { .. } => "stopping", + Phase::Backoff { .. } => "backoff", + Phase::CleanupFailed { .. } => "cleanup-failed", + Phase::Failed => "failed", + Phase::Stopped => "stopped", + } +} + +pub(crate) fn runtime_directory() -> Result { + let raw = std::env::var_os("XDG_RUNTIME_DIR") + .filter(|value| !value.is_empty()) + .ok_or("XDG_RUNTIME_DIR is not set, so X11 has no private per-session directory")?; + let path = PathBuf::from(raw); + validate_runtime_directory(&path)?; + + Ok(path) +} + +fn validate_runtime_directory(path: &std::path::Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| format!("XDG_RUNTIME_DIR is unusable ({}): {error}", path.display()))?; + let uid = unsafe { libc::geteuid() }; + if !metadata.is_dir() || metadata.file_type().is_symlink() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 + { + return Err(format!( + "XDG_RUNTIME_DIR is not a private user-owned directory: {}", + path.display() + )); + } + + Ok(()) +} + +pub fn initialize(state: &mut Ferese) { + if !state.xwayland_config.enabled { + info!("X11 support is disabled by configuration"); + return; + } + + if let Err(error) = start_service(state) { + warn!(%error, "X11 unavailable; continuing with a native Wayland desktop"); + state.x11_diagnostic = Some(error); + return; + } + + if state.xwayland_config.startup == XwaylandStartup::Eager { + if let Err(error) = request_first_generation(state) { + warn!(%error, "X11 could not be requested at startup; continuing with a native Wayland desktop"); + state.x11_diagnostic = Some(error); + } + } else { + info!("X11 is available on demand; no X11 process has been started"); + } +} + +fn start_service(state: &mut Ferese) -> Result<(), String> { + let config = state.xwayland_config.clone(); + let loop_handle = state.loop_handle.clone(); + let mut manager = prepare(&config)?; + + state.session_environment.x11 = Some(manager.x11_environment()); + + if let Err(error) = register_listeners(&loop_handle, &mut manager) { + state.session_environment.x11 = None; + return Err(format!("cannot watch the X11 listening sockets: {error}")); + } + + let display_name = manager.display.clone(); + state.xwayland = Some(manager); + state.x11_diagnostic = None; + info!(display = %display_name, "reserved an X11 display for the session"); + + Ok(()) +} + +fn request_first_generation(state: &mut Ferese) -> Result { + request_start(state) + .ok_or_else(|| "X11 could not be started yet; a previous attempt is still in progress".to_owned()) +} + +/// Create the private directory that holds one service instance's readiness +/// socket. +/// +/// Production and the tests share this constructor so the mode is asserted +/// against the same code that ships: the directory is `0700` whatever the +/// session umask is, because another user must not be able to place a socket +/// where Ferese looks for notifications. +fn notification_directory(runtime_directory: &std::path::Path) -> Result { + tempfile::Builder::new() + .prefix("ferese-x11-") + .rand_bytes(16) + .permissions(std::os::unix::fs::PermissionsExt::from_mode(0o700)) + .tempdir_in(runtime_directory) + .map_err(|error| format!("cannot create the X11 notification directory: {error}")) +} + +fn prepare(config: &XwaylandConfig) -> Result { + let runtime_directory = runtime_directory()?; + let notify_directory = notification_directory(&runtime_directory)?; + let reservation = Reservation::allocate().map_err(|error| format!("cannot reserve an X11 display: {error}"))?; + let display = reservation.display_number(); + let authority = AuthorityFile::create(&runtime_directory, display) + .map_err(|error| format!("cannot create an Xauthority file: {error}"))?; + + Ok(XwaylandManager { + lifecycle: Lifecycle::default(), + config: config.clone(), + display: reservation.display_name(), + reservation, + authority, + notify_directory, + process: None, + sources: GenerationSources::default(), + backoff_token: None, + stop_kill_deadline: None, + stop_kill_grace: None, + listener_tokens: Vec::new(), + last_error: None, + readiness_verdict: Readiness::NotAttempted, + }) +} + +// Xwayland, not this callback, must accept the queued client connection. +fn register_listeners(loop_handle: &Loop, manager: &mut XwaylandManager) -> Result<(), String> { + let mut inserted = Vec::new(); + + for listener in manager.reservation.listeners() { + #[cfg(test)] + if test_hooks::fire(&test_hooks::FAIL_LISTENER_REGISTRATION_AFTER) { + for token in inserted.drain(..) { + loop_handle.remove(token); + } + return Err("injected X11 listener registration failure".to_owned()); + } + + let listener = listener.try_clone().map_err(|error| { + for token in inserted.drain(..) { + loop_handle.remove(token); + } + error.to_string() + })?; + let token = loop_handle + .insert_source(Generic::new(listener, Interest::READ, Mode::Level), |_, _, state| { + let handle = state.loop_handle.clone(); + + let ticket = state.xwayland.as_mut().and_then(|manager| { + let ticket = manager.lifecycle.request_start(); + if ticket.is_some() { + manager.disable_listeners(&handle); + } + ticket + }); + + if let Some(generation) = ticket { + handle.insert_idle(move |state| { + let current = state + .xwayland + .as_ref() + .is_some_and(|manager| manager.owns_generation(generation)); + if current { + start_generation(state, generation); + } + }); + } + + Ok(PostAction::Disable) + }) + .map_err(|error| { + for token in inserted.drain(..) { + loop_handle.remove(token); + } + error.to_string() + })?; + + inserted.push(token); + } + + manager.listener_tokens.extend(inserted); + + Ok(()) +} + +pub fn retry(state: &mut Ferese) -> Result<(), String> { + if !state.xwayland_config.enabled { + return Err("X11 is disabled in the configuration; enable it and start a new session".to_owned()); + } + + if let Some(manager) = state.xwayland.as_mut() { + let loop_handle = state.loop_handle.clone(); + manager.explicit_retry(&loop_handle)?; + } else if let Err(error) = start_service(state) { + state.x11_diagnostic = Some(error.clone()); + return Err(error); + } + + request_first_generation(state)?; + state.x11_diagnostic = None; + Ok(()) +} + +pub fn status_snapshot(state: &Ferese) -> ferese_ipc::xwayland::Status { + use ferese_ipc::xwayland::{State, Status}; + + let Some(manager) = state.xwayland.as_ref() else { + let disabled = !state.xwayland_config.enabled; + return Status { + enabled: state.xwayland_config.enabled, + effective_startup: match state.xwayland_config.startup { + XwaylandStartup::OnDemand => "on-demand", + XwaylandStartup::Eager => "eager", + } + .to_owned(), + state: if disabled { State::Disabled } else { State::Failed }, + display: None, + satellite_pid: None, + generation: None, + readiness: Readiness::NotAttempted, + recent_failures: 0, + // There is no live service whose configuration could drift, so + // nothing needs a restart to take effect. + restart_required: false, + last_error: if disabled { + None + } else { + // Never report an empty failure: say what the user can do. + Some( + state.x11_diagnostic.clone().unwrap_or_else(|| { + "the X11 service is not running; retry it or start a new session".to_owned() + }), + ) + }, + }; + }; + + let status = manager.status(); + Status { + enabled: status.enabled, + effective_startup: status.effective_startup, + state: manager.projected_state(), + display: status.display, + satellite_pid: status.satellite_pid, + generation: status.generation, + readiness: status.readiness, + recent_failures: status.recent_failures, + restart_required: manager.config() != &state.xwayland_config, + last_error: status.last_error, + } +} + +pub fn request_start(state: &mut Ferese) -> Option { + let handle = state.loop_handle.clone(); + let ticket = state + .xwayland + .as_mut() + .and_then(|manager| manager.lifecycle.request_start()); + + if let Some(generation) = ticket { + handle.insert_idle(move |state| start_generation(state, generation)); + } + + ticket +} + +fn start_generation(state: &mut Ferese, generation: u64) { + let loop_handle = state.loop_handle.clone(); + let Some(manager) = state.xwayland.as_mut() else { + return; + }; + if !manager.owns_generation(generation) || manager.lifecycle.phase != Phase::Starting(generation) { + return; + } + + manager.disable_listeners(&loop_handle); + manager.release_generation_sources(&loop_handle); + + let socket = match ReadinessSocket::create(manager.notify_directory.path(), generation) { + Ok(socket) => socket, + Err(error) => { + let error = format!("cannot create the X11 readiness socket: {error}"); + manager.fail(&loop_handle, generation, &error, false); + return; + } + }; + let notify_path = socket.path().to_owned(); + manager.readiness_verdict = Readiness::Pending; + + let environment = state.session_environment.clone(); + let config_path = manager.config.path.clone(); + let authority = manager.authority.path().to_owned(); + + let spawned = child::spawn_satellite( + &config_path, + &environment, + &manager.reservation, + authority.as_path(), + Some(notify_path.as_path()), + ); + + let satellite = match spawned { + Ok(child) => child, + Err(error) => { + let error = format!("cannot start {}: {error}", config_path.display()); + manager.fail(&loop_handle, generation, &error, false); + return; + } + }; + + if !manager.lifecycle.spawned(generation) { + // The state machine moved on. Take ownership first so the process is + // reaped through the same contract rather than leaked. + let mut process = OwnedProcessGroup::adopt(satellite); + let _ = process.terminate(STOP_TIMEOUT); + return; + } + let pid = satellite.id(); + info!(pid, generation, "started the X11 service"); + + // Publish ownership first, so any later failure has something to stop. + manager.sources.readiness_socket = Some(socket); + manager.process = Some(OwnedProcessGroup::adopt(satellite)); + + // Acquire the generation's required sources transactionally. A source that + // cannot be installed means the service is not observable, so the new + // generation is stopped immediately instead of being published. + if let Err(error) = install_readiness_source(manager, &loop_handle, generation, pid) { + manager.fail( + &loop_handle, + generation, + &format!("cannot watch the X11 readiness socket: {error}"), + false, + ); + return; + } + if let Err(error) = manager.install_exit_watch(&loop_handle, generation, pid) { + manager.fail( + &loop_handle, + generation, + &format!("cannot watch the X11 service process: {error}"), + false, + ); + return; + } + + // Finite startup deadline. A timeout takes the same owned stop transition + // as any other failure, so the process is stopped rather than abandoned. + let deadline = startup_deadline(); + let timer = Timer::from_duration(deadline); + match loop_handle.insert_source(timer, move |_, _, state| { + let handle = state.loop_handle.clone(); + if let Some(manager) = state.xwayland.as_mut() + && manager.owns_generation(generation) + && manager.lifecycle.phase == Phase::Spawned(generation) + { + // The service executed but stayed silent: name the contract it broke. + let error = format!( + "the X11 service started but reported no readiness within {deadline:?}; \ + the packaged xwayland-satellite may lack the readiness notification feature" + ); + manager.fail(&handle, generation, &error, true); + } + TimeoutAction::Drop + }) { + Ok(token) => manager.sources.startup_deadline = Some(token), + Err(error) => { + let error = format!("cannot arm the X11 startup deadline: {error}"); + manager.fail(&loop_handle, generation, &error, false); + } + } +} + +fn install_readiness_source( + manager: &mut XwaylandManager, + loop_handle: &Loop, + generation: u64, + pid: u32, +) -> Result<(), String> { + let Some(socket) = manager.sources.readiness_socket.as_ref() else { + return Err("the readiness endpoint is missing".to_owned()); + }; + let clone = socket + .try_clone_owned() + .map_err(|error| format!("cannot duplicate the readiness endpoint: {error}"))?; + + let token = loop_handle + .insert_source(Generic::new(clone, Interest::READ, Mode::Level), move |_, _, state| { + let handle = state.loop_handle.clone(); + let Some(manager) = state.xwayland.as_mut() else { + return Ok(PostAction::Remove); + }; + if !manager.owns_generation(generation) || manager.lifecycle.phase != Phase::Spawned(generation) { + return Ok(PostAction::Remove); + } + + for _ in 0..READINESS_DRAIN_BUDGET { + let Some(socket) = manager.sources.readiness_socket.as_ref() else { + return Ok(PostAction::Remove); + }; + match socket.recv_ready(pid) { + Ok(true) => { + info!(generation, "X11 service reported readiness"); + if manager.lifecycle.ready(generation) { + manager.finish_ready(&handle); + + manager.enable_listeners(&handle); + } + return Ok(PostAction::Remove); + } + // Ignore stale, foreign, or malformed notifications. + Ok(false) => continue, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + // Stay level-triggered for any remaining work. + return Ok(PostAction::Continue); + } + Err(error) => { + warn!(%error, "cannot read the X11 readiness notification"); + return Ok(PostAction::Continue); + } + } + } + + Ok(PostAction::Continue) + }) + .map_err(|error| format!("cannot register the readiness source: {error}"))?; + manager.sources.readiness = Some(token); + + Ok(()) +} + +pub fn shutdown_after_loop(state: &mut Ferese) { + let Some(mut manager) = state.xwayland.take() else { + return; + }; + let display_name = manager.display.clone(); + info!(display = %display_name, "stopping the managed X11 service"); + let loop_handle = state.loop_handle.clone(); + + // Leave the event loop before the bounded sequence below, so it cannot race + // a dispatch. + manager.lifecycle.begin_shutdown_stop(); + manager.remove_listener_sources(&loop_handle); + manager.release_generation_sources(&loop_handle); + manager.remove_backoff_timer(&loop_handle); + + if let Some(mut process) = manager.process.take() + && let Err(error) = process.terminate(STOP_TIMEOUT) + { + warn!(%error, "the X11 service could not be stopped cleanly"); + manager.last_error = Some(error); + } + + manager.reservation.close_listeners(); + + state.session_environment.x11 = None; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn the_startup_deadline_is_finite_and_bounded() { + assert!(STARTUP_TIMEOUT > Duration::ZERO && STARTUP_TIMEOUT <= Duration::from_secs(30)); + } + + #[test] + fn phases_have_stable_names_for_ipc() { + assert_eq!(phase_name(Phase::Idle), "idle"); + assert_eq!(phase_name(Phase::Running(1)), "running"); + assert_eq!( + phase_name(Phase::Backoff { + generation: 1, + until: std::time::Instant::now() + }), + "backoff" + ); + assert_eq!(phase_name(Phase::Failed), "failed"); + assert_eq!( + phase_name(Phase::CleanupFailed { generation: 1 }), + "cleanup-failed", + "an unconfirmed cleanup is reported distinctly from a finished stop" + ); + + assert_eq!(phase_name(Phase::Starting(1)), "starting"); + assert_eq!(phase_name(Phase::Spawned(1)), "starting"); + } + + #[test] + fn a_confirmed_generation_reports_verified_readiness() { + if std::env::var_os("XDG_RUNTIME_DIR").is_none() { + return; + } + + let mut manager = prepare(&XwaylandConfig::default()).expect("reserve a managed X11 service"); + assert_eq!( + manager.status().readiness, + Readiness::NotAttempted, + "a fresh generation has not reported readiness yet" + ); + + manager.note_verified_readiness(); + + assert_eq!( + manager.status().readiness, + Readiness::Verified, + "confirmed readiness must be recorded, or status can never report it" + ); + } + + /// Two managers must never share a notification endpoint, even across + /// process lifetimes where a stale socket from an earlier instance is + /// still on disk. + #[test] + fn notification_endpoints_are_private_per_instance() { + let root = tempfile::tempdir().expect("a private runtime directory"); + // The production constructor, so the mode below is the shipped one and + // not a second implementation that only the test believes in. + let stale = notification_directory(root.path()).expect("the first instance directory"); + let stale_socket = ReadinessSocket::create(stale.path(), 1).expect("the first endpoint"); + + let next = notification_directory(root.path()).expect("the second instance directory"); + let next_socket = ReadinessSocket::create(next.path(), 1).expect("the second endpoint"); + + assert_ne!(stale.path(), next.path(), "each instance owns its own directory"); + assert_ne!( + stale_socket.path(), + next_socket.path(), + "the same generation number in a different instance must not collide" + ); + + // The directory is private: another user must not be able to place a + // socket where Ferese will look for notifications. + let metadata = std::fs::metadata(next.path()).expect("directory metadata"); + let mode = std::os::unix::fs::MetadataExt::mode(&metadata); + assert_eq!( + mode & 0o777, + 0o700, + "the notification directory must be private to the session, whatever the umask is" + ); + } + + #[test] + fn the_runtime_directory_must_be_private() { + assert!(validate_runtime_directory(Path::new("/tmp")).is_err()); + + assert!(validate_runtime_directory(Path::new("/dev/null")).is_err()); + assert!(validate_runtime_directory(Path::new("/nonexistent/ferese")).is_err()); + + let link = std::env::temp_dir().join("ferese-xwayland-runtime-link"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink("/tmp", &link).expect("create a symlink to a valid directory"); + assert!(validate_runtime_directory(&link).is_err()); + let _ = std::fs::remove_file(&link); + } +} diff --git a/compositor/src/xwayland/readiness.rs b/compositor/src/xwayland/readiness.rs new file mode 100644 index 0000000..410728f --- /dev/null +++ b/compositor/src/xwayland/readiness.rs @@ -0,0 +1,875 @@ +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixDatagram; +use std::path::{Path, PathBuf}; + +/// Bytes reserved for ancillary control data, well above `CMSG_LEN(ucred)`. +const CONTROL_CAPACITY: usize = 128; + +fn cmsg_len_ucred() -> usize { + cmsg_data_offset() + std::mem::size_of::() +} + +/// Byte offset at which `CMSG_DATA` starts: `cmsghdr` rounded up to +/// `size_t` alignment. +fn cmsg_data_offset() -> usize { + let header = std::mem::size_of::(); + let align = std::mem::size_of::(); + header.div_ceil(align) * align +} + +/// Room for the control data of one notification. +/// +/// `cmsghdr` is the first field so the buffer carries that type's alignment. +/// A `#[repr(C)]` union with a `ucred` is not enough: the kernel requires +/// `msg_control` to be aligned for `cmsghdr`, which is wider. +#[repr(C)] +struct ControlBuffer { + header: libc::cmsghdr, + payload: [u8; CONTROL_CAPACITY], +} + +// Requires Satellite's systemd notification feature; exec success is not readiness. +pub(crate) struct ReadinessSocket { + socket: UnixDatagram, + path: PathBuf, +} + +impl ReadinessSocket { + pub fn create(runtime_directory: &Path, generation: u64) -> io::Result { + let path = runtime_directory.join(format!("ferese-x11-notify-{generation}.sock")); + + let socket = UnixDatagram::bind(&path)?; + socket.set_nonblocking(true)?; + + let enable: libc::c_int = 1; + let length = libc::socklen_t::try_from(std::mem::size_of::()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "optlen overflow"))?; + // SAFETY: setsockopt on a live owned socket, with `enable` a live local + // int of exactly `length` bytes. + if unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PASSCRED, + std::ptr::from_ref(&enable).cast(), + length, + ) + } == -1 + { + return Err(io::Error::last_os_error()); + } + + Ok(Self { socket, path }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn recv_ready(&self, satellite_pid: u32) -> io::Result { + let mut buffer = [0_u8; 512]; + let mut control = ControlBuffer { + header: unsafe { std::mem::zeroed() }, + payload: [0_u8; CONTROL_CAPACITY], + }; + let mut iov = libc::iovec { + iov_base: buffer.as_mut_ptr().cast(), + iov_len: buffer.len(), + }; + let mut message: libc::msghdr = unsafe { std::mem::zeroed() }; + message.msg_iov = std::ptr::from_mut(&mut iov); + message.msg_iovlen = 1; + message.msg_control = std::ptr::from_mut(&mut control).cast(); + message.msg_controllen = std::mem::size_of::(); + + // SAFETY: every pointer refers to a live local buffer for the duration + // of the call, and `message` is fully initialized before use. + let received = unsafe { + libc::recvmsg( + self.socket.as_raw_fd(), + std::ptr::from_mut(&mut message), + libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC, + ) + }; + if received < 0 { + return Err(io::Error::last_os_error()); + } + + // Walk *every* ancillary message, not just the first: a sender is free + // to put credentials second, and descriptors attached to any message + // must not be leaked into this long-lived process. This happens before + // the truncation check below, because a truncated datagram can still + // have installed descriptors: the flag does not release them. + let mut from_satellite = false; + // SAFETY: the control buffer was sized, aligned, and described by this + // message, and CMSG_NXTHDR walks only what the kernel filled in. + unsafe { + let mut header = libc::CMSG_FIRSTHDR(&message); + while !header.is_null() { + let (cmsg, data) = (*header, libc::CMSG_DATA(header)); + + if cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_CREDENTIALS { + // Only an exactly sized ucred is a credential. A shorter or + // longer one is malformed, so it does not count. + if cmsg.cmsg_len == cmsg_len_ucred() { + // SAFETY: cmsg_len was checked to describe exactly one + // ucred, and CMSG_DATA points into our own buffer. + let credential = &*data.cast::(); + // Ignore the Xwayland child and any other process. + if credential.pid as u32 == satellite_pid { + from_satellite = true; + } + } + } else if cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_RIGHTS { + // Accepting a datagram must never adopt descriptors from an + // untrusted sender. Close them instead of dropping them on + // the floor, where they would stay open in this process. + let payload = cmsg.cmsg_len.saturating_sub(cmsg_data_offset()); + let count = payload / std::mem::size_of::(); + for index in 0..count { + // SAFETY: element `index` lies inside the ancillary data + // the kernel reported, and is read exactly once. + let raw = *data.cast::().add(index); + // SAFETY: `raw` is an open descriptor delivered by the + // kernel, so closing it releases the sender's copy. + libc::close(raw); + } + } + + header = libc::CMSG_NXTHDR(&message, header); + } + } + + // Decided only after the control walk: a completely filled payload + // buffer cannot be distinguished from truncation, and the kernel's + // truncation flags never satisfy the readiness contract. + if received as usize == buffer.len() || message.msg_flags & (libc::MSG_CTRUNC | libc::MSG_TRUNC) != 0 { + return Ok(false); + } + + if !from_satellite { + return Ok(false); + } + + Ok(matches!( + exact_line(&buffer[..received as usize], b"READY=1"), + Some(true) + )) + } + + pub fn try_clone_owned(&self) -> io::Result { + self.socket.try_clone() + } +} + +impl AsRawFd for ReadinessSocket { + fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.socket.as_raw_fd() + } +} + +fn exact_line(payload: &[u8], wanted: &[u8]) -> Option { + if payload.is_empty() || payload.contains(&0) { + return None; + } + let text = std::str::from_utf8(payload).ok()?; + let mut found = false; + for line in text.split('\n') { + if line.as_bytes() == wanted { + found = true; + } else if !line.is_empty() && !is_key_value(line) { + return None; + } + } + + Some(found) +} + +fn is_key_value(line: &str) -> bool { + line.split_once('=') + .is_some_and(|(key, _)| !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')) +} + +impl Drop for ReadinessSocket { + fn drop(&mut self) { + if let Err(error) = std::fs::remove_file(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(path = %self.path.display(), %error, "could not remove readiness socket"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Bounded wait, mirroring the production drain budget. + const READINESS_DRAIN_BUDGET_TESTS: usize = 200; + + /// Open a datagram socket already connected to `path`. + /// + /// Connecting first means the senders can use plain `sendmsg`, whose + /// glibc export carries no destination address. + /// + /// # Safety + /// Async-signal-safe, for use between `fork` and `_exit`. + unsafe fn connect_datagram(path: &Path) -> libc::c_int { + unsafe { + let fd = libc::socket(libc::AF_UNIX, libc::SOCK_DGRAM, 0); + if fd < 0 { + return -1; + } + let mut address: libc::sockaddr_un = std::mem::zeroed(); + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + let bytes = path.as_os_str().as_encoded_bytes(); + if bytes.len() >= address.sun_path.len() { + libc::close(fd); + return -1; + } + for (slot, byte) in address.sun_path.iter_mut().zip(bytes) { + *slot = *byte as libc::c_char; + } + if libc::connect( + fd, + std::ptr::from_ref(&address).cast(), + // The fixed `sockaddr_un` length is what the kernel expects for + // AF_UNIX; `sun_path` is already zero-filled past the name. + libc::socklen_t::try_from(std::mem::size_of::()).unwrap(), + ) == -1 + { + libc::close(fd); + return -1; + } + fd + } + } + + fn private_runtime_dir(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ferese-readiness-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap(); + root + } + + fn send_from_child(path: &Path, payload: &[u8]) -> i32 { + // SAFETY: the child path performs only socket/bind-free sendto and + // _exit, which are async-signal-safe. + unsafe { + let pid = libc::fork(); + assert!(pid >= 0, "fork must succeed"); + if pid == 0 { + let Ok(client) = UnixDatagram::unbound() else { + libc::_exit(70); + }; + let sent = client.send_to(payload, path); + libc::_exit(if sent.is_ok() { 0 } else { 71 }); + } + let mut status = 0; + libc::waitpid(pid, std::ptr::from_mut(&mut status), 0); + status + } + } + + /// 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 + /// descriptor must be released by the receiver rather than leaked. + fn send_with_descriptor_from_child(path: &Path, sentinel: &Path) -> i32 { + // SAFETY: the child performs only socket/open/sendmsg/_exit, all + // async-signal-safe. + 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); + } + // A descriptor for a known file, so the receiver can prove it + // closed exactly this one rather than guessing at a count. + let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else { + libc::_exit(73); + }; + let spare = libc::open(name.as_ptr(), libc::O_RDONLY); + if spare < 0 { + libc::_exit(72); + } + let mut iov = libc::iovec { + iov_base: b"READY=1".as_ptr().cast_mut().cast(), + iov_len: 7, + }; + // One SCM_RIGHTS message, in an aligned buffer. + let mut control = ControlBuffer { + header: std::mem::zeroed(), + payload: [0_u8; CONTROL_CAPACITY], + }; + // `payload` starts where `CMSG_DATA` starts, so the descriptor + // belongs at the beginning of it. Writing it further along would + // silently send whatever zeros sit in between. + let descriptor = spare; + std::ptr::copy_nonoverlapping( + std::ptr::from_ref(&descriptor).cast::(), + control.payload.as_mut_ptr(), + std::mem::size_of::(), + ); + let total = cmsg_data_offset() + std::mem::size_of::(); + control.header.cmsg_len = total as _; + control.header.cmsg_level = libc::SOL_SOCKET; + control.header.cmsg_type = libc::SCM_RIGHTS; + + let mut message: libc::msghdr = std::mem::zeroed(); + message.msg_iov = std::ptr::from_mut(&mut iov); + message.msg_iovlen = 1; + message.msg_control = std::ptr::from_mut(&mut control).cast(); + message.msg_controllen = total; + let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL); + if sent < 0 { + libc::_exit(71); + } + // Stay alive so the receiver can accept the credential while + // this process still exists. + libc::sleep(1); + libc::_exit(0); + } + pid + } + } + + /// A truncation flag must be exercised for real, not simulated by a shorter payload. + #[test] + 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. + assert!( + !recv_within(&socket, u32::MAX), + "MSG_TRUNC must never satisfy readiness" + ); + } + + /// Descriptors attached to a notification must be released, not adopted. + #[test] + fn attached_descriptors_are_closed_rather_than_leaked() { + let runtime = private_runtime_dir("fds"); + let socket = ReadinessSocket::create(&runtime, 22).unwrap(); + let path = socket.path().to_owned(); + + // Counted by identity, not by process-wide total, so a parallel test + // cannot make this flaky. + let sentinel = sentinel_file("fds"); + let pid = send_with_descriptor_from_child(&path, &sentinel); + assert!( + recv_within(&socket, pid as u32), + "a READY=1 from the named PID is accepted even with an attached fd" + ); + + assert!( + !holds_open(&sentinel), + "the descriptor delivered with the notification must be closed, \ + not left open in the compositor" + ); + let _ = std::fs::remove_file(&sentinel); + + // SAFETY: reaping a direct child that has finished sleeping. + let mut status = 0; + unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) }; + } + + #[test] + fn credentials_after_another_control_message_are_still_read() { + let runtime = private_runtime_dir("cmsg-order"); + let socket = ReadinessSocket::create(&runtime, 23).unwrap(); + let path = socket.path().to_owned(); + + let sentinel = sentinel_file("cmsg-order"); + let pid = send_with_descriptor_from_child(&path, &sentinel); + assert!( + recv_within(&socket, pid as u32), + "a credential that is not the first control message must still count" + ); + + // SAFETY: reaping a direct child that has finished sleeping. + let mut status = 0; + unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) }; + } + + /// Send `READY=1` with more descriptors than the receiver's control buffer can + /// hold, so the kernel truncates the ancillary data while installing what fits. + fn send_descriptors_beyond_capacity_from_child(path: &Path, sentinel: &Path, count: usize) -> i32 { + // SAFETY: the child performs only socket/open/sendmsg/sleep/_exit, all + // async-signal-safe except sleep, which is a bounded wait before exit. + 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 Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else { + libc::_exit(73); + }; + let sentinel_fd = libc::open(name.as_ptr(), libc::O_RDONLY); + if sentinel_fd < 0 { + libc::_exit(72); + } + let filler = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY); + if filler < 0 { + libc::_exit(72); + } + + let mut iov = libc::iovec { + iov_base: b"READY=1".as_ptr().cast_mut().cast(), + iov_len: 7, + }; + // A control buffer large enough to *send* the flood; the + // receiver's much smaller buffer is what truncates it. + let mut control = [0_u8; 1024]; + let mut message: libc::msghdr = std::mem::zeroed(); + message.msg_iov = std::ptr::from_mut(&mut iov); + message.msg_iovlen = 1; + message.msg_control = control.as_mut_ptr().cast(); + message.msg_controllen = control.len() as _; + let header = libc::CMSG_FIRSTHDR(&message); + if header.is_null() { + libc::_exit(74); + } + (*header).cmsg_level = libc::SOL_SOCKET; + (*header).cmsg_type = libc::SCM_RIGHTS; + let bytes = (count * std::mem::size_of::()) as u32; + (*header).cmsg_len = libc::CMSG_LEN(bytes) as _; + // Exactly the aligned size of one control message: trailing + // zero bytes would be read as a second, malformed message and + // the kernel would reject the send with EINVAL. + message.msg_controllen = libc::CMSG_SPACE(bytes) as _; + let data = libc::CMSG_DATA(header); + for index in 0..count { + // The sentinel rides along first, so it is one of the + // descriptors the receiver's short buffer actually installs. + let descriptor = if index == 0 { sentinel_fd } else { filler }; + *data.cast::().add(index) = descriptor; + } + let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL); + if sent < 0 { + libc::_exit(71); + } + libc::sleep(1); + libc::_exit(0); + } + pid + } + } + + /// Send `count` malformed datagrams, each carrying a descriptor. + fn send_malformed_storm_from_child(path: &Path, sentinel: &Path, count: usize) -> i32 { + // SAFETY: the child performs only socket/open/sendmsg/_exit, all + // async-signal-safe. + 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 Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else { + libc::_exit(73); + }; + let sentinel_fd = libc::open(name.as_ptr(), libc::O_RDONLY); + if sentinel_fd < 0 { + libc::_exit(72); + } + for _ in 0..count { + let mut iov = libc::iovec { + iov_base: b"READY=2".as_ptr().cast_mut().cast(), + iov_len: 7, + }; + let mut control = ControlBuffer { + header: std::mem::zeroed(), + payload: [0_u8; CONTROL_CAPACITY], + }; + let descriptor = sentinel_fd; + std::ptr::copy_nonoverlapping( + std::ptr::from_ref(&descriptor).cast::(), + control.payload.as_mut_ptr(), + std::mem::size_of::(), + ); + control.header.cmsg_len = (cmsg_data_offset() + std::mem::size_of::()) as _; + control.header.cmsg_level = libc::SOL_SOCKET; + control.header.cmsg_type = libc::SCM_RIGHTS; + + let mut message: libc::msghdr = std::mem::zeroed(); + message.msg_iov = std::ptr::from_mut(&mut iov); + message.msg_iovlen = 1; + message.msg_control = std::ptr::from_mut(&mut control).cast(); + message.msg_controllen = cmsg_data_offset() + std::mem::size_of::(); + if libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL) < 0 { + libc::_exit(71); + } + } + libc::_exit(0); + } + pid + } + } + + /// Open descriptors in this process; only *deltas* between two calls are + /// meaningful, because parallel tests add and remove descriptors too. + fn open_fd_count() -> usize { + std::fs::read_dir("/proc/self/fd") + .map(|entries| entries.count()) + .unwrap_or(0) + } + + /// A truncated ancillary buffer must be rejected and its delivered descriptors closed. + #[test] + fn truncated_control_messages_still_close_the_descriptors_they_delivered() { + let runtime = private_runtime_dir("ctrunc"); + let socket = ReadinessSocket::create(&runtime, 24).unwrap(); + let path = socket.path().to_owned(); + + let sentinel = sentinel_file("ctrunc"); + let before = open_fd_count(); + // Far more descriptors than the receiver's 128-byte payload can hold. + let pid = send_descriptors_beyond_capacity_from_child(&path, &sentinel, 64); + // SAFETY: reaping a direct child that has finished sleeping. + let mut status = 0; + unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) }; + assert!( + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + "child must deliver the truncated notification: {status:#x}" + ); + + let verdict = recv_within(&socket, pid as u32); + assert!(!verdict, "MSG_CTRUNC must never satisfy readiness"); + assert!( + !holds_open(&sentinel), + "descriptors installed before MSG_CTRUNC was noticed must be closed" + ); + let after = open_fd_count(); + assert!( + after <= before + 4, + "truncation leaked descriptors: {before} open before, {after} after" + ); + let _ = std::fs::remove_file(&sentinel); + } + + /// A flood of malformed datagrams must leak nothing and leave the socket usable. + #[test] + fn a_storm_of_malformed_datagrams_leaks_nothing_and_leaves_the_socket_usable() { + let runtime = private_runtime_dir("storm"); + let socket = ReadinessSocket::create(&runtime, 25).unwrap(); + let path = socket.path().to_owned(); + + let sentinel = sentinel_file("storm"); + let before = open_fd_count(); + let pid = send_malformed_storm_from_child(&path, &sentinel, 128); + + // Drain while the sender may still be blocked in `sendmsg`: the socket + // queue holds far fewer datagrams than the storm, so waiting for the + // child to exit before reading would deadlock the pair. One deadline + // covers both the send and the receive. + let started = std::time::Instant::now(); + let deadline = started + std::time::Duration::from_secs(10); + let mut judged = 0; + let mut status = 0; + while judged < 128 { + loop { + match socket.recv_ready(u32::MAX) { + Ok(_) => judged += 1, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("unexpected readiness read error: {error}"), + } + } + if judged >= 128 || std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + if judged < 128 { + // The sender never finished, so it is still blocked. Kill it and + // reap it here rather than leave it behind for another test. + // SAFETY: signalling and reaping a direct child of this process. + unsafe { + libc::kill(pid, libc::SIGKILL); + libc::waitpid(pid, std::ptr::from_mut(&mut status), 0); + } + panic!("the storm was not delivered within the shared deadline: {judged} of 128 judged"); + } + + // Every datagram was accepted, so the sender has reached its exit. + // SAFETY: reaping a direct child that has finished sending. + unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) }; + assert!( + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + "child must deliver the storm: {status:#x}" + ); + assert!(judged >= 128, "the storm must be delivered and judged: {judged}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(2), + "judging the storm must not stall the consumer: {:?}", + started.elapsed() + ); + assert!( + !holds_open(&sentinel), + "every descriptor carried by the storm must be closed" + ); + let after = open_fd_count(); + assert!( + after <= before + 4, + "the storm leaked descriptors: {before} open before, {after} after" + ); + let _ = std::fs::remove_file(&sentinel); + + let ready_sentinel = sentinel_file("storm-ready"); + let ready_pid = send_with_descriptor_from_child(&path, &ready_sentinel); + assert!( + recv_within(&socket, ready_pid as u32), + "a READY=1 after the storm is still judged by credential, and the \ + storm sender's exit must not have wedged the socket" + ); + assert!( + !holds_open(&ready_sentinel), + "cleanup must keep working after the storm" + ); + let _ = std::fs::remove_file(&ready_sentinel); + + // SAFETY: reaping a direct child that has finished sleeping. + let mut ready_status = 0; + unsafe { libc::waitpid(ready_pid, std::ptr::from_mut(&mut ready_status), 0) }; + } + + /// Drain the socket until one datagram is judged, bounded like production. + fn recv_within(socket: &ReadinessSocket, pid: u32) -> bool { + for _ in 0..READINESS_DRAIN_BUDGET_TESTS { + match socket.recv_ready(pid) { + Ok(verdict) => return verdict, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => panic!("unexpected readiness read error: {error}"), + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("no readiness datagram arrived"); + } + + /// A unique empty file used to identify a delivered descriptor. + fn sentinel_file(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("ferese-readiness-sentinel-{name}-{}", std::process::id())); + let _ = std::fs::remove_file(&path); + std::fs::write(&path, b"").unwrap(); + path + } + + /// Whether this process currently holds any descriptor on `path`. + fn holds_open(path: &Path) -> bool { + let Ok(entries) = std::fs::read_dir("/proc/self/fd") else { + return false; + }; + entries + .filter_map(Result::ok) + .any(|entry| std::fs::read_link(entry.path()).is_ok_and(|target| target == path)) + } + + #[test] + fn creates_a_private_socket_that_is_removed_on_drop() { + let runtime = private_runtime_dir("create"); + let path = { + let socket = ReadinessSocket::create(&runtime, 7).unwrap(); + let path = socket.path().to_owned(); + assert!(path.exists()); + assert!(path.starts_with(&runtime)); + assert!(path.to_string_lossy().contains('7'), "path must encode the generation"); + path + }; + + assert!(!path.exists(), "readiness socket must be removed on drop"); + } + + #[test] + fn each_generation_gets_a_distinct_socket() { + let runtime = private_runtime_dir("generations"); + let first = ReadinessSocket::create(&runtime, 1).unwrap(); + let second = ReadinessSocket::create(&runtime, 2).unwrap(); + + assert_ne!(first.path(), second.path()); + } + + #[test] + fn line_matching_requires_an_exact_ready_line() { + assert_eq!(exact_line(b"READY=1", b"READY=1"), Some(true)); + assert_eq!(exact_line(b"READY=1\n", b"READY=1"), Some(true)); + assert_eq!(exact_line(b"MAINPID=42\nREADY=1", b"READY=1"), Some(true)); + + assert_eq!(exact_line(b"READY=0", b"READY=1"), Some(false)); + assert_eq!(exact_line(b"READY=1 ", b"READY=1"), Some(false)); + assert_eq!(exact_line(b"XREADY=1", b"READY=1"), Some(false)); + assert_eq!(exact_line(b"READY=11", b"READY=1"), Some(false)); + assert_eq!(exact_line(b"READY=1=2", b"READY=1"), Some(false)); + assert_eq!(exact_line(b"", b"READY=1"), None); + assert_eq!(exact_line(b"READY=1\x00", b"READY=1"), None); + assert_eq!(exact_line(b"\xff\xfe\x00\x01", b"READY=1"), None); + } + + #[test] + fn a_ready_datagram_from_another_process_is_rejected() { + let runtime = private_runtime_dir("foreign"); + let socket = ReadinessSocket::create(&runtime, 3).unwrap(); + let path = socket.path().to_owned(); + + let status = send_from_child(&path, b"READY=1"); + assert!( + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + "child must send" + ); + + let mut rejected = false; + for _ in 0..1000 { + match socket.recv_ready(u32::MAX) { + Ok(false) => { + rejected = true; + break; + } + Ok(true) => panic!("a foreign READY=1 must never satisfy readiness"), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => panic!("unexpected readiness read error: {error}"), + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(rejected, "a READY=1 from another process must be rejected"); + } + + #[test] + fn a_ready_datagram_from_the_expected_pid_is_accepted() { + let runtime = private_runtime_dir("expected"); + let socket = ReadinessSocket::create(&runtime, 4).unwrap(); + let path = socket.path().to_owned(); + + let pid = { + // SAFETY: mirror of send_from_child, returning the child PID. + unsafe { + let pid = libc::fork(); + assert!(pid >= 0); + if pid == 0 { + for _ in 0..500 { + if let Ok(client) = UnixDatagram::unbound() + && client.send_to(b"READY=1", &path).is_ok() + { + libc::_exit(0); + } + libc::usleep(2_000); + } + libc::_exit(72); + } + pid + } + }; + + let mut accepted = false; + for _ in 0..1000 { + match socket.recv_ready(pid as u32) { + Ok(true) => { + accepted = true; + break; + } + Ok(false) => {} + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => panic!("unexpected readiness read error: {error}"), + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + + // SAFETY: reap the child we just spawned. + unsafe { + let mut status = 0; + libc::waitpid(pid, std::ptr::from_mut(&mut status), 0); + } + + assert!(accepted, "an exact READY=1 from the expected PID must be accepted"); + } + + #[test] + fn a_non_ready_datagram_from_the_expected_pid_is_rejected() { + let runtime = private_runtime_dir("not-ready"); + let socket = ReadinessSocket::create(&runtime, 5).unwrap(); + let path = socket.path().to_owned(); + + let pid = unsafe { + let pid = libc::fork(); + assert!(pid >= 0); + if pid == 0 { + for _ in 0..500 { + if let Ok(client) = UnixDatagram::unbound() + && client.send_to(b"RELOADING=1\nSTATUS=starting", &path).is_ok() + { + libc::_exit(0); + } + libc::usleep(2_000); + } + libc::_exit(73); + } + pid + }; + + let mut rejected = false; + for _ in 0..1000 { + match socket.recv_ready(pid as u32) { + Ok(false) => { + rejected = true; + break; + } + Ok(true) => panic!("RELOADING=1 must not satisfy readiness"), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => panic!("unexpected readiness read error: {error}"), + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + + unsafe { + let mut status = 0; + libc::waitpid(pid, std::ptr::from_mut(&mut status), 0); + } + + assert!(rejected, "RELOADING=1 must not satisfy readiness"); + } +} diff --git a/compositor/src/xwayland/sockets.rs b/compositor/src/xwayland/sockets.rs new file mode 100644 index 0000000..c0f62a4 --- /dev/null +++ b/compositor/src/xwayland/sockets.rs @@ -0,0 +1,371 @@ +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::ops::Range; +use std::os::linux::net::SocketAddrExt; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::os::unix::net::{SocketAddr, UnixListener}; +use std::path::{Path, PathBuf}; + +use tracing::warn; + +// Identity checks prevent accidental unlinking, not races with hostile same-UID code. +#[derive(Debug)] +struct CreatedPath { + path: PathBuf, + device: u64, + inode: u64, +} + +impl CreatedPath { + fn record(path: PathBuf, metadata: &fs::Metadata) -> Self { + Self { + path, + device: metadata.dev(), + inode: metadata.ino(), + } + } + + fn remove(&mut self) { + match fs::symlink_metadata(&self.path) { + Ok(metadata) if metadata.dev() == self.device && metadata.ino() == self.inode => { + if let Err(error) = fs::remove_file(&self.path) { + warn!( + path = %self.path.display(), + %error, + "could not remove X11 path this process created" + ); + } + } + Ok(_) => warn!( + path = %self.path.display(), + "refusing to remove an X11 path that no longer matches the one this process created" + ), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => warn!( + path = %self.path.display(), + %error, + "could not inspect X11 path during cleanup" + ), + } + } +} + +impl Drop for CreatedPath { + fn drop(&mut self) { + self.remove(); + } +} + +fn validate_shared_directory(path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + let uid = unsafe { libc::geteuid() }; + if !metadata.is_dir() || metadata.file_type().is_symlink() || (metadata.uid() != 0 && metadata.uid() != uid) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("untrusted X11 directory: {}", path.display()), + )); + } + + if metadata.mode() & 0o022 != 0 && metadata.mode() & 0o1000 == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("shared X11 directory is not sticky: {}", path.display()), + )); + } + + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct Reservation { + display: u32, + listeners: Vec, + socket_path: Option, + lock_path: Option, + socket_directory: PathBuf, +} + +impl Reservation { + // Leave pre-existing locks and socket paths untouched. + pub fn allocate() -> io::Result { + #[cfg(test)] + { + // Owned inputs, so an ordinary test never competes for a real slot in + // `/tmp/.X11-unix` nor depends on that directory existing. + let (sockets, locks) = crate::xwayland::test_hooks::allocator_root_for_reservation().unwrap_or_else(|| { + let root = crate::xwayland::test_hooks::default_allocator_root(); + (root.clone(), root) + }); + Self::allocate_in(&sockets, &locks, 0..64) + } + + #[cfg(not(test))] + Self::allocate_in(Path::new("/tmp/.X11-unix"), Path::new("/tmp"), 0..64) + } + + fn allocate_in(socket_directory: &Path, lock_directory: &Path, candidates: Range) -> io::Result { + validate_shared_directory(socket_directory)?; + validate_shared_directory(lock_directory)?; + + for display in candidates { + let path = lock_directory.join(format!(".X{display}-lock")); + let mut file = match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&path) + { + Ok(file) => file, + + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + + let lock_path = CreatedPath::record(path, &file.metadata()?); + if let Err(error) = writeln!(file, "{:>10}", std::process::id()).and_then(|()| file.flush()) { + drop(file); + drop(lock_path); + return Err(error); + } + drop(file); + + let mut reservation = Self { + display, + listeners: Vec::new(), + socket_path: None, + lock_path: Some(lock_path), + socket_directory: socket_directory.to_owned(), + }; + + match reservation.bind_same_display() { + Ok(()) => return Ok(reservation), + Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + drop(reservation); + } + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AddrInUse, + "no unused X11 display in the configured internal candidate range", + )) + } + + pub fn display_number(&self) -> u32 { + self.display + } + + pub fn display_name(&self) -> String { + format!(":{}", self.display) + } + + pub fn listeners(&self) -> &[UnixListener] { + &self.listeners + } + + // Remove source clones and stop the server before closing the reservation. + pub fn close_listeners(&mut self) { + self.listeners.clear(); + self.socket_path = None; + } + + pub fn bind_same_display(&mut self) -> io::Result<()> { + #[cfg(test)] + if crate::xwayland::test_hooks::fire(&crate::xwayland::test_hooks::FAIL_REBIND_AFTER) { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "injected X11 display rebind failure", + )); + } + + if !self.listeners.is_empty() || self.socket_path.is_some() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "X11 listeners are already bound", + )); + } + + validate_shared_directory(&self.socket_directory)?; + let path = self.socket_directory.join(format!("X{}", self.display)); + let filesystem = UnixListener::bind(&path)?; + let owned_path = CreatedPath::record(path.clone(), &fs::symlink_metadata(&path)?); + let abstract_address = SocketAddr::from_abstract_name(path.as_os_str().as_bytes())?; + let abstract_socket = match UnixListener::bind_addr(&abstract_address) { + Ok(listener) => listener, + Err(error) => { + // Drop the recorded path first so cleanup happens in order, once. + drop(owned_path); + drop(filesystem); + return Err(error); + } + }; + + self.listeners = vec![filesystem, abstract_socket]; + self.socket_path = Some(owned_path); + + Ok(()) + } + + fn release_display(&mut self) { + self.close_listeners(); + self.lock_path = None; + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + self.release_display(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn shared_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ferese-xwayland-alloc-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + for directory in ["sockets", "locks"] { + fs::create_dir(root.join(directory)).unwrap(); + fs::set_permissions(root.join(directory), fs::Permissions::from_mode(0o1777)).unwrap(); + } + root + } + + #[test] + fn allocates_a_display_with_two_listener_transports() { + let root = shared_root("two-transports"); + let reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap(); + + assert!(reservation.listeners().len() >= 2); + let socket = root.join("sockets").join(format!("X{}", reservation.display_number())); + assert!(socket.exists(), "conventional socket path must exist"); + assert!( + root.join("locks") + .join(format!(".X{}-lock", reservation.display_number())) + .exists() + ); + } + + #[test] + fn skips_an_occupied_display_without_reclaiming_it() { + let root = shared_root("occupied"); + let sockets = root.join("sockets"); + let locks = root.join("locks"); + let first = Reservation::allocate_in(&sockets, &locks, 0..64).unwrap(); + let taken = first.display_number(); + + let second = Reservation::allocate_in(&sockets, &locks, 0..64).unwrap(); + assert_ne!(second.display_number(), taken); + assert!(second.display_number() > taken); + + assert!(locks.join(format!(".X{taken}-lock")).exists()); + assert!(sockets.join(format!("X{taken}")).exists()); + } + + #[test] + fn an_existing_lock_file_is_left_untouched() { + let root = shared_root("stale-lock"); + let locks = root.join("locks"); + let stale = locks.join(".X3-lock"); + fs::write(&stale, b"9999\n").unwrap(); + + let reservation = Reservation::allocate_in(&root.join("sockets"), &locks, 0..64).unwrap(); + assert_ne!(reservation.display_number(), 3, "occupied number must be skipped"); + assert_eq!(fs::read(&stale).unwrap(), b"9999\n", "stale lock must not be reclaimed"); + } + + #[test] + fn cleanup_removes_paths_this_process_created() { + let root = shared_root("cleanup"); + let reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap(); + let display = reservation.display_number(); + let socket = root.join("sockets").join(format!("X{display}")); + let lock = root.join("locks").join(format!(".X{display}-lock")); + + drop(reservation); + + assert!(!socket.exists(), "owned socket path must be removed"); + assert!(!lock.exists(), "owned lock path must be removed"); + } + + #[test] + fn close_listeners_keeps_the_display_lock_owned() { + let root = shared_root("close-listeners"); + let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap(); + let display = reservation.display_number(); + + reservation.close_listeners(); + + assert!(reservation.listeners().is_empty()); + assert!(root.join("locks").join(format!(".X{display}-lock")).exists()); + assert!(!root.join("sockets").join(format!("X{display}")).exists()); + } + + #[test] + fn retry_rebinds_the_same_display_number() { + let root = shared_root("retry"); + let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap(); + let display = reservation.display_number(); + reservation.close_listeners(); + + reservation.bind_same_display().unwrap(); + + assert_eq!(reservation.display_number(), display); + assert!(!reservation.listeners().is_empty()); + assert!(root.join("sockets").join(format!("X{display}")).exists()); + } + + #[test] + fn binding_twice_is_rejected() { + let root = shared_root("double-bind"); + let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap(); + reservation.close_listeners(); + reservation.bind_same_display().unwrap(); + + let error = reservation.bind_same_display().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + } + + #[test] + fn an_untrusted_socket_directory_is_rejected() { + let root = std::env::temp_dir().join(format!("ferese-xwayland-untrusted-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + + fs::set_permissions(&root, fs::Permissions::from_mode(0o777)).unwrap(); + + let locks = root.join("locks"); + fs::create_dir(&locks).unwrap(); + fs::set_permissions(&locks, fs::Permissions::from_mode(0o1777)).unwrap(); + + let error = Reservation::allocate_in(&root, &locks, 0..64).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(error.to_string().contains("not sticky")); + } + + #[test] + fn a_missing_socket_directory_is_an_actionable_error() { + let root = shared_root("missing-dir"); + let error = Reservation::allocate_in(&root.join("absent"), &root.join("locks"), 0..64).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn exhausting_the_candidate_range_reports_no_free_display() { + let root = shared_root("exhausted"); + let locks = root.join("locks"); + for display in 10..13 { + fs::write(locks.join(format!(".X{display}-lock")), b"1\n").unwrap(); + } + + let error = Reservation::allocate_in(&root.join("sockets"), &locks, 10..13).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + assert!(error.to_string().contains("no unused X11 display")); + } +} diff --git a/crates/ferese-ipc/src/lib.rs b/crates/ferese-ipc/src/lib.rs index 78fe0f3..0fa040e 100644 --- a/crates/ferese-ipc/src/lib.rs +++ b/crates/ferese-ipc/src/lib.rs @@ -7,6 +7,7 @@ use serde_json::Value; pub mod media; pub mod theme; +pub mod xwayland; pub const VERSION: u32 = 1; pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024; diff --git a/crates/ferese-ipc/src/xwayland.rs b/crates/ferese-ipc/src/xwayland.rs new file mode 100644 index 0000000..d6d9c53 --- /dev/null +++ b/crates/ferese-ipc/src/xwayland.rs @@ -0,0 +1,139 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const STATUS_COMMAND: &str = "xwayland-status"; +pub const RETRY_COMMAND: &str = "xwayland-retry"; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum State { + Disabled, + Idle, + Starting, + Running, + Backoff, + Failed, + Stopped, +} + +impl State { + pub fn is_inactive(self) -> bool { + matches!(self, Self::Disabled | Self::Idle | Self::Stopped) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Readiness { + Pending, + Verified, + Rejected, + NotAttempted, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Status { + pub enabled: bool, + pub effective_startup: String, + pub state: State, + pub display: Option, + /// PID of Satellite, not its Xwayland child. + pub satellite_pid: Option, + pub generation: Option, + pub readiness: Readiness, + pub recent_failures: u32, + pub restart_required: bool, + pub last_error: Option, +} + +impl Status { + pub fn from_value(value: Value) -> serde_json::Result { + serde_json::from_value(value) + } + + pub fn to_value(&self) -> Value { + match serde_json::to_value(self) { + Ok(value) => value, + Err(error) => serde_json::json!({ "error": error.to_string() }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn states_use_stable_snake_case_names() { + for (state, name) in [ + (State::Disabled, "disabled"), + (State::Idle, "idle"), + (State::Starting, "starting"), + (State::Running, "running"), + (State::Backoff, "backoff"), + (State::Failed, "failed"), + (State::Stopped, "stopped"), + ] { + assert_eq!(serde_json::to_value(state).unwrap(), json!(name)); + } + } + + #[test] + fn readiness_values_round_trip() { + for (readiness, name) in [ + (Readiness::Pending, "pending"), + (Readiness::Verified, "verified"), + (Readiness::Rejected, "rejected"), + (Readiness::NotAttempted, "not_attempted"), + ] { + let encoded = serde_json::to_value(readiness).unwrap(); + assert_eq!(encoded, json!(name)); + assert_eq!(serde_json::from_value::(encoded).unwrap(), readiness); + } + } + + #[test] + fn a_status_snapshot_never_carries_credentials() { + let status = Status { + enabled: true, + effective_startup: "on-demand".to_owned(), + state: State::Running, + display: Some(":7".to_owned()), + satellite_pid: Some(24017), + generation: Some(2), + readiness: Readiness::Verified, + recent_failures: 1, + restart_required: false, + last_error: None, + }; + let value = status.to_value(); + let text = value.to_string(); + assert!(!text.contains("MIT-MAGIC-COOKIE-1")); + assert_eq!(value["satellite_pid"], json!(24017)); + assert_eq!(value["display"], json!(":7")); + assert_eq!(value["state"], json!("running")); + assert_eq!(Status::from_value(value).unwrap(), status); + } + + #[test] + fn an_unknown_field_from_a_newer_compositor_is_ignored() { + let decoded = Status::from_value(json!({ + "enabled": true, + "effective_startup": "eager", + "state": "running", + "display": ":7", + "satellite_pid": 1, + "generation": 1, + "readiness": "verified", + "recent_failures": 0, + "restart_required": false, + "last_error": null, + "future_field": {"anything": true}, + })) + .expect("a newer field must not break decoding"); + + assert_eq!(decoded.state, State::Running); + assert_eq!(decoded.effective_startup, "eager"); + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 49c2354..40747aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,7 +109,7 @@ columns align with the viewport. Wider columns reveal their left edge when approached from the left and their right edge when approached from the right; an existing view inside a wider column stays in place. `center_on_focus` centers the focused column. `paged` packs columns into viewport-sized pages: halves form pairs, thirds -form triples; mixed widths and client minimum sizes determine actual boundaries. +form triples; mixed widths and effective minimum sizes determine actual boundaries. Changing default width preserves manually resized columns. ## Animations @@ -574,10 +574,16 @@ window rules can override that behavior. | `workspace` | integer > 0 | Leave placement unchanged | | `floating` | boolean | Leave placement unchanged | | `width`, `height` | numbers > 0 | Application-chosen floating size | +| `min-width`, `min-height` | numbers > 0 | Replaces the client's advertised minimum size for that axis | | `fullscreen` | boolean | Leave fullscreen state unchanged | | `block-out-from-screencasts` | boolean | Exclude from captures; enabled by default for Ferese authentication dialogs | | `idle-inhibit` | `none`, `visible`, `fullscreen`, `playing`, `fullscreen-playing` | Automatic fullscreen playback; see [Idle inhibition](#idle-inhibition) | +`window-rule app-id="spotify" min-width=500 min-height=400` replaces what the +app itself reports as its smallest size, so a configured column width wins over +an app that refuses to shrink (`min-width=1` removes the floor). Each axis is +independent; an omitted axis keeps the client's minimum. + `window-rule app-id="org.example.Private" block-out-from-screencasts=#true` keeps matching windows visible on the display but omits them (including their popups, subsurfaces, shadows and overview previews) from monitor and region @@ -603,7 +609,7 @@ part of their parent, then tries saved geometry, then the position with the leas summed overlap. Equal-overlap candidates favor the focused window's center. If the size cannot fit, a per-output cascade advances by 32 logical pixels and wraps to the work-area -origin. Placement respects layer-shell exclusive zones and client minimum sizes; +origin. Placement respects layer-shell exclusive zones and effective minimum sizes; an oversized window keeps its size with its top-left corner reachable. Successful move/resize completion saves ordinary floating geometry by app ID in @@ -949,6 +955,96 @@ Settings → Windows. `theme.geometry.shell-radius` takes precedence over the ol none are set, the shell uses 14 px. Legacy clock/note radius fields no longer override shell rounding. +## X11 support (xwayland-satellite) + +Ferese can run legacy X11-only applications through +[xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite). +```kdl +xwayland { + enabled #true + startup on-demand + path "xwayland-satellite" +} +``` + +| Key | Values | Default | Meaning | +| --- | --- | --- | --- | +| `enabled` | `true` / `false` | `true` | Run the bridge at all. `false` is a complete opt-out. | +| `startup` | `on-demand` / `eager` | `on-demand` | `on-demand` starts Satellite the first time an application connects to the X11 socket. `eager` starts it during session startup. | +| `path` | path to an executable | `xwayland-satellite` | Which binary to run. | + +These keys require a new session. Reloading configuration does not restart or +reallocate the X11 service; `feresectl xwayland status` reports +`restart_required` when the running configuration no longer matches the file. + +### Requirements and failure behavior + +`xwayland-satellite` and an `Xwayland` binary it can find must be installed. +Satellite must be built with its `systemd` feature enabled +(`cargo build --release --features systemd`); that feature sends the `READY=1` +notification Ferese waits for. Upstream's default feature set is empty, so a +default build never sends it and every start ends in a startup failure after +Ferese's fixed 10-second budget, even though the process is running. Being +spawned is not readiness. If any of these are missing, the session still starts +as a normal Wayland desktop and `feresectl xwayland status` explains why X11 is +unavailable. Ferese never publishes a `DISPLAY` value that does not work, so +applications fail cleanly instead of hanging against an endpoint that accepts +nothing. + +In a nested session, Ferese's own environment is preserved for backend +initialization, but applications it launches receive the new Ferese endpoints. +The host's `DISPLAY` is never exported to the activation environment of a nested +session, so it cannot leak into services started by your login session. + +### Diagnostics and recovery + +```text +feresectl xwayland status +feresectl xwayland retry +``` + +`status` reports whether the service is idle, starting, running, backing off, or +failed, plus the display, the Satellite PID, and whether readiness was verified. +A failed service is not restarted automatically: Ferese retries a small bounded +number of times, then stops and waits, so a broken binary cannot produce a +restart loop. Run `feresectl xwayland retry` once the cause is fixed. The +snapshot never contains the X11 cookie, so it is safe to paste into a bug +report. + +`retry` can also refuse to run. If the previous service group cannot be proven +gone — for example it survived the stop sequence — Ferese keeps its cleanup +record instead of starting a second generation that would compete with +survivors for the same display, and the retry fails with a message naming what +still holds the X11 display. The endpoint is not republished in the meantime. +Run the retry again once that group has finished exiting. + +Each start uses a fresh, private notification socket and only a `READY=1` +message from the Satellite process that was actually spawned is accepted, so a +stale or unrelated notification cannot be mistaken for readiness. + +### Limitations + +- X11 applications are ordinary Wayland windows as far as layout, focus, and + window rules are concerned. Rules match the application identity Satellite + reports, so they are configured the same way as for native clients. +- Native Wayland preferences are kept as they are. Ferese does not force GTK, + Qt, Firefox, or Electron onto X11 to demonstrate support. +- Portal dialogs requested with an `x11:` parent are shown **unparented**. The + chooser or consent prompt still opens and authorization is unchanged; Ferese + does not yet translate an X11 window ID into a Wayland export, and inventing a + parent would be a security problem. +- Per-monitor fractional scaling of X11 applications depends on the selected + Satellite build and its Xsettings policy. Text sharpness, pointer coordinates, + and popup placement should be checked on each output profile rather than + assumed. +- Drag and drop between native and X11 applications depends on the Satellite + build and is not part of the initial support claim. +- Sandboxed applications may need explicit permission to reach the display. A + flatpak with only `fallback-x11` gets no X11 socket inside a Wayland session and + cannot read the private `XAUTHORITY` file under `$XDG_RUNTIME_DIR`, so it exits + without a window while the service itself reports healthy. See + [Sandboxed applications](installation.md#sandboxed-applications). + ## Power and logout Power actions use a centered confirmation. **Super+Shift+E** and diff --git a/docs/installation.md b/docs/installation.md index 2f2ee08..9e42407 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -54,6 +54,62 @@ other distributions; check the [Smithay dependencies](https://github.com/Smithay/smithay#system-dependencies) for equivalent libraries. +### X11 support (optional) + +To run X11-only applications, Ferese launches +[xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite), +which in turn needs an `Xwayland` binary. Both are runtime dependencies, not +build dependencies, and neither is needed to build Ferese. + +- `Xwayland` ships with the X.Org server packages: `xorg-x11-server-Xwayland` + on Fedora and Debian/Ubuntu, `xorg-xwayland` on Arch. +- `xwayland-satellite` is not packaged by most distributions. Build or install + it from its upstream releases **with its `systemd` feature enabled** + (`cargo build --release --features systemd`). That feature is what sends the + `READY=1` readiness notification Ferese waits for. Upstream's default feature + set is empty, so a default build never sends it, Ferese's fixed 10-second + startup budget expires, and the service reports a startup failure instead of + a display. A spawned process that is still alive is not readiness. + +Both the compositor's default and the packaged configuration ship with X11 +enabled, so a session with the two pieces above needs no configuration change. +If you maintain your own `config.kdl`, the block to keep is + +```kdl +xwayland { + enabled #true + startup on-demand + path "xwayland-satellite" +} +``` + +in `~/.config/ferese/config.kdl` and starting a new session, then check +`feresectl xwayland status`. See +[X11 support](configuration.md#x11-support-xwayland-satellite) for the full key +list, diagnostics, and known limitations. + +#### Sandboxed applications + +Ferese keeps the X11 cookie in a private file under `$XDG_RUNTIME_DIR`, and +applications inherit it through `XAUTHORITY`. Sandboxed packaging formats do not +all make that path visible, so an X11-only application may fail to start even +though `feresectl xwayland status` reports a running service. + +Flatpak masks `/run/user/1000` with the application's own runtime directory, and +a `fallback-x11` permission is not granted inside a Wayland session. Both effects +deny the sandbox access to the display, so the application exits without a +window. Grant the socket explicitly: + +```sh +flatpak override --user --socket=x11 com.spotify.Client +``` + +Snaps under strict confinement usually expose the real `/run/user/1000`, so a +snap that speaks X11 typically connects without extra configuration. That is a +confinement detail rather than a guarantee: if a snap application still cannot +reach the display, check whether its sandbox can read the `XAUTHORITY` path +reported by `feresectl xwayland status`. + ### Rust toolchain Install Rust through [rustup.rs](https://rustup.rs/). The checkout's diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 2001642..73db727 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -19,6 +19,9 @@ fn main() -> Result<(), Box> { if env::args().nth(1).as_deref() == Some("theme") { return theme_command(env::args().skip(2).collect()); } + if env::args().nth(1).as_deref() == Some("xwayland") { + return xwayland_command(env::args().skip(2).collect()); + } let (command, args) = parse_args(env::args().skip(1))?; let request = Request { version: VERSION, @@ -87,6 +90,40 @@ fn theme_command(args: Vec) -> Result<(), Box> { Ok(()) } +fn xwayland_command(args: Vec) -> Result<(), Box> { + use ferese_ipc::xwayland::{RETRY_COMMAND, STATUS_COMMAND, Status}; + + let command = match args.as_slice() { + [command] if command == "status" => STATUS_COMMAND, + [command] if command == "retry" => RETRY_COMMAND, + _ => return Err("usage: feresectl xwayland ".into()), + }; + + let request = Request { + version: VERSION, + id: 1, + kind: "command".to_owned(), + command: command.to_owned(), + args: json!({}), + }; + let mut stream = UnixStream::connect(socket_path()?)?; + write_frame(&mut stream, &request)?; + let response: Response = read_frame(&mut stream)?; + if let Some(error) = response.error { + return Err(format!("{}: {}", error.code, error.message).into()); + } + + let status = Status::from_value(response.result.unwrap_or(Value::Null))?; + if command == RETRY_COMMAND { + println!( + "X11 retry requested; current state: {}", + serde_json::to_string(&status.state)? + ); + } + println!("{}", serde_json::to_string_pretty(&status)?); + Ok(()) +} + // The compositor stages the PNG in a private directory and replies with its // path. Open it before unlinking so a failure never destroys the only copy, and // stream the bytes with explicit writes: println! would corrupt binary output. @@ -233,13 +270,75 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl xwayland \n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] mod tests { use super::*; + #[test] + fn xwayland_commands_map_to_the_shared_ipc_command_names() { + assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status"); + assert_eq!(ferese_ipc::xwayland::RETRY_COMMAND, "xwayland-retry"); + + assert!(usage().contains("feresectl xwayland ")); + } + + #[test] + fn the_xwayland_status_snapshot_round_trips_over_the_ipc_framing() { + let status = ferese_ipc::xwayland::Status { + enabled: true, + effective_startup: "on-demand".to_owned(), + state: ferese_ipc::xwayland::State::Running, + display: Some(":7".to_owned()), + satellite_pid: Some(24017), + generation: Some(2), + readiness: ferese_ipc::xwayland::Readiness::Verified, + recent_failures: 1, + restart_required: false, + last_error: None, + }; + let request = Request { + version: VERSION, + id: 1, + kind: "command".to_owned(), + command: ferese_ipc::xwayland::STATUS_COMMAND.to_owned(), + args: json!({}), + }; + + let mut bytes = Vec::new(); + write_frame(&mut bytes, &request).expect("encode the request"); + let decoded: Request = read_frame(&mut bytes.as_slice()).expect("decode the request"); + assert_eq!(decoded.command, "xwayland-status"); + + let mut reply = Vec::new(); + write_frame( + &mut reply, + &Response::success(1, ferese_ipc::xwayland::Status::to_value(&status)), + ) + .expect("encode the response"); + let response: Response = read_frame(&mut reply.as_slice()).expect("decode the response"); + assert!(response.error.is_none()); + + let decoded = ferese_ipc::xwayland::Status::from_value(response.result.expect("a result")) + .expect("decode the status snapshot"); + assert_eq!(decoded, status); + assert_eq!(decoded.state, ferese_ipc::xwayland::State::Running); + } + + #[test] + fn a_failed_x11_retry_surfaces_the_compositor_error_code() { + let response = Response::error(1, "x11_unavailable", "X11 is disabled in the configuration"); + let mut bytes = Vec::new(); + write_frame(&mut bytes, &response).expect("encode"); + let decoded: Response = read_frame(&mut bytes.as_slice()).expect("decode"); + + let error = decoded.error.expect("an error response"); + assert_eq!(error.code, "x11_unavailable"); + assert!(error.message.contains("disabled")); + } + #[test] fn media_commands_route_transport_and_player_preferences() { assert_eq!(parse_args(["media".into()]).unwrap().0, "media-get"); diff --git a/packaging/config.kdl b/packaging/config.kdl index 19d88e4..61ff89f 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -15,6 +15,12 @@ commands { mic-mute "wpctl" "set-mute" "@DEFAULT_AUDIO_SOURCE@" "toggle" } +xwayland { + enabled #true + startup on-demand + path "xwayland-satellite" +} + // These supplement the built-in shortcuts listed in docs/shortcuts.md. binding "Super+B" "spawn" "browser" binding "Super+Z" "spawn" "editor" diff --git a/packaging/ferese-session-shell b/packaging/ferese-session-shell index 363229b..334c179 100755 --- a/packaging/ferese-session-shell +++ b/packaging/ferese-session-shell @@ -29,10 +29,22 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then # Shell control uses a private socket FD. Activation must use the public # socket so portal and other desktop services connect as ordinary clients. public_display=${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}} - env WAYLAND_DISPLAY="$public_display" dbus-update-activation-environment --systemd \ - WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || \ - env WAYLAND_DISPLAY="$public_display" dbus-update-activation-environment \ - WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true + # Empty assignments replace stale values; D-Bus cannot remove activation variables. + activation_vars=( + "WAYLAND_DISPLAY=$public_display" + "DISPLAY=${DISPLAY:-}" + "XAUTHORITY=${XAUTHORITY:-}" + XDG_CURRENT_DESKTOP + XDG_SESSION_DESKTOP + XDG_SESSION_TYPE + ) + if command -v dbus-update-activation-environment >/dev/null; then + if ! env WAYLAND_DISPLAY="$public_display" \ + dbus-update-activation-environment --systemd "${activation_vars[@]}"; then + env WAYLAND_DISPLAY="$public_display" \ + dbus-update-activation-environment "${activation_vars[@]}" || true + fi + fi # User buses can outlive logout and may predate a newly installed service directory. if command -v dbus-send >/dev/null; then dbus-send --session --type=method_call --print-reply \ diff --git a/portal/src/consent.rs b/portal/src/consent.rs index 88c1bb3..be8dcff 100644 --- a/portal/src/consent.rs +++ b/portal/src/consent.rs @@ -136,8 +136,11 @@ impl cosmic::Application for Consent { Message::Opened(id) => { let parent = self.prompt.parent.clone(); return cosmic::iced::window::run(id, move |window| { - let parent = crate::parent::Parent::attach(window, &parent)?.map(Arc::new); - Ok((parent, ModalMaterial::attach(window))) + let attached = crate::parent::Parent::attach(window, &parent)?; + if let Some(diagnostic) = attached.diagnostic { + eprintln!("ferese portal: {diagnostic}"); + } + Ok((attached.parent.map(Arc::new), ModalMaterial::attach(window))) }) .map(|result| cosmic::Action::App(Message::Attached(result))); } diff --git a/portal/src/parent.rs b/portal/src/parent.rs index 81935de..fc23e5c 100644 --- a/portal/src/parent.rs +++ b/portal/src/parent.rs @@ -13,6 +13,62 @@ use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2; use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2::ZxdgImportedV2; use wayland_protocols::xdg::foreign::zv2::client::zxdg_importer_v2::ZxdgImporterV2; +const MAX_PARENT_IDENTIFIER: usize = 4096; + +#[derive(Debug, Default)] +pub(crate) struct ParentAttachment { + pub parent: Option, + pub diagnostic: Option, +} + +fn classify_parent<'a>(parent: &'a str) -> ParentKind<'a> { + if parent.is_empty() { + return ParentKind::None; + } + if parent.len() > MAX_PARENT_IDENTIFIER { + return ParentKind::Unsupported; + } + if let Some(handle) = parent.strip_prefix("wayland:") { + return if handle.is_empty() { + ParentKind::Unsupported + } else { + ParentKind::Wayland(handle) + }; + } + if let Some(handle) = parent.strip_prefix("x11:") { + return if is_x11_window_identifier(handle) { + ParentKind::X11 + } else { + // A malformed XID is a protocol error, not an X11 parent to + // degrade from: accepting arbitrary text here would let any string + // be reported as an X11 window. + ParentKind::Unsupported + }; + } + ParentKind::Unsupported +} + +/// Whether `identifier` is a syntactically valid X11 window identifier. +/// +/// XIDs are unsigned 32-bit hex, optionally with the `0x` prefix, as X11 +/// clients write them (for example `0x2400003` or `1a2b3c`). +fn is_x11_window_identifier(identifier: &str) -> bool { + let digits = identifier + .strip_prefix("0x") + .or_else(|| identifier.strip_prefix("0X")) + .unwrap_or(identifier); + + !digits.is_empty() && digits.len() <= 8 && digits.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[derive(Debug, PartialEq, Eq)] +enum ParentKind<'a> { + None, + Wayland(&'a str), + X11, + Unsupported, +} + #[derive(Debug)] pub(crate) struct Parent { connection: Connection, @@ -21,15 +77,25 @@ pub(crate) struct Parent { } impl Parent { - pub(crate) fn attach(window: &dyn cosmic::iced::window::Window, parent: &str) -> Result, String> { + pub(crate) fn attach(window: &dyn cosmic::iced::window::Window, parent: &str) -> Result { use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; - if parent.is_empty() { - return Ok(None); - } - let handle = parent - .strip_prefix("wayland:") - .filter(|handle| !handle.is_empty() && handle.len() <= 4096) - .ok_or("Unsupported portal parent window identifier")?; + let handle = match classify_parent(parent) { + ParentKind::None => { + return Ok(ParentAttachment::default()); + } + ParentKind::X11 => { + return Ok(ParentAttachment { + parent: None, + diagnostic: Some( + "showing this dialog unparented: X11 parent windows are not supported yet".to_owned(), + ), + }); + } + ParentKind::Unsupported => { + return Err("Unsupported portal parent window identifier".into()); + } + ParentKind::Wayland(handle) => handle, + }; let display = window.display_handle().map_err(|error| error.to_string())?; let surface = window.window_handle().map_err(|error| error.to_string())?; let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(surface)) = @@ -65,11 +131,14 @@ impl Parent { } imported.set_parent_of(&surface); connection.flush().map_err(|error| error.to_string())?; - Ok(Some(Self { - connection, - imported, - importer, - })) + Ok(ParentAttachment { + parent: Some(Self { + connection, + imported, + importer, + }), + diagnostic: None, + }) } } @@ -114,3 +183,71 @@ impl Dispatch for State { } delegate_noop!(State: ignore ZxdgImporterV2); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_x11_parent_degrades_instead_of_failing_the_request() { + assert!(matches!(classify_parent("x11:0x2400003"), ParentKind::X11)); + assert!(matches!(classify_parent("x11:1a2b3c"), ParentKind::X11)); + } + + #[test] + fn an_x11_parent_is_never_reinterpreted_as_a_wayland_handle() { + match classify_parent("x11:0x2400003") { + ParentKind::Wayland(handle) => panic!("an X11 parent must not become a Wayland handle: {handle}"), + ParentKind::X11 => {} + other => panic!("unexpected classification: {other:?}"), + } + } + + #[test] + fn wayland_parents_are_still_imported() { + assert!(matches!( + classify_parent("wayland:handle-1"), + ParentKind::Wayland("handle-1") + )); + assert_eq!(classify_parent(""), ParentKind::None); + } + + #[test] + fn malformed_x11_window_identifiers_are_errors_not_parents() { + // Anything that is not an X11 window identifier must never be reported + // as one: doing so would turn arbitrary caller text into a window. + for parent in [ + "x11:", + "x11:not-a-window", + "x11:0xzzzz", + "x11:0x2400003extra", + "x11:9999999999", + "x11:-1", + ] { + assert!( + matches!(classify_parent(parent), ParentKind::Unsupported), + "{parent} must stay a hard error" + ); + } + + // Every form an X11 client actually produces is still accepted. + for parent in ["x11:0x2400003", "x11:1a2b3c", "x11:0X1", "x11:ffffffff"] { + assert!( + matches!(classify_parent(parent), ParentKind::X11), + "{parent} is a valid window identifier" + ); + } + } + + #[test] + fn malformed_identifiers_remain_hard_errors() { + for parent in ["x11:", "wayland:", "dbus:", "not-a-parent", "X11:0x1"] { + assert!( + matches!(classify_parent(parent), ParentKind::Unsupported), + "{parent} must stay a hard error" + ); + } + let huge = format!("wayland:{}", "a".repeat(MAX_PARENT_IDENTIFIER + 1)); + assert!(matches!(classify_parent(&huge), ParentKind::Unsupported)); + } +} diff --git a/portal/src/picker.rs b/portal/src/picker.rs index ab42448..b4b32ec 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -177,8 +177,14 @@ impl cosmic::Application for Picker { Message::WindowOpened(id) => { let parent = self.prompt.parent.clone(); return cosmic::iced::window::run(id, move |window| { - let parent = crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new); - Ok((parent, ferese_theme_client::material::ModalMaterial::attach(window))) + let attached = crate::parent::Parent::attach(window, &parent)?; + if let Some(diagnostic) = attached.diagnostic { + eprintln!("ferese portal: {diagnostic}"); + } + Ok(( + attached.parent.map(std::sync::Arc::new), + ferese_theme_client::material::ModalMaterial::attach(window), + )) }) .map(|result| cosmic::Action::App(Message::Attached(result))); } diff --git a/protocol.c b/protocol.c deleted file mode 100644 index 7fddbf8..0000000 --- a/protocol.c +++ /dev/null @@ -1,103 +0,0 @@ -/* Generated by wayland-scanner 1.26.0 */ - -/* - * Copyright 2026 Ferese contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - */ - -#include -#include -#include -#include "wayland-util.h" - -#ifndef __has_attribute -# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */ -#endif - -#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4) -#define WL_PRIVATE __attribute__ ((visibility("hidden"))) -#else -#define WL_PRIVATE -#endif - -extern const struct wl_interface ferese_shell_v1_interface; - -static const struct wl_interface *ferese_shell_v1_types[] = { - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - &ferese_shell_v1_interface, -}; - -static const struct wl_message ferese_shell_manager_v1_requests[] = { - { "get_shell", "n", ferese_shell_v1_types + 9 }, - { "destroy", "", ferese_shell_v1_types + 0 }, -}; - -WL_PRIVATE const struct wl_interface ferese_shell_manager_v1_interface = { - "ferese_shell_manager_v1", 6, - 2, ferese_shell_manager_v1_requests, - 0, NULL, -}; - -static const struct wl_message ferese_shell_v1_requests[] = { - { "activate_window", "uu", ferese_shell_v1_types + 0 }, - { "close_window", "uu", ferese_shell_v1_types + 0 }, - { "activate_workspace", "uu", ferese_shell_v1_types + 0 }, - { "enter_overview", "", ferese_shell_v1_types + 0 }, - { "exit_overview", "", ferese_shell_v1_types + 0 }, - { "select_overview_window", "uu", ferese_shell_v1_types + 0 }, - { "destroy", "", ferese_shell_v1_types + 0 }, - { "confirm_logout", "3u", ferese_shell_v1_types + 0 }, - { "cancel_logout", "3u", ferese_shell_v1_types + 0 }, - { "confirm_logout_with_inhibitors", "4uuuu", ferese_shell_v1_types + 0 }, -}; - -static const struct wl_message ferese_shell_v1_events[] = { - { "snapshot_begin", "u", ferese_shell_v1_types + 0 }, - { "window", "uuuussu", ferese_shell_v1_types + 0 }, - { "output", "uusuuu", ferese_shell_v1_types + 0 }, - { "workspace", "uuuusuuuu", ferese_shell_v1_types + 0 }, - { "snapshot_end", "u", ferese_shell_v1_types + 0 }, - { "overview_state", "u", ferese_shell_v1_types + 0 }, - { "request_failed", "uuus", ferese_shell_v1_types + 0 }, - { "capabilities", "u", ferese_shell_v1_types + 0 }, - { "config_begin", "2", ferese_shell_v1_types + 0 }, - { "config_chunk", "2s", ferese_shell_v1_types + 0 }, - { "config_end", "2", ferese_shell_v1_types + 0 }, - { "logout_cancelled", "3u", ferese_shell_v1_types + 0 }, - { "logout_requested", "3us", ferese_shell_v1_types + 0 }, - { "toggle_keybinding_guide", "5s", ferese_shell_v1_types + 0 }, - { "toggle_display_mode", "6s", ferese_shell_v1_types + 0 }, - { "monitor_state_changed", "6", ferese_shell_v1_types + 0 }, -}; - -WL_PRIVATE const struct wl_interface ferese_shell_v1_interface = { - "ferese_shell_v1", 6, - 10, ferese_shell_v1_requests, - 16, ferese_shell_v1_events, -}; - diff --git a/protocol_client_header.c b/protocol_client_header.c deleted file mode 100644 index fa2dab5..0000000 --- a/protocol_client_header.c +++ /dev/null @@ -1,560 +0,0 @@ -/* Generated by wayland-scanner 1.26.0 */ - -#ifndef FERESE_SHELL_V1_CLIENT_PROTOCOL_H -#define FERESE_SHELL_V1_CLIENT_PROTOCOL_H - -#include -#include -#include "wayland-client.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @page page_ferese_shell_v1 The ferese_shell_v1 protocol - * @section page_ifaces_ferese_shell_v1 Interfaces - * - @subpage page_iface_ferese_shell_manager_v1 - create a capability-scoped Ferese shell controller - * - @subpage page_iface_ferese_shell_v1 - managed-window metadata and shell control - * @section page_copyright_ferese_shell_v1 Copyright - *
- *
- * Copyright 2026 Ferese contributors
- *
- * Permission is hereby granted, free of charge, to any person obtaining a
- * copy of this software and associated documentation files (the "Software"),
- * to deal in the Software without restriction, including without limitation
- * the rights to use, copy, modify, merge, publish, distribute, sublicense,
- * and/or sell copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
- * DEALINGS IN THE SOFTWARE.
- * 
- */ -struct ferese_shell_manager_v1; -struct ferese_shell_v1; - -#ifndef FERESE_SHELL_MANAGER_V1_INTERFACE -#define FERESE_SHELL_MANAGER_V1_INTERFACE -/** - * @page page_iface_ferese_shell_manager_v1 ferese_shell_manager_v1 - * @section page_iface_ferese_shell_manager_v1_desc Description - * - * This private global is visible only to clients granted the shell-control - * capability. It does not assign a Wayland surface role. - * @section page_iface_ferese_shell_manager_v1_api API - * See @ref iface_ferese_shell_manager_v1. - */ -/** - * @defgroup iface_ferese_shell_manager_v1 The ferese_shell_manager_v1 interface - * - * This private global is visible only to clients granted the shell-control - * capability. It does not assign a Wayland surface role. - */ -extern const struct wl_interface ferese_shell_manager_v1_interface; -#endif -#ifndef FERESE_SHELL_V1_INTERFACE -#define FERESE_SHELL_V1_INTERFACE -/** - * @page page_iface_ferese_shell_v1 ferese_shell_v1 - * @section page_iface_ferese_shell_v1_desc Description - * - * Window, workspace, and output identifiers are unsigned 64-bit values - * encoded as high and low 32-bit words. Each snapshot atomically replaces - * the prior shell view after snapshot_end. - * @section page_iface_ferese_shell_v1_api API - * See @ref iface_ferese_shell_v1. - */ -/** - * @defgroup iface_ferese_shell_v1 The ferese_shell_v1 interface - * - * Window, workspace, and output identifiers are unsigned 64-bit values - * encoded as high and low 32-bit words. Each snapshot atomically replaces - * the prior shell view after snapshot_end. - */ -extern const struct wl_interface ferese_shell_v1_interface; -#endif - -#define FERESE_SHELL_MANAGER_V1_GET_SHELL 0 -#define FERESE_SHELL_MANAGER_V1_DESTROY 1 - - -/** - * @ingroup iface_ferese_shell_manager_v1 - */ -#define FERESE_SHELL_MANAGER_V1_GET_SHELL_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_manager_v1 - */ -#define FERESE_SHELL_MANAGER_V1_DESTROY_SINCE_VERSION 1 - -/** @ingroup iface_ferese_shell_manager_v1 */ -static inline void -ferese_shell_manager_v1_set_user_data(struct ferese_shell_manager_v1 *ferese_shell_manager_v1, void *user_data) -{ - wl_proxy_set_user_data((struct wl_proxy *) ferese_shell_manager_v1, user_data); -} - -/** @ingroup iface_ferese_shell_manager_v1 */ -static inline void * -ferese_shell_manager_v1_get_user_data(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) -{ - return wl_proxy_get_user_data((struct wl_proxy *) ferese_shell_manager_v1); -} - -static inline uint32_t -ferese_shell_manager_v1_get_version(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) -{ - return wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1); -} - -/** - * @ingroup iface_ferese_shell_manager_v1 - */ -static inline struct ferese_shell_v1 * -ferese_shell_manager_v1_get_shell(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) -{ - struct wl_proxy *id; - - id = wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_manager_v1, - FERESE_SHELL_MANAGER_V1_GET_SHELL, &ferese_shell_v1_interface, wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1), 0, NULL); - - return (struct ferese_shell_v1 *) id; -} - -/** - * @ingroup iface_ferese_shell_manager_v1 - */ -static inline void -ferese_shell_manager_v1_destroy(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_manager_v1, - FERESE_SHELL_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1), WL_MARSHAL_FLAG_DESTROY); -} - -#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM -#define FERESE_SHELL_V1_WINDOW_STATE_ENUM -enum ferese_shell_v1_window_state { - FERESE_SHELL_V1_WINDOW_STATE_FOCUSED = 1, - FERESE_SHELL_V1_WINDOW_STATE_URGENT = 2, - FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN = 4, - FERESE_SHELL_V1_WINDOW_STATE_FLOATING = 8, -}; -#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM */ - -#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM -#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM -enum ferese_shell_v1_failed_request { - FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW = 0, - FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW = 1, - FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW = 2, - FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE = 3, -}; -#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM */ - -/** - * @ingroup iface_ferese_shell_v1 - * @struct ferese_shell_v1_listener - */ -struct ferese_shell_v1_listener { - /** - */ - void (*snapshot_begin)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t serial); - /** - */ - void (*window)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t window_hi, - uint32_t window_lo, - uint32_t workspace_hi, - uint32_t workspace_lo, - const char *app_id, - const char *title, - uint32_t state); - /** - * @param focused one when this is the focused output - */ - void (*output)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t output_hi, - uint32_t output_lo, - const char *name, - uint32_t active_workspace_hi, - uint32_t active_workspace_lo, - uint32_t focused); - /** - * @param output_hi zero when the workspace has no output - * @param output_lo zero when the workspace has no output - * @param index one-based position on this output - * @param window_count managed windows, including floating windows - * @param visible one when displayed on its output - * @param focused one when visible on the focused output - */ - void (*workspace)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t workspace_hi, - uint32_t workspace_lo, - uint32_t output_hi, - uint32_t output_lo, - const char *name, - uint32_t index, - uint32_t window_count, - uint32_t visible, - uint32_t focused); - /** - */ - void (*snapshot_end)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t serial); - /** - * @param active one when overview is active - */ - void (*overview_state)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t active); - /** - */ - void (*request_failed)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t request, - uint32_t object_hi, - uint32_t object_lo, - const char *reason); - /** - * marks completion of initial protocol setup - * - * - */ - void (*capabilities)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t version); - /** - * complete validated configuration for live reload - * - * Sent only after the compositor has accepted the complete - * configuration. Followed by bounded UTF-8 chunks and config_end. - * Clients apply only the completed source, preserving their - * current appearance if parsing fails. - * @since 2 - */ - void (*config_begin)(void *data, - struct ferese_shell_v1 *ferese_shell_v1); - /** - * @since 2 - */ - void (*config_chunk)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - const char *source); - /** - * @since 2 - */ - void (*config_end)(void *data, - struct ferese_shell_v1 *ferese_shell_v1); - /** - * @since 3 - */ - void (*logout_cancelled)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t serial); - /** - * @since 3 - */ - void (*logout_requested)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - uint32_t serial, - const char *output_name); - /** - * toggle the shortcut hint on the focused output - * - * Explicit requests are independent of the guide-at-login - * preference. - * @since 5 - */ - void (*toggle_keybinding_guide)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - const char *output_name); - /** - * toggle the display mode chooser on the focused output - * - * - * @since 6 - */ - void (*toggle_display_mode)(void *data, - struct ferese_shell_v1 *ferese_shell_v1, - const char *output_name); - /** - * physical monitor configuration changed - * - * Query the output IPC inventory after reconciliation. This - * includes disabled monitors and mirror targets without a logical - * wl_output. - * @since 6 - */ - void (*monitor_state_changed)(void *data, - struct ferese_shell_v1 *ferese_shell_v1); -}; - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline int -ferese_shell_v1_add_listener(struct ferese_shell_v1 *ferese_shell_v1, - const struct ferese_shell_v1_listener *listener, void *data) -{ - return wl_proxy_add_listener((struct wl_proxy *) ferese_shell_v1, - (void (**)(void)) listener, data); -} - -#define FERESE_SHELL_V1_ACTIVATE_WINDOW 0 -#define FERESE_SHELL_V1_CLOSE_WINDOW 1 -#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE 2 -#define FERESE_SHELL_V1_ENTER_OVERVIEW 3 -#define FERESE_SHELL_V1_EXIT_OVERVIEW 4 -#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW 5 -#define FERESE_SHELL_V1_DESTROY 6 -#define FERESE_SHELL_V1_CONFIRM_LOGOUT 7 -#define FERESE_SHELL_V1_CANCEL_LOGOUT 8 -#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS 9 - -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_SNAPSHOT_BEGIN_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_OUTPUT_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_WORKSPACE_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_SNAPSHOT_END_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_OVERVIEW_STATE_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_REQUEST_FAILED_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CAPABILITIES_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIG_BEGIN_SINCE_VERSION 2 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIG_CHUNK_SINCE_VERSION 2 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIG_END_SINCE_VERSION 2 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_LOGOUT_CANCELLED_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_LOGOUT_REQUESTED_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE_SINCE_VERSION 5 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE_SINCE_VERSION 6 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED_SINCE_VERSION 6 - -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_ACTIVATE_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CLOSE_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_ENTER_OVERVIEW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_EXIT_OVERVIEW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_DESTROY_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIRM_LOGOUT_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CANCEL_LOGOUT_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS_SINCE_VERSION 4 - -/** @ingroup iface_ferese_shell_v1 */ -static inline void -ferese_shell_v1_set_user_data(struct ferese_shell_v1 *ferese_shell_v1, void *user_data) -{ - wl_proxy_set_user_data((struct wl_proxy *) ferese_shell_v1, user_data); -} - -/** @ingroup iface_ferese_shell_v1 */ -static inline void * -ferese_shell_v1_get_user_data(struct ferese_shell_v1 *ferese_shell_v1) -{ - return wl_proxy_get_user_data((struct wl_proxy *) ferese_shell_v1); -} - -static inline uint32_t -ferese_shell_v1_get_version(struct ferese_shell_v1 *ferese_shell_v1) -{ - return wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_activate_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_ACTIVATE_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_close_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_CLOSE_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_activate_workspace(struct ferese_shell_v1 *ferese_shell_v1, uint32_t workspace_hi, uint32_t workspace_lo) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_ACTIVATE_WORKSPACE, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, workspace_hi, workspace_lo); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_enter_overview(struct ferese_shell_v1 *ferese_shell_v1) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_ENTER_OVERVIEW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_exit_overview(struct ferese_shell_v1 *ferese_shell_v1) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_EXIT_OVERVIEW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_select_overview_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_destroy(struct ferese_shell_v1 *ferese_shell_v1) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), WL_MARSHAL_FLAG_DESTROY); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_confirm_logout(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_CONFIRM_LOGOUT, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_cancel_logout(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_CANCEL_LOGOUT, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial); -} - -/** - * @ingroup iface_ferese_shell_v1 - */ -static inline void -ferese_shell_v1_confirm_logout_with_inhibitors(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial, uint32_t inhibitor_revision, uint32_t query_token, uint32_t force) -{ - wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, - FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial, inhibitor_revision, query_token, force); -} - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/protocol_server_header.c b/protocol_server_header.c deleted file mode 100644 index 19df514..0000000 --- a/protocol_server_header.c +++ /dev/null @@ -1,561 +0,0 @@ -/* Generated by wayland-scanner 1.26.0 */ - -#ifndef FERESE_SHELL_V1_SERVER_PROTOCOL_H -#define FERESE_SHELL_V1_SERVER_PROTOCOL_H - -#include -#include -#include "wayland-server.h" - -#ifdef __cplusplus -extern "C" { -#endif - -struct wl_client; -struct wl_resource; - -/** - * @page page_ferese_shell_v1 The ferese_shell_v1 protocol - * @section page_ifaces_ferese_shell_v1 Interfaces - * - @subpage page_iface_ferese_shell_manager_v1 - create a capability-scoped Ferese shell controller - * - @subpage page_iface_ferese_shell_v1 - managed-window metadata and shell control - * @section page_copyright_ferese_shell_v1 Copyright - *
- *
- * Copyright 2026 Ferese contributors
- *
- * Permission is hereby granted, free of charge, to any person obtaining a
- * copy of this software and associated documentation files (the "Software"),
- * to deal in the Software without restriction, including without limitation
- * the rights to use, copy, modify, merge, publish, distribute, sublicense,
- * and/or sell copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
- * DEALINGS IN THE SOFTWARE.
- * 
- */ -struct ferese_shell_manager_v1; -struct ferese_shell_v1; - -#ifndef FERESE_SHELL_MANAGER_V1_INTERFACE -#define FERESE_SHELL_MANAGER_V1_INTERFACE -/** - * @page page_iface_ferese_shell_manager_v1 ferese_shell_manager_v1 - * @section page_iface_ferese_shell_manager_v1_desc Description - * - * This private global is visible only to clients granted the shell-control - * capability. It does not assign a Wayland surface role. - * @section page_iface_ferese_shell_manager_v1_api API - * See @ref iface_ferese_shell_manager_v1. - */ -/** - * @defgroup iface_ferese_shell_manager_v1 The ferese_shell_manager_v1 interface - * - * This private global is visible only to clients granted the shell-control - * capability. It does not assign a Wayland surface role. - */ -extern const struct wl_interface ferese_shell_manager_v1_interface; -#endif -#ifndef FERESE_SHELL_V1_INTERFACE -#define FERESE_SHELL_V1_INTERFACE -/** - * @page page_iface_ferese_shell_v1 ferese_shell_v1 - * @section page_iface_ferese_shell_v1_desc Description - * - * Window, workspace, and output identifiers are unsigned 64-bit values - * encoded as high and low 32-bit words. Each snapshot atomically replaces - * the prior shell view after snapshot_end. - * @section page_iface_ferese_shell_v1_api API - * See @ref iface_ferese_shell_v1. - */ -/** - * @defgroup iface_ferese_shell_v1 The ferese_shell_v1 interface - * - * Window, workspace, and output identifiers are unsigned 64-bit values - * encoded as high and low 32-bit words. Each snapshot atomically replaces - * the prior shell view after snapshot_end. - */ -extern const struct wl_interface ferese_shell_v1_interface; -#endif - -/** - * @ingroup iface_ferese_shell_manager_v1 - * @struct ferese_shell_manager_v1_interface - */ -struct ferese_shell_manager_v1_interface { - /** - */ - void (*get_shell)(struct wl_client *client, - struct wl_resource *resource, - uint32_t id); - /** - */ - void (*destroy)(struct wl_client *client, - struct wl_resource *resource); -}; - - -/** - * @ingroup iface_ferese_shell_manager_v1 - */ -#define FERESE_SHELL_MANAGER_V1_GET_SHELL_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_manager_v1 - */ -#define FERESE_SHELL_MANAGER_V1_DESTROY_SINCE_VERSION 1 - -#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM -#define FERESE_SHELL_V1_WINDOW_STATE_ENUM -enum ferese_shell_v1_window_state { - FERESE_SHELL_V1_WINDOW_STATE_FOCUSED = 1, - FERESE_SHELL_V1_WINDOW_STATE_URGENT = 2, - FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN = 4, - FERESE_SHELL_V1_WINDOW_STATE_FLOATING = 8, -}; -#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM */ - -#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID -#define FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID -/** - * @ingroup iface_ferese_shell_v1 - * Validate a ferese_shell_v1 window_state value. - * - * @return true on success, false on error. - * @ref ferese_shell_v1_window_state - */ -static inline bool -ferese_shell_v1_window_state_is_valid(uint32_t value, uint32_t version) { - uint32_t valid = 0; - if (version >= 1) - valid |= FERESE_SHELL_V1_WINDOW_STATE_FOCUSED; - if (version >= 1) - valid |= FERESE_SHELL_V1_WINDOW_STATE_URGENT; - if (version >= 1) - valid |= FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN; - if (version >= 1) - valid |= FERESE_SHELL_V1_WINDOW_STATE_FLOATING; - return (value & ~valid) == 0; -} -#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID */ - -#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM -#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM -enum ferese_shell_v1_failed_request { - FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW = 0, - FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW = 1, - FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW = 2, - FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE = 3, -}; -#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM */ - -#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID -#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID -/** - * @ingroup iface_ferese_shell_v1 - * Validate a ferese_shell_v1 failed_request value. - * - * @return true on success, false on error. - * @ref ferese_shell_v1_failed_request - */ -static inline bool -ferese_shell_v1_failed_request_is_valid(uint32_t value, uint32_t version) { - switch (value) { - case FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW: - return version >= 1; - case FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW: - return version >= 1; - case FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW: - return version >= 1; - case FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE: - return version >= 1; - default: - return false; - } -} -#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID */ - -/** - * @ingroup iface_ferese_shell_v1 - * @struct ferese_shell_v1_interface - */ -struct ferese_shell_v1_interface { - /** - */ - void (*activate_window)(struct wl_client *client, - struct wl_resource *resource, - uint32_t window_hi, - uint32_t window_lo); - /** - */ - void (*close_window)(struct wl_client *client, - struct wl_resource *resource, - uint32_t window_hi, - uint32_t window_lo); - /** - */ - void (*activate_workspace)(struct wl_client *client, - struct wl_resource *resource, - uint32_t workspace_hi, - uint32_t workspace_lo); - /** - */ - void (*enter_overview)(struct wl_client *client, - struct wl_resource *resource); - /** - */ - void (*exit_overview)(struct wl_client *client, - struct wl_resource *resource); - /** - */ - void (*select_overview_window)(struct wl_client *client, - struct wl_resource *resource, - uint32_t window_hi, - uint32_t window_lo); - /** - */ - void (*destroy)(struct wl_client *client, - struct wl_resource *resource); - /** - * @since 3 - */ - void (*confirm_logout)(struct wl_client *client, - struct wl_resource *resource, - uint32_t serial); - /** - * @since 3 - */ - void (*cancel_logout)(struct wl_client *client, - struct wl_resource *resource, - uint32_t serial); - /** - * @since 4 - */ - void (*confirm_logout_with_inhibitors)(struct wl_client *client, - struct wl_resource *resource, - uint32_t serial, - uint32_t inhibitor_revision, - uint32_t query_token, - uint32_t force); -}; - -#define FERESE_SHELL_V1_SNAPSHOT_BEGIN 0 -#define FERESE_SHELL_V1_WINDOW 1 -#define FERESE_SHELL_V1_OUTPUT 2 -#define FERESE_SHELL_V1_WORKSPACE 3 -#define FERESE_SHELL_V1_SNAPSHOT_END 4 -#define FERESE_SHELL_V1_OVERVIEW_STATE 5 -#define FERESE_SHELL_V1_REQUEST_FAILED 6 -#define FERESE_SHELL_V1_CAPABILITIES 7 -#define FERESE_SHELL_V1_CONFIG_BEGIN 8 -#define FERESE_SHELL_V1_CONFIG_CHUNK 9 -#define FERESE_SHELL_V1_CONFIG_END 10 -#define FERESE_SHELL_V1_LOGOUT_CANCELLED 11 -#define FERESE_SHELL_V1_LOGOUT_REQUESTED 12 -#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE 13 -#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE 14 -#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED 15 - -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_SNAPSHOT_BEGIN_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_OUTPUT_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_WORKSPACE_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_SNAPSHOT_END_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_OVERVIEW_STATE_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_REQUEST_FAILED_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CAPABILITIES_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIG_BEGIN_SINCE_VERSION 2 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIG_CHUNK_SINCE_VERSION 2 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIG_END_SINCE_VERSION 2 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_LOGOUT_CANCELLED_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_LOGOUT_REQUESTED_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE_SINCE_VERSION 5 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE_SINCE_VERSION 6 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED_SINCE_VERSION 6 - -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_ACTIVATE_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CLOSE_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_ENTER_OVERVIEW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_EXIT_OVERVIEW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_DESTROY_SINCE_VERSION 1 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIRM_LOGOUT_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CANCEL_LOGOUT_SINCE_VERSION 3 -/** - * @ingroup iface_ferese_shell_v1 - */ -#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS_SINCE_VERSION 4 - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an snapshot_begin event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_snapshot_begin(struct wl_resource *resource_, uint32_t serial) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_SNAPSHOT_BEGIN, serial); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an window event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_window(struct wl_resource *resource_, uint32_t window_hi, uint32_t window_lo, uint32_t workspace_hi, uint32_t workspace_lo, const char *app_id, const char *title, uint32_t state) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_WINDOW, window_hi, window_lo, workspace_hi, workspace_lo, app_id, title, state); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an output event to the client owning the resource. - * @param resource_ The client's resource - * @param focused one when this is the focused output - */ -static inline void -ferese_shell_v1_send_output(struct wl_resource *resource_, uint32_t output_hi, uint32_t output_lo, const char *name, uint32_t active_workspace_hi, uint32_t active_workspace_lo, uint32_t focused) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_OUTPUT, output_hi, output_lo, name, active_workspace_hi, active_workspace_lo, focused); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an workspace event to the client owning the resource. - * @param resource_ The client's resource - * @param output_hi zero when the workspace has no output - * @param output_lo zero when the workspace has no output - * @param index one-based position on this output - * @param window_count managed windows, including floating windows - * @param visible one when displayed on its output - * @param focused one when visible on the focused output - */ -static inline void -ferese_shell_v1_send_workspace(struct wl_resource *resource_, uint32_t workspace_hi, uint32_t workspace_lo, uint32_t output_hi, uint32_t output_lo, const char *name, uint32_t index, uint32_t window_count, uint32_t visible, uint32_t focused) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_WORKSPACE, workspace_hi, workspace_lo, output_hi, output_lo, name, index, window_count, visible, focused); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an snapshot_end event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_snapshot_end(struct wl_resource *resource_, uint32_t serial) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_SNAPSHOT_END, serial); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an overview_state event to the client owning the resource. - * @param resource_ The client's resource - * @param active one when overview is active - */ -static inline void -ferese_shell_v1_send_overview_state(struct wl_resource *resource_, uint32_t active) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_OVERVIEW_STATE, active); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an request_failed event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_request_failed(struct wl_resource *resource_, uint32_t request, uint32_t object_hi, uint32_t object_lo, const char *reason) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_REQUEST_FAILED, request, object_hi, object_lo, reason); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an capabilities event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_capabilities(struct wl_resource *resource_, uint32_t version) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_CAPABILITIES, version); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an config_begin event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_config_begin(struct wl_resource *resource_) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_BEGIN); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an config_chunk event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_config_chunk(struct wl_resource *resource_, const char *source) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_CHUNK, source); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an config_end event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_config_end(struct wl_resource *resource_) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_END); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an logout_cancelled event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_logout_cancelled(struct wl_resource *resource_, uint32_t serial) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_LOGOUT_CANCELLED, serial); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an logout_requested event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_logout_requested(struct wl_resource *resource_, uint32_t serial, const char *output_name) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_LOGOUT_REQUESTED, serial, output_name); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an toggle_keybinding_guide event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_toggle_keybinding_guide(struct wl_resource *resource_, const char *output_name) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE, output_name); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an toggle_display_mode event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_toggle_display_mode(struct wl_resource *resource_, const char *output_name) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE, output_name); -} - -/** - * @ingroup iface_ferese_shell_v1 - * Sends an monitor_state_changed event to the client owning the resource. - * @param resource_ The client's resource - */ -static inline void -ferese_shell_v1_send_monitor_state_changed(struct wl_resource *resource_) -{ - wl_resource_post_event(resource_, FERESE_SHELL_V1_MONITOR_STATE_CHANGED); -} - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/scripts/tests/fake-xwayland-satellite.py b/scripts/tests/fake-xwayland-satellite.py new file mode 100755 index 0000000..a88df9b --- /dev/null +++ b/scripts/tests/fake-xwayland-satellite.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Fake Satellite for child-launch and notification tests; it does not serve X11.""" + +import ctypes +import errno +import json +import os +import signal +import socket +import sys +import time + +SO_ACCEPTCONN = 30 +_LIBC = ctypes.CDLL(None, use_errno=True) +_LIBC.getsockopt.argtypes = [ + ctypes.c_int, ctypes.c_int, ctypes.c_int, + ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint), +] + +RECORD = os.environ.get('FAKE_SATELLITE_RECORD', '') +READY = os.environ.get('FAKE_SATELLITE_READY', '1') +READY_DELAY = float(os.environ.get('FAKE_SATELLITE_READY_DELAY', '0')) +EXIT_BEFORE_READY = os.environ.get('FAKE_SATELLITE_EXIT_BEFORE_READY', '0') == '1' +EXIT_AFTER_READY = os.environ.get('FAKE_SATELLITE_EXIT_AFTER_READY', '0') == '1' +FORK_NOTIFY = os.environ.get('FAKE_SATELLITE_FORK_NOTIFY', '0') == '1' +IGNORE_SIGTERM = os.environ.get('FAKE_SATELLITE_IGNORE_SIGTERM', '0') == '1' +HOLD_FDS = os.environ.get('FAKE_SATELLITE_HOLD_FDS', '0') == '1' +LINGER = float(os.environ.get('FAKE_SATELLITE_LINGER', '30')) + + +def parse_args(argv): + display = None + authority = None + listenfds = [] + index = 0 + while index < len(argv): + argument = argv[index] + if display is None and not argument.startswith('-'): + display = argument + index += 1 + continue + if argument == '-auth': + index += 1 + authority = argv[index] + elif argument == '-nolisten': + index += 1 + if argv[index] != 'tcp': + sys.exit('-nolisten must be followed by tcp') + elif argument == '-listenfd': + index += 1 + listenfds.append(int(argv[index])) + else: + sys.exit(f'unexpected argument: {argument}') + index += 1 + + if display is None or authority is None: + sys.exit('missing display or -auth') + if not listenfds: + sys.exit('expected at least one -listenfd') + if len(set(listenfds)) != len(listenfds): + sys.exit('duplicate -listenfd') + return display, authority, listenfds + + +# The scan's own descriptor and entries that vanish mid-scan are not held descriptors. +def inherited_fds(): + inherited = [] + with os.scandir('/proc/self/fd') as entries: + for entry in entries: + try: + fd = int(entry.name) + except ValueError: + continue + try: + target = os.readlink(entry.path) + except OSError: + continue + if target == '/proc/self/fd': + continue + inherited.append(fd) + return sorted(inherited) + + +# Compare the link target: an fd number alone can be reused by an unrelated descriptor. +def sentinel_is_leaked(inherited): + sentinel = os.environ.get('FAKE_SATELLITE_SENTINEL_FD') + if not sentinel: + return False + try: + fd = int(sentinel) + except ValueError: + return False + target = os.environ.get('FAKE_SATELLITE_SENTINEL_TARGET') + if target: + try: + return os.readlink(f'/proc/self/fd/{fd}') == target + except OSError: + return False + return fd in inherited + + +def describe_fds(listenfds): + inherited = inherited_fds() + sentinel = os.environ.get('FAKE_SATELLITE_SENTINEL_FD') + return { + 'inherited_fds': inherited, + 'listenfds': listenfds, + 'sentinel_fd': int(sentinel) if sentinel else None, + 'sentinel_leaked': sentinel_is_leaked(inherited), + 'listening': { + str(fd): is_listening(fd) for fd in listenfds + }, + 'all_listening': all(is_listening(fd) for fd in listenfds), + } + + +def is_listening(fd): + value = ctypes.c_int(0) + length = ctypes.c_uint(ctypes.sizeof(value)) + if _LIBC.getsockopt( + fd, + socket.SOL_SOCKET, + SO_ACCEPTCONN, + ctypes.byref(value), + ctypes.byref(length), + ) != 0: + return False + return value.value == 1 + + +def notify(payload): + notify_socket = os.environ.get('NOTIFY_SOCKET') + if not notify_socket: + return False + address = notify_socket[1:] if notify_socket.startswith('@') else notify_socket + sender = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + sender.connect(address) + sender.sendall(payload) + return True + except OSError as error: + print(f'fake-satellite: notify failed: {error}', file=sys.stderr) + return False + finally: + sender.close() + + +# The descendant never exits on its own: a live holder after the leader is gone means group cleanup failed. +def hold_descriptors(listenfds): + child = os.fork() + if child == 0: + for fd in listenfds: + try: + os.dup2(fd, fd) + except OSError: + pass + while True: + time.sleep(3600) + return child + + +def main(): + display, authority, listenfds = parse_args(sys.argv[1:]) + if IGNORE_SIGTERM: + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + holder_pid = None + if HOLD_FDS: + holder_pid = hold_descriptors(listenfds) + + if RECORD: + record = { + 'pid': os.getpid(), + 'display': display, + 'authority': authority, + 'argv': sys.argv[1:], + 'env': { + key: os.environ.get(key) + for key in ('DISPLAY', 'XAUTHORITY', 'WAYLAND_DISPLAY', + 'WAYLAND_SOCKET', 'FERESE_PUBLIC_WAYLAND_DISPLAY', + 'FERESE_SHELL_CONTROL_SOCKET') + }, + } + record.update(describe_fds(listenfds)) + record['holder_pid'] = holder_pid + with open(RECORD, 'a', encoding='utf-8') as handle: + handle.write(json.dumps(record) + '\n') + + if EXIT_BEFORE_READY: + sys.exit(int(os.environ.get('FAKE_SATELLITE_EXIT_CODE', '17'))) + + if READY_DELAY: + time.sleep(READY_DELAY) + + if READY == '1': + payload = b'READY=1' + elif READY == 'child': + payload = b'READY=1' + elif READY == 'garbage': + payload = b'READY' + elif READY == 'truncated': + # Oversize the buffer so the kernel really reports MSG_TRUNC; a short + # payload would only be a malformed line. + payload = b'READY=1' + b'X' * 4096 + else: + payload = b'' + + if payload: + if FORK_NOTIFY or READY == 'child': + pid = os.fork() + if pid == 0: + notify(payload) + os._exit(0) + os.waitpid(pid, 0) + else: + notify(payload) + + if EXIT_AFTER_READY: + sys.exit(int(os.environ.get('FAKE_SATELLITE_EXIT_CODE', '19'))) + + deadline = time.monotonic() + LINGER + while time.monotonic() < deadline: + time.sleep(0.05) + sys.exit(0) + + +if __name__ == '__main__': + try: + main() + except OSError as error: + if error.errno != errno.EINTR: + raise diff --git a/scripts/tests/test_session_startup.py b/scripts/tests/test_session_startup.py index dc6d553..be4feda 100644 --- a/scripts/tests/test_session_startup.py +++ b/scripts/tests/test_session_startup.py @@ -12,13 +12,16 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell' class SessionStartupTest(unittest.TestCase): - def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False): + def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False, + display=None, xauthority=None, extra_env=None): with tempfile.TemporaryDirectory() as directory: root = Path(directory) helper = root / 'ferese-session-shell' shutil.copyfile(SCRIPT, helper) programs = { - 'dbus-update-activation-environment': 'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n', + 'dbus-update-activation-environment': ( + 'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n' + 'printf "vars:%s\\n" "$*" >> "$TEST_ROOT/events"\n'), 'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n', 'systemctl': 'echo "$*" >> "$TEST_ROOT/events"\nif [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n', 'ferese-polkit-agent': 'exec sleep 30\n', @@ -32,6 +35,13 @@ class SessionStartupTest(unittest.TestCase): FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display', FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77', TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status), TEST_WAIT=str(int(terminate))) + env.pop('DISPLAY', None) + env.pop('XAUTHORITY', None) + if display is not None: + env['DISPLAY'] = display + if xauthority is not None: + env['XAUTHORITY'] = xauthority + env.update(extra_env or {}) child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if terminate: deadline = time.monotonic() + 3 @@ -44,13 +54,51 @@ class SessionStartupTest(unittest.TestCase): child.communicate(timeout=4) return child.returncode, (root / 'events').read_text().splitlines() + @staticmethod + def imported_vars(events): + for line in events: + if line.startswith('vars:'): + return line[len('vars:'):].split(' ') + raise AssertionError('no activation-environment import recorded') + def test_direct_session_imports_public_display_before_activation_and_cleans_up(self): status, events = self.run_session(shell_status=7) self.assertEqual(status, 7) - self.assertEqual(events, ['import:public-display', 'reload-bus', '--user daemon-reload', - '--user start ferese-session.target', - '--user try-restart xdg-desktop-portal-ferese.service', 'shell:77', - '--user stop ferese-session.target']) + self.assertEqual([e for e in events if e.startswith('import:')], ['import:public-display']) + self.assertEqual([e for e in events if not e.startswith(('import:', 'vars:'))], + ['reload-bus', '--user daemon-reload', + '--user start ferese-session.target', + '--user try-restart xdg-desktop-portal-ferese.service', 'shell:77', + '--user stop ferese-session.target']) + + def test_a_managed_x11_endpoint_is_imported_for_dbus_activators(self): + status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd') + self.assertEqual(status, 0) + imported = self.imported_vars(events) + self.assertIn('DISPLAY=:7', imported) + self.assertIn('XAUTHORITY=/run/user/1000/ferese-xauth-abcd', imported) + self.assertIn('WAYLAND_DISPLAY=public-display', imported) + + def test_a_session_without_x11_imports_empty_values_instead_of_omitting_them(self): + status, events = self.run_session() + self.assertEqual(status, 0) + imported = self.imported_vars(events) + self.assertIn('DISPLAY=', imported) + self.assertIn('XAUTHORITY=', imported) + + def test_activation_import_never_leaks_the_private_socket_or_cookies(self): + status, events = self.run_session( + display=':7', xauthority='/run/user/1000/ferese-xauth-abcd', + extra_env={'WAYLAND_SOCKET': '77', + 'FERESE_SHELL_CONTROL_SOCKET': '/run/user/1000/ferese/shell.sock', + 'FERESE_PRIVATE_X11_COOKIE': 'deadbeefdeadbeefdeadbeefdeadbeef'}) + self.assertEqual(status, 0) + imported = self.imported_vars(events) + for leaked in ('WAYLAND_SOCKET', 'FERESE_SHELL_CONTROL_SOCKET', + 'FERESE_PRIVATE_X11_COOKIE', 'deadbeef'): + self.assertFalse([entry for entry in imported if leaked in entry], + f'{leaked} must not reach the activation environment') + self.assertIn('WAYLAND_DISPLAY=public-display', imported) def test_nested_session_never_changes_host_activation(self): status, events = self.run_session(direct=False) diff --git a/shell/ferese-shell/src/status_ui/media.rs b/shell/ferese-shell/src/status_ui/media.rs index a553e54..60af74b 100644 --- a/shell/ferese-shell/src/status_ui/media.rs +++ b/shell/ferese-shell/src/status_ui/media.rs @@ -243,7 +243,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me .center_y(64) .into(), }; - let mut info = column![text(player.label()).size(15).width(Length::Fill).ellipsize( + let text_size = if player.label().len() >= 24 { 13 } else { 15 }; + let mut info = column![text(player.label()).size(text_size).width(Length::Fill).ellipsize( cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2)) )] .spacing(4)