From 6e27672774be3da7551085dcb207dc56579bf1a7 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 12 Aug 2026 01:48:27 -0300 Subject: [PATCH] refactor: move the socket probe to its own file behind a feature Addresses the maintainer's two requests on #580: linux.rs was crowded, and the new fallback should not touch the base Wayland path. All the socket-probe machinery moves to src/platform/linux/wayland_probe.rs - the child entry point, the runtime-dir scan, the privilege drop, the process-group probe and its buffered-line inspection - leaving WaylandDisplayInfo and get_wayland_displays in linux.rs. The module and the fallback call in get_wayland_displays are gated on a new off-by-default feature 'wayland_probe'; without it get_wayland_displays returns the connect error exactly as it did before this fallback existed, so a consumer that does not build the DRM login-screen backend compiles none of this. The DRM build turns the feature on through scrap/drm. --- Cargo.toml | 3 + src/platform/linux.rs | 365 ++-------------------------- src/platform/linux/wayland_probe.rs | 342 ++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 349 deletions(-) create mode 100644 src/platform/linux/wayland_probe.rs diff --git a/Cargo.toml b/Cargo.toml index 6ad3012fa..11a49653a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ edition = "2018" [features] default = [] webrtc = ["dep:webrtc"] +# The isolated Wayland socket-probe fallback (src/platform/linux/wayland_probe.rs). Off by default +# so the base Wayland enumeration is untouched; the DRM login-screen build (scrap/drm) turns it on. +wayland_probe = [] [dependencies] # new flexi_logger failed on rustc 1.75 diff --git a/src/platform/linux.rs b/src/platform/linux.rs index cb9b582f5..b2ff866c2 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,4 +1,4 @@ -use crate::{bail, ResultType}; +use crate::ResultType; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -376,359 +376,26 @@ pub struct WaylandDisplayInfo { pub refresh_rate: i32, } -#[cfg(target_os = "linux")] -const RUNTIME_DIR_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); - -/// The argument the consumer binary must dispatch to `wayland_display_probe_child_main` before -/// any other startup work; see that function for why the probe is its own process. -#[cfg(target_os = "linux")] -pub const WAYLAND_DISPLAY_PROBE_ARG: &str = "--wayland-display-probe"; - -/// First stdout line of a probe child. A binary that does not dispatch the arg never prints it. -#[cfg(target_os = "linux")] -const WAYLAND_PROBE_MAGIC: &str = "wayland-display-probe-v1"; - -/// Latched on a failed handshake: a consumer that does not dispatch the probe arg runs its NORMAL -/// startup instead, and this path re-enters every enumeration cycle. -#[cfg(target_os = "linux")] -static PROBE_UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); - -#[cfg(target_os = "linux")] -static RUNTIME_DIR_PROBE_BUSY: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -/// Clears the in-flight flag on every exit path of the parent, error arms included. -#[cfg(target_os = "linux")] -struct ProbeBusyGuard; - -#[cfg(target_os = "linux")] -impl Drop for ProbeBusyGuard { - fn drop(&mut self) { - RUNTIME_DIR_PROBE_BUSY.store(false, std::sync::atomic::Ordering::Release); - } -} - -/// Entry point of the isolated probe process. The consumer binary dispatches -/// `WAYLAND_DISPLAY_PROBE_ARG` here first, before config, logging or any other startup work. -/// -/// Its own process because the release profile builds with panic=abort: sctk panics on malformed -/// protocol bytes, and in-process that abort takes the whole server down. Here it takes down only -/// this child, which the parent reports as a failed probe. The seat0 lookup also runs in here, so -/// the parent's single deadline bounds the loginctl reads too. -#[cfg(target_os = "linux")] -pub fn wayland_display_probe_child_main() -> ! { - use std::io::Write; - // The handshake first, so the parent can tell this entry point ran and not a consumer binary - // that fell through to its normal startup. - println!("{WAYLAND_PROBE_MAGIC}"); - let _ = std::io::stdout().flush(); - let code = match seat0_runtime_dir() - .and_then(|dir| { - drop_to_dir_owner(&dir)?; - probe_runtime_dir(&dir) - }) - .and_then(|displays| serde_json::to_string(&displays).map_err(anyhow::Error::from)) - { - Ok(json) => { - println!("{json}"); - 0 - } - Err(err) => { - eprintln!("{err:#}"); - 1 - } - }; - let _ = std::io::stdout().flush(); - std::process::exit(code) -} - -#[cfg(target_os = "linux")] -static ENDPOINT_WAS_NAMED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -/// Whether the environment ever named a wayland endpoint in this process. Empty is not a name. -/// -/// Read before `connect_to_env`, which removes `WAYLAND_SOCKET` from the environment on both its -/// success and its bad-fd path; and latched, so a consumed variable cannot turn a process that WAS -/// pointed at a compositor into one that is free to go looking for another. -#[cfg(target_os = "linux")] -fn env_names_wayland_endpoint() -> bool { - use std::sync::atomic::Ordering; - let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] - .iter() - .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())); - if named { - ENDPOINT_WAS_NAMED.store(true, Ordering::Release); - } - ENDPOINT_WAS_NAMED.load(Ordering::Acquire) -} - -/// `/run/user/` of the active seat0 session, a greeter included. -/// -/// Derived from the uid rather than read from `XDG_RUNTIME_DIR`: the root service is given no such -/// variable, and `get_home_dir_trusted` below refuses to trust the environment for the same reason. -#[cfg(target_os = "linux")] -/// The probe parses compositor-controlled protocol data; a root service must not do that as -/// root. Before touching the socket, become the runtime directory's owner — and refuse to probe -/// at all if the drop fails, since staying root is the one unacceptable outcome. -#[cfg(target_os = "linux")] -fn drop_to_dir_owner(dir: &Path) -> ResultType<()> { - if unsafe { libc::geteuid() } != 0 { - return Ok(()); - } - use std::os::unix::fs::MetadataExt; - let meta = std::fs::metadata(dir)?; - let (uid, gid) = (meta.uid(), meta.gid()); - if uid == 0 { - // Root's own session: there is no boundary to cross and nothing to drop to. - return Ok(()); - } - unsafe { - if libc::setgroups(0, std::ptr::null()) != 0 - || libc::setgid(gid) != 0 - || libc::setuid(uid) != 0 - || libc::setuid(0) == 0 - { - bail!("could not drop privileges for the socket probe"); - } - } - Ok(()) -} - -fn seat0_runtime_dir() -> ResultType { - let uid = get_values_of_seat0_with_gdm_wayland(&[1]).remove(0); - if uid.is_empty() || !uid.bytes().all(|b| b.is_ascii_digit()) { - bail!("no active seat0 session to take a runtime directory from"); - } - Ok(PathBuf::from(format!("/run/user/{uid}"))) -} - -/// The wayland sockets present in `dir`, lowest display number first. -/// -/// Scanned rather than guessed: `wl_display_add_socket_auto` takes the first FREE name up to -/// `wayland-32`, and a greeter is where leftovers accumulate across compositor restarts. Only that -/// name pattern, because the same directory holds pipewire and dbus sockets. -#[cfg(target_os = "linux")] -fn wayland_sockets_in(dir: &Path) -> Vec { - use std::os::unix::fs::FileTypeExt; - let mut paths: Vec = match std::fs::read_dir(dir) { - Ok(entries) => entries - .flatten() - .filter(|entry| { - let name = entry.file_name(); - let name = name.to_string_lossy(); - name.starts_with("wayland-") - && !name.ends_with(".lock") - && entry.file_type().map(|t| t.is_socket()).unwrap_or(false) - }) - .map(|entry| entry.path()) - .collect(), - Err(_) => Vec::new(), - }; - paths.sort_by_key(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .and_then(|name| name.strip_prefix("wayland-")) - .and_then(|number| number.parse::().ok()) - .unwrap_or(u32::MAX) - }); - paths -} - -/// Enumerate through a socket in the seat0 runtime directory, for the case where nothing named an -/// endpoint: a greeter's `--server` and the root service are given no compositor variables, so -/// nothing tells the enumerator where a compositor that IS running lives. An endpoint that WAS -/// named and failed must not silently reattach to a different compositor. -/// -/// In a subprocess and bounded, because the caller holds a process-wide lock across the call while -/// `connect(2)` parks on a full backlog and sctk's roundtrip polls without a deadline; and because -/// sctk panics on malformed output events, which the release profile's panic=abort turns into an -/// abort of the whole server. A child dies alone, and on the deadline it is killed instead of -/// leaking a thread. The seat0 lookup runs inside the child, under the same deadline. -#[cfg(target_os = "linux")] -fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType> { - use std::io::Read; - use std::sync::atomic::Ordering; - if named_endpoint { - bail!("an explicit wayland endpoint is set and did not connect"); - } - if PROBE_UNSUPPORTED.load(Ordering::Acquire) { - bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}"); - } - if RUNTIME_DIR_PROBE_BUSY.swap(true, Ordering::AcqRel) { - bail!("an earlier probe has not returned"); - } - let _busy = ProbeBusyGuard; - let exe = std::env::current_exe()?; - // Its own process group, so the deadline can kill loginctl descendants along with the child, - // and so no surviving descendant can hold the pipes open past the reads below. - use std::os::unix::process::CommandExt; - let mut child = std::process::Command::new(exe) - .arg(WAYLAND_DISPLAY_PROBE_ARG) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .process_group(0) - .spawn()?; - let probe_pgid = child.id() as libc::pid_t; - let kill_probe_group = || unsafe { - let _ = libc::kill(-probe_pgid, libc::SIGKILL); - }; - let deadline = std::time::Instant::now() + RUNTIME_DIR_PROBE_TIMEOUT; - let status = loop { - match child.try_wait()? { - Some(status) => { - kill_probe_group(); - break status; - } - None if std::time::Instant::now() >= deadline => { - kill_probe_group(); - let _ = child.wait(); - // An unwired binary runs its normal startup, and a long-running one (the - // server itself) lands HERE rather than at the handshake check below — latch - // on this path too, or every enumeration cycle spawns a full consumer - // process. Judged by what the child already wrote: a real probe prints the - // magic line first and flushes, so its absence after a whole deadline means - // this is not a probe. Only buffered bytes are read — a blocking read could - // hang on a grandchild that inherited the write end. - match first_buffered_line(child.stdout.take()) { - // The pipe could not be inspected at all: no evidence, no latch. - None => { - bail!("the wayland socket probe timed out and its output was uninspectable") - } - Some(head) if head.as_deref() == Some(WAYLAND_PROBE_MAGIC) => { - bail!("the wayland socket probe did not answer and was killed"); - } - Some(_) => { - PROBE_UNSUPPORTED.store(true, Ordering::Release); - bail!("the wayland socket probe timed out without the handshake; probe disabled"); - } - } - } - None => std::thread::sleep(std::time::Duration::from_millis(25)), - } - }; - let mut stdout = String::new(); - let mut stderr = String::new(); - if let Some(mut pipe) = child.stdout.take() { - let _ = pipe.read_to_string(&mut stdout); - } - if let Some(mut pipe) = child.stderr.take() { - let _ = pipe.read_to_string(&mut stderr); - } - let mut lines = stdout.lines(); - if lines.next() != Some(WAYLAND_PROBE_MAGIC) { - // Not a probe: the binary ran its normal startup. Latch, or this path would spawn one - // full consumer process per enumeration cycle. - PROBE_UNSUPPORTED.store(true, Ordering::Release); - bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}; probe disabled"); - } - if !status.success() { - let detail = stderr.trim(); - if detail.is_empty() { - // panic=abort or a signal leaves stderr empty; the status is then the only cause. - bail!("wayland socket probe failed: {status}"); - } - bail!("wayland socket probe failed ({status}): {detail}"); - } - let displays: Vec = - match serde_json::from_str(lines.next().unwrap_or_default()) { - Ok(displays) => displays, - Err(err) => bail!("wayland socket probe answered a malformed list: {err}"), - }; - // The child already refuses an empty list; refuse it here too, so a truncated pipe cannot - // become a cached-for-life empty enumeration. - if displays.is_empty() { - bail!("wayland socket probe returned no outputs"); - } - log::debug!( - "wayland: {} output(s) via the probe subprocess", - displays.len() - ); - Ok(displays) -} - -/// The first line already sitting in the pipe buffer, read strictly non-blocking: children of a -/// killed consumer can inherit the write end and keep it open, so an EOF-seeking read here could -/// hang the enumeration forever. Outer `None` means the pipe could not be INSPECTED (missing -/// handle, fcntl or read failure) and must not be read as evidence of anything; `Some(None)` is -/// an inspected-and-empty buffer. -#[cfg(target_os = "linux")] -fn first_buffered_line(pipe: Option) -> Option> { - use std::io::Read; - use std::os::fd::AsRawFd; - let mut pipe = pipe?; - let fd = pipe.as_raw_fd(); - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { - return None; - } - } - // The magic line is written in one flush and fits many times over; one read is enough. - let mut buf = vec![0u8; 256]; - match pipe.read(&mut buf) { - Ok(n) => { - buf.truncate(n); - Some( - String::from_utf8_lossy(&buf) - .lines() - .next() - .map(str::to_owned), - ) - } - // A drained pipe answers WouldBlock here, and an empty buffer after a whole deadline IS - // evidence; any error still counts as uninspectable. - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => Some(None), - Err(_) => None, - } -} - -#[cfg(target_os = "linux")] -fn probe_runtime_dir(dir: &Path) -> ResultType> { - use std::os::unix::net::UnixStream; - let mut errs = Vec::new(); - for path in wayland_sockets_in(dir) { - match UnixStream::connect(&path) - .map_err(anyhow::Error::from) - .and_then(|s| Connection::from_socket(s).map_err(anyhow::Error::from)) - .and_then(|conn| collect_wayland_displays(&conn)) - { - // The caller caches an empty list as ground truth for the process lifetime, and a - // compositor still probing its monitors is exactly what this path connects to. - Ok(displays) if displays.is_empty() => { - errs.push(format!("{}: no outputs yet", path.display())) - } - Ok(displays) => { - // Which socket answered, when nothing in the environment named one. - log::debug!( - "wayland: {} output(s) from {}, found by scanning", - displays.len(), - path.display() - ); - return Ok(displays); - } - Err(err) => errs.push(format!("{}: {err}", path.display())), - } - } - bail!( - "no usable wayland socket in {} ({})", - dir.display(), - if errs.is_empty() { - "none present".to_owned() - } else { - errs.join("; ") - } - ) -} +/// The isolated socket-probe fallback, in its own file and behind the `wayland_probe` feature so +/// the base Wayland path never compiles it. The DRM login-screen build turns it on. +#[cfg(feature = "wayland_probe")] +pub mod wayland_probe; +#[cfg(feature = "wayland_probe")] +pub use wayland_probe::{wayland_display_probe_child_main, WAYLAND_DISPLAY_PROBE_ARG}; // Retrieves information about all connected displays via the Wayland protocol. pub fn get_wayland_displays() -> ResultType> { - // Read before connecting: `connect_to_env` consumes `WAYLAND_SOCKET`. - let named_endpoint = env_names_wayland_endpoint(); + // Read before connecting: `connect_to_env` consumes `WAYLAND_SOCKET`. Only the probe fallback + // needs this, so it is computed only when that feature is compiled in. + #[cfg(feature = "wayland_probe")] + let named_endpoint = wayland_probe::env_names_wayland_endpoint(); match Connection::connect_to_env() { Ok(conn) => collect_wayland_displays(&conn), - Err(err) => wayland_displays_from_runtime_dir(named_endpoint) + // Without the feature, the connect error is final, exactly as before this fallback existed. + #[cfg(not(feature = "wayland_probe"))] + Err(err) => Err(err.into()), + #[cfg(feature = "wayland_probe")] + Err(err) => wayland_probe::wayland_displays_from_runtime_dir(named_endpoint) .map_err(|fallback_err| anyhow::anyhow!("{err}; {fallback_err}")), } } diff --git a/src/platform/linux/wayland_probe.rs b/src/platform/linux/wayland_probe.rs new file mode 100644 index 000000000..09e4541e7 --- /dev/null +++ b/src/platform/linux/wayland_probe.rs @@ -0,0 +1,342 @@ +//! Isolated Wayland display probe: enumerates a compositor over a runtime-directory socket when +//! the environment names no endpoint (a greeter's `--server` and the root service are given no +//! compositor variables). Gated behind the `wayland_probe` feature so the base Wayland path is +//! untouched — a consumer that does not build the DRM login-screen backend never compiles this, +//! and `get_wayland_displays` keeps its original behavior of returning the connect error. + +use super::{collect_wayland_displays, get_values_of_seat0_with_gdm_wayland, WaylandDisplayInfo}; +use crate::{bail, ResultType}; +use sctk::reexports::client::Connection; +use std::path::{Path, PathBuf}; + +const RUNTIME_DIR_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// The argument the consumer binary must dispatch to `wayland_display_probe_child_main` before +/// any other startup work; see that function for why the probe is its own process. +pub const WAYLAND_DISPLAY_PROBE_ARG: &str = "--wayland-display-probe"; + +/// First stdout line of a probe child. A binary that does not dispatch the arg never prints it. +const WAYLAND_PROBE_MAGIC: &str = "wayland-display-probe-v1"; + +/// Latched on a failed handshake: a consumer that does not dispatch the probe arg runs its NORMAL +/// startup instead, and this path re-enters every enumeration cycle. +static PROBE_UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +static RUNTIME_DIR_PROBE_BUSY: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Clears the in-flight flag on every exit path of the parent, error arms included. +struct ProbeBusyGuard; + +impl Drop for ProbeBusyGuard { + fn drop(&mut self) { + RUNTIME_DIR_PROBE_BUSY.store(false, std::sync::atomic::Ordering::Release); + } +} + +/// Entry point of the isolated probe process. The consumer binary dispatches +/// `WAYLAND_DISPLAY_PROBE_ARG` here first, before config, logging or any other startup work. +/// +/// Its own process because the release profile builds with panic=abort: sctk panics on malformed +/// protocol bytes, and in-process that abort takes the whole server down. Here it takes down only +/// this child, which the parent reports as a failed probe. The seat0 lookup also runs in here, so +/// the parent's single deadline bounds the loginctl reads too. +pub fn wayland_display_probe_child_main() -> ! { + use std::io::Write; + // The handshake first, so the parent can tell this entry point ran and not a consumer binary + // that fell through to its normal startup. + println!("{WAYLAND_PROBE_MAGIC}"); + let _ = std::io::stdout().flush(); + let code = match seat0_runtime_dir() + .and_then(|dir| { + drop_to_dir_owner(&dir)?; + probe_runtime_dir(&dir) + }) + .and_then(|displays| serde_json::to_string(&displays).map_err(anyhow::Error::from)) + { + Ok(json) => { + println!("{json}"); + 0 + } + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + let _ = std::io::stdout().flush(); + std::process::exit(code) +} + +static ENDPOINT_WAS_NAMED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Whether the environment ever named a wayland endpoint in this process. Empty is not a name. +/// +/// Read before `connect_to_env`, which removes `WAYLAND_SOCKET` from the environment on both its +/// success and its bad-fd path; and latched, so a consumed variable cannot turn a process that WAS +/// pointed at a compositor into one that is free to go looking for another. +pub(super) fn env_names_wayland_endpoint() -> bool { + use std::sync::atomic::Ordering; + let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())); + if named { + ENDPOINT_WAS_NAMED.store(true, Ordering::Release); + } + ENDPOINT_WAS_NAMED.load(Ordering::Acquire) +} + +/// The probe parses compositor-controlled protocol data; a root service must not do that as +/// root. Before touching the socket, become the runtime directory's owner — and refuse to probe +/// at all if the drop fails, since staying root is the one unacceptable outcome. +fn drop_to_dir_owner(dir: &Path) -> ResultType<()> { + if unsafe { libc::geteuid() } != 0 { + return Ok(()); + } + use std::os::unix::fs::MetadataExt; + let meta = std::fs::metadata(dir)?; + let (uid, gid) = (meta.uid(), meta.gid()); + if uid == 0 { + // Root's own session: there is no boundary to cross and nothing to drop to. + return Ok(()); + } + unsafe { + if libc::setgroups(0, std::ptr::null()) != 0 + || libc::setgid(gid) != 0 + || libc::setuid(uid) != 0 + || libc::setuid(0) == 0 + { + bail!("could not drop privileges for the socket probe"); + } + } + Ok(()) +} + +/// `/run/user/` of the active seat0 session, a greeter included. +/// +/// Derived from the uid rather than read from `XDG_RUNTIME_DIR`: the root service is given no such +/// variable, and `get_home_dir_trusted` refuses to trust the environment for the same reason. +fn seat0_runtime_dir() -> ResultType { + let uid = get_values_of_seat0_with_gdm_wayland(&[1]).remove(0); + if uid.is_empty() || !uid.bytes().all(|b| b.is_ascii_digit()) { + bail!("no active seat0 session to take a runtime directory from"); + } + Ok(PathBuf::from(format!("/run/user/{uid}"))) +} + +/// The wayland sockets present in `dir`, lowest display number first. +/// +/// Scanned rather than guessed: `wl_display_add_socket_auto` takes the first FREE name up to +/// `wayland-32`, and a greeter is where leftovers accumulate across compositor restarts. Only that +/// name pattern, because the same directory holds pipewire and dbus sockets. +fn wayland_sockets_in(dir: &Path) -> Vec { + use std::os::unix::fs::FileTypeExt; + let mut paths: Vec = match std::fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with("wayland-") + && !name.ends_with(".lock") + && entry.file_type().map(|t| t.is_socket()).unwrap_or(false) + }) + .map(|entry| entry.path()) + .collect(), + Err(_) => Vec::new(), + }; + paths.sort_by_key(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix("wayland-")) + .and_then(|number| number.parse::().ok()) + .unwrap_or(u32::MAX) + }); + paths +} + +/// Enumerate through a socket in the seat0 runtime directory, for the case where nothing named an +/// endpoint: a greeter's `--server` and the root service are given no compositor variables, so +/// nothing tells the enumerator where a compositor that IS running lives. An endpoint that WAS +/// named and failed must not silently reattach to a different compositor. +/// +/// In a subprocess and bounded, because the caller holds a process-wide lock across the call while +/// `connect(2)` parks on a full backlog and sctk's roundtrip polls without a deadline; and because +/// sctk panics on malformed output events, which the release profile's panic=abort turns into an +/// abort of the whole server. A child dies alone, and on the deadline it is killed instead of +/// leaking a thread. The seat0 lookup runs inside the child, under the same deadline. +pub(super) fn wayland_displays_from_runtime_dir( + named_endpoint: bool, +) -> ResultType> { + use std::io::Read; + use std::sync::atomic::Ordering; + if named_endpoint { + bail!("an explicit wayland endpoint is set and did not connect"); + } + if PROBE_UNSUPPORTED.load(Ordering::Acquire) { + bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}"); + } + if RUNTIME_DIR_PROBE_BUSY.swap(true, Ordering::AcqRel) { + bail!("an earlier probe has not returned"); + } + let _busy = ProbeBusyGuard; + let exe = std::env::current_exe()?; + // Its own process group, so the deadline can kill loginctl descendants along with the child, + // and so no surviving descendant can hold the pipes open past the reads below. + use std::os::unix::process::CommandExt; + let mut child = std::process::Command::new(exe) + .arg(WAYLAND_DISPLAY_PROBE_ARG) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .process_group(0) + .spawn()?; + let probe_pgid = child.id() as libc::pid_t; + let kill_probe_group = || unsafe { + let _ = libc::kill(-probe_pgid, libc::SIGKILL); + }; + let deadline = std::time::Instant::now() + RUNTIME_DIR_PROBE_TIMEOUT; + let status = loop { + match child.try_wait()? { + Some(status) => { + kill_probe_group(); + break status; + } + None if std::time::Instant::now() >= deadline => { + kill_probe_group(); + let _ = child.wait(); + // An unwired binary runs its normal startup, and a long-running one (the + // server itself) lands HERE rather than at the handshake check below — latch + // on this path too, or every enumeration cycle spawns a full consumer + // process. Judged by what the child already wrote: a real probe prints the + // magic line first and flushes, so its absence after a whole deadline means + // this is not a probe. Only buffered bytes are read — a blocking read could + // hang on a grandchild that inherited the write end. + match first_buffered_line(child.stdout.take()) { + // The pipe could not be inspected at all: no evidence, no latch. + None => { + bail!("the wayland socket probe timed out and its output was uninspectable") + } + Some(head) if head.as_deref() == Some(WAYLAND_PROBE_MAGIC) => { + bail!("the wayland socket probe did not answer and was killed"); + } + Some(_) => { + PROBE_UNSUPPORTED.store(true, Ordering::Release); + bail!("the wayland socket probe timed out without the handshake; probe disabled"); + } + } + } + None => std::thread::sleep(std::time::Duration::from_millis(25)), + } + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_string(&mut stdout); + } + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_string(&mut stderr); + } + let mut lines = stdout.lines(); + if lines.next() != Some(WAYLAND_PROBE_MAGIC) { + // Not a probe: the binary ran its normal startup. Latch, or this path would spawn one + // full consumer process per enumeration cycle. + PROBE_UNSUPPORTED.store(true, Ordering::Release); + bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}; probe disabled"); + } + if !status.success() { + let detail = stderr.trim(); + if detail.is_empty() { + // panic=abort or a signal leaves stderr empty; the status is then the only cause. + bail!("wayland socket probe failed: {status}"); + } + bail!("wayland socket probe failed ({status}): {detail}"); + } + let displays: Vec = + match serde_json::from_str(lines.next().unwrap_or_default()) { + Ok(displays) => displays, + Err(err) => bail!("wayland socket probe answered a malformed list: {err}"), + }; + // The child already refuses an empty list; refuse it here too, so a truncated pipe cannot + // become a cached-for-life empty enumeration. + if displays.is_empty() { + bail!("wayland socket probe returned no outputs"); + } + log::debug!( + "wayland: {} output(s) via the probe subprocess", + displays.len() + ); + Ok(displays) +} + +/// The first line already sitting in the pipe buffer, read strictly non-blocking: children of a +/// killed consumer can inherit the write end and keep it open, so an EOF-seeking read here could +/// hang the enumeration forever. Outer `None` means the pipe could not be INSPECTED (missing +/// handle, fcntl or read failure) and must not be read as evidence of anything; `Some(None)` is +/// an inspected-and-empty buffer. +fn first_buffered_line(pipe: Option) -> Option> { + use std::io::Read; + use std::os::fd::AsRawFd; + let mut pipe = pipe?; + let fd = pipe.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return None; + } + } + // The magic line is written in one flush and fits many times over; one read is enough. + let mut buf = vec![0u8; 256]; + match pipe.read(&mut buf) { + Ok(n) => { + buf.truncate(n); + Some( + String::from_utf8_lossy(&buf) + .lines() + .next() + .map(str::to_owned), + ) + } + // A drained pipe answers WouldBlock here, and an empty buffer after a whole deadline IS + // evidence; any error still counts as uninspectable. + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => Some(None), + Err(_) => None, + } +} + +fn probe_runtime_dir(dir: &Path) -> ResultType> { + use std::os::unix::net::UnixStream; + let mut errs = Vec::new(); + for path in wayland_sockets_in(dir) { + match UnixStream::connect(&path) + .map_err(anyhow::Error::from) + .and_then(|s| Connection::from_socket(s).map_err(anyhow::Error::from)) + .and_then(|conn| collect_wayland_displays(&conn)) + { + // The caller caches an empty list as ground truth for the process lifetime, and a + // compositor still probing its monitors is exactly what this path connects to. + Ok(displays) if displays.is_empty() => { + errs.push(format!("{}: no outputs yet", path.display())) + } + Ok(displays) => { + // Which socket answered, when nothing in the environment named one. + log::debug!( + "wayland: {} output(s) from {}, found by scanning", + displays.len(), + path.display() + ); + return Ok(displays); + } + Err(err) => errs.push(format!("{}: {err}", path.display())), + } + } + bail!( + "no usable wayland socket in {} ({})", + dir.display(), + if errs.is_empty() { + "none present".to_owned() + } else { + errs.join("; ") + } + ) +}