From bcb3b44433d0622caad4b595743808fb80c6133f Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Fri, 7 Aug 2026 17:36:11 -0300 Subject: [PATCH 1/9] fix(linux): find the compositor socket when WAYLAND_DISPLAY is not set `get_wayland_displays` gives up as soon as `Connection::connect_to_env()` fails, which is any process that was not handed `WAYLAND_DISPLAY`. A login screen is the case that matters: rustdesk starts the greeter's `--server` without the compositor variables on purpose, and the desktop layout is then unavailable for the whole session even though the compositor is running and its socket sits in the runtime directory of the very user that process runs as. Measured at an sddm Plasma Wayland greeter: `/run/user/112/wayland-0` exists, `connect(2)` to it as that user succeeds, and the registry advertises `wl_output` and `wl_seat`. With this fallback the greeter reads the same output layout any other session does. Strictly a fallback after `connect_to_env` has already failed, so it cannot change a host where that succeeds; where there is no compositor at all the socket is simply absent and it fails exactly as before. --- src/platform/linux.rs | 47 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index d4b29bb20..86abf9fa4 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -377,6 +377,47 @@ pub struct WaylandDisplayInfo { } // Retrieves information about all connected displays via the Wayland protocol. +/// Connect to the compositor socket sitting in `XDG_RUNTIME_DIR` when `WAYLAND_DISPLAY` is not set. +/// +/// A login screen is the case this exists for. The greeter's `--server` is started deliberately +/// without the compositor variables, because the DRM capture path talks to the root service and a +/// render node and never to the compositor -- but the compositor IS running, and its socket is +/// owned by the very user this process runs as. Measured at an sddm greeter: `/run/user/112/wayland-0` +/// exists, a plain `connect(2)` as that user succeeds, and the registry advertises `wl_output`. +/// +/// Without this the output layout is unavailable at a login screen, and everything downstream has +/// to guess: every DRM display reports origin (0,0) because on Wayland each output scans out of its +/// own framebuffer, so the uinput pointer range collapsed to a single display on a multi-monitor +/// greeter. +/// +/// Strictly a fallback after `connect_to_env` has already failed, so it cannot change any host +/// where that succeeds; and where there is no compositor at all the socket is simply absent and +/// this fails exactly as before. +#[cfg(target_os = "linux")] +fn connect_to_runtime_dir_socket() -> ResultType { + use std::os::unix::net::UnixStream; + let dir = std::env::var("XDG_RUNTIME_DIR")?; + let mut last = None; + // Compositors name the socket `wayland-N`; 0 and 1 cover a greeter and a session started after + // it. Anything beyond that is not worth probing blind. + for name in ["wayland-0", "wayland-1"] { + let path = std::path::Path::new(&dir).join(name); + if !path.exists() { + continue; + } + match UnixStream::connect(&path).map_err(anyhow::Error::from).and_then(|s| { + Connection::from_socket(s).map_err(anyhow::Error::from) + }) { + Ok(conn) => return Ok(conn), + Err(err) => last = Some(format!("{}: {err}", path.display())), + } + } + Err(anyhow::anyhow!( + "no usable wayland socket in XDG_RUNTIME_DIR ({})", + last.unwrap_or_else(|| "none present".to_owned()) + )) +} + pub fn get_wayland_displays() -> ResultType> { struct WaylandEnv { registry_state: RegistryState, @@ -404,7 +445,11 @@ pub fn get_wayland_displays() -> ResultType> { sctk::delegate_output!(WaylandEnv); sctk::delegate_registry!(WaylandEnv); - let conn = Connection::connect_to_env()?; + let conn = match Connection::connect_to_env() { + Ok(conn) => conn, + Err(err) => connect_to_runtime_dir_socket() + .map_err(|fallback_err| anyhow::anyhow!("{err}; {fallback_err}"))?, + }; let (globals, mut event_queue) = globals::registry_queue_init(&conn)?; let queue_handle = event_queue.handle(); From f436f53f0d3a71431596bd620fbd20b349ab8711 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Fri, 7 Aug 2026 19:29:04 -0300 Subject: [PATCH 2/9] fix: only fall back when nothing named an endpoint, and validate the directory Review of the first version, all three correct. The important one: `connect_to_env()` honours `WAYLAND_DISPLAY` and `WAYLAND_SOCKET`, and the fallback ignored that choice. If an explicit endpoint was named and failed, this could attach to a *different* compositor and read output positions from the wrong display. It now runs only when neither variable is set, which is also the only case it was ever justified by. `XDG_RUNTIME_DIR` is read with `var_os` and rejected when relative, since `.` or `../tmp` would probe against the working directory. And every failed probe is kept instead of the last one overwriting the first, so an error naming both sockets says what happened to both. The comment is three lines now: the reasoning belongs here, in the commit. --- src/platform/linux.rs | 59 ++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 86abf9fa4..4086db473 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,4 +1,4 @@ -use crate::ResultType; +use crate::{bail, ResultType}; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -377,45 +377,46 @@ pub struct WaylandDisplayInfo { } // Retrieves information about all connected displays via the Wayland protocol. -/// Connect to the compositor socket sitting in `XDG_RUNTIME_DIR` when `WAYLAND_DISPLAY` is not set. -/// -/// A login screen is the case this exists for. The greeter's `--server` is started deliberately -/// without the compositor variables, because the DRM capture path talks to the root service and a -/// render node and never to the compositor -- but the compositor IS running, and its socket is -/// owned by the very user this process runs as. Measured at an sddm greeter: `/run/user/112/wayland-0` -/// exists, a plain `connect(2)` as that user succeeds, and the registry advertises `wl_output`. -/// -/// Without this the output layout is unavailable at a login screen, and everything downstream has -/// to guess: every DRM display reports origin (0,0) because on Wayland each output scans out of its -/// own framebuffer, so the uinput pointer range collapsed to a single display on a multi-monitor -/// greeter. -/// -/// Strictly a fallback after `connect_to_env` has already failed, so it cannot change any host -/// where that succeeds; and where there is no compositor at all the socket is simply absent and -/// this fails exactly as before. +// A greeter's `--server` is started without the compositor variables, so nothing tells +// the enumerator where a compositor that IS running lives. Only when nothing was told: +// an explicit endpoint that fails must not silently reattach to a different compositor. #[cfg(target_os = "linux")] fn connect_to_runtime_dir_socket() -> ResultType { use std::os::unix::net::UnixStream; - let dir = std::env::var("XDG_RUNTIME_DIR")?; - let mut last = None; - // Compositors name the socket `wayland-N`; 0 and 1 cover a greeter and a session started after - // it. Anything beyond that is not worth probing blind. + if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("WAYLAND_SOCKET").is_some() + { + bail!("an explicit wayland endpoint is set and did not connect"); + } + let dir = match std::env::var_os("XDG_RUNTIME_DIR") { + Some(dir) => std::path::PathBuf::from(dir), + None => bail!("XDG_RUNTIME_DIR is not set"), + }; + // Relative would probe against the working directory, not the runtime directory. + if !dir.is_absolute() { + bail!("XDG_RUNTIME_DIR is not absolute: {}", dir.display()); + } + let mut errs = Vec::new(); for name in ["wayland-0", "wayland-1"] { - let path = std::path::Path::new(&dir).join(name); + let path = dir.join(name); if !path.exists() { continue; } - match UnixStream::connect(&path).map_err(anyhow::Error::from).and_then(|s| { - Connection::from_socket(s).map_err(anyhow::Error::from) - }) { + match UnixStream::connect(&path) + .map_err(anyhow::Error::from) + .and_then(|s| Connection::from_socket(s).map_err(anyhow::Error::from)) + { Ok(conn) => return Ok(conn), - Err(err) => last = Some(format!("{}: {err}", path.display())), + Err(err) => errs.push(format!("{}: {err}", path.display())), } } - Err(anyhow::anyhow!( + bail!( "no usable wayland socket in XDG_RUNTIME_DIR ({})", - last.unwrap_or_else(|| "none present".to_owned()) - )) + if errs.is_empty() { + "none present".to_owned() + } else { + errs.join("; ") + } + ) } pub fn get_wayland_displays() -> ResultType> { From 4220302d4003dc568e61eef783fa381d176c113d Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Sat, 8 Aug 2026 14:26:01 -0300 Subject: [PATCH 3/9] fix: bound the socket fallback, and take the runtime dir from the uid Answers the review on this PR. The fallback kept three states that could not self-heal, and two of them came from the same place: it trusted the environment and it had no deadline. B1, no deadline on any part of the new path while the caller holds a process-wide lock: `connect(2)` parks on a full backlog and sctk's roundtrip polls with no deadline, so a socket that accepts and never speaks wayland stalls the display service, the six drm_capturer call sites and the flutter_ffi SyncReturn. The probe now runs on its own thread with a 2 s deadline, under the 3 s the uinput caller already budgets, and only one probe can be in flight. That also keeps sctk's `panic!`/`todo!` on malformed output events off the thread holding the lock, so it can no longer poison that mutex, and a `Builder` is used because `thread::spawn` panics when a thread cannot be created. B2, a newly reachable empty `Ok` latched into the caller's cache for the process lifetime: an empty list is now an error, so the existing no-cache path still applies and the next poll retries. B3, the `WAYLAND_SOCKET` half of the guard could not fire, because `connect_to_env` removes that variable on its success and bad-fd paths and this is re-entered every ~1.5 s. Both variables are read at the call site before connecting, and the answer is latched: a consumed variable cannot turn a process that was pointed at a compositor into one free to look for another. C4 and S1 have the same fix: the directory is `/run/user/` of the active seat0 session rather than `XDG_RUNTIME_DIR`, so no environment value reaches a connect in a process that can be root, and it works in the default build, which is given no such variable (C3). The candidates are scanned rather than guessed, since `wl_display_add_socket_auto` takes the first FREE name up to `wayland-32` and a greeter accumulates leftovers; that drops the `path.exists()` pre-stat and its TOCTOU window with it (C7), and empty values are no longer read as names (C6). C2, the first socket that accepted used to win unconditionally: the loop now carries on to the next candidate when a socket connects but fails the protocol or reports no outputs. C8, `registry_handlers!()` was empty, so a `wl_output` advertised between the registry snapshot and the roundtrip was dropped and never reached `outputs()`. C5 is real and is not fixable here: `get_primary_monitor` spawning a timeout-less xrandr inside the held lock is in the consumer, and I will send it there. --- src/platform/linux.rs | 175 +++++++++++++++++++++++++++++++++++------- 1 file changed, 146 insertions(+), 29 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 4086db473..3d04d0639 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -376,41 +376,152 @@ pub struct WaylandDisplayInfo { pub refresh_rate: i32, } -// Retrieves information about all connected displays via the Wayland protocol. -// A greeter's `--server` is started without the compositor variables, so nothing tells -// the enumerator where a compositor that IS running lives. Only when nothing was told: -// an explicit endpoint that fails must not silently reattach to a different compositor. #[cfg(target_os = "linux")] -fn connect_to_runtime_dir_socket() -> ResultType { - use std::os::unix::net::UnixStream; - if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("WAYLAND_SOCKET").is_some() - { +const RUNTIME_DIR_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +#[cfg(target_os = "linux")] +static RUNTIME_DIR_PROBE_BUSY: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Clears the in-flight flag even if the probe thread unwinds, so a panic does not disable the +/// fallback for the rest of the process. +#[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); + } +} + +#[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")] +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. +/// +/// Off this thread and bounded, because the caller holds a process-wide lock across the call while +/// `connect(2)` parks on a full backlog, sctk's roundtrip polls without a deadline, and sctk panics +/// on malformed output events. A probe that never returns leaves one thread behind, and every later +/// call then fails fast instead of stalling. +#[cfg(target_os = "linux")] +fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType> { + use std::sync::atomic::Ordering; + if named_endpoint { bail!("an explicit wayland endpoint is set and did not connect"); } - let dir = match std::env::var_os("XDG_RUNTIME_DIR") { - Some(dir) => std::path::PathBuf::from(dir), - None => bail!("XDG_RUNTIME_DIR is not set"), - }; - // Relative would probe against the working directory, not the runtime directory. - if !dir.is_absolute() { - bail!("XDG_RUNTIME_DIR is not absolute: {}", dir.display()); + let dir = seat0_runtime_dir()?; + if RUNTIME_DIR_PROBE_BUSY.swap(true, Ordering::AcqRel) { + bail!("an earlier probe of {} has not returned", dir.display()); } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let probe_dir = dir.clone(); + // Builder, because `thread::spawn` PANICS when the thread cannot be created, and that would + // unwind through a caller holding a process-wide lock. + if let Err(err) = std::thread::Builder::new() + .name("wayland-socket-probe".into()) + .spawn(move || { + let _guard = ProbeBusyGuard; + let _ = tx.send(probe_runtime_dir(&probe_dir)); + }) + { + RUNTIME_DIR_PROBE_BUSY.store(false, Ordering::Release); + bail!("could not spawn the wayland socket probe: {err}"); + } + match rx.recv_timeout(RUNTIME_DIR_PROBE_TIMEOUT) { + Ok(res) => res, + Err(_) => bail!("no answer from a wayland socket in {}", dir.display()), + } +} + +#[cfg(target_os = "linux")] +fn probe_runtime_dir(dir: &Path) -> ResultType> { + use std::os::unix::net::UnixStream; let mut errs = Vec::new(); - for name in ["wayland-0", "wayland-1"] { - let path = dir.join(name); - if !path.exists() { - continue; - } + 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)) { - Ok(conn) => return Ok(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) => return Ok(displays), Err(err) => errs.push(format!("{}: {err}", path.display())), } } bail!( - "no usable wayland socket in XDG_RUNTIME_DIR ({})", + "no usable wayland socket in {} ({})", + dir.display(), if errs.is_empty() { "none present".to_owned() } else { @@ -419,7 +530,18 @@ fn connect_to_runtime_dir_socket() -> ResultType { ) } +// 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(); + match Connection::connect_to_env() { + Ok(conn) => collect_wayland_displays(&conn), + Err(err) => wayland_displays_from_runtime_dir(named_endpoint) + .map_err(|fallback_err| anyhow::anyhow!("{err}; {fallback_err}")), + } +} + +fn collect_wayland_displays(conn: &Connection) -> ResultType> { struct WaylandEnv { registry_state: RegistryState, output_state: OutputState, @@ -440,18 +562,13 @@ pub fn get_wayland_displays() -> ResultType> { &mut self.registry_state } - sctk::registry_handlers!(); + sctk::registry_handlers![OutputState]; } sctk::delegate_output!(WaylandEnv); sctk::delegate_registry!(WaylandEnv); - let conn = match Connection::connect_to_env() { - Ok(conn) => conn, - Err(err) => connect_to_runtime_dir_socket() - .map_err(|fallback_err| anyhow::anyhow!("{err}; {fallback_err}"))?, - }; - let (globals, mut event_queue) = globals::registry_queue_init(&conn)?; + let (globals, mut event_queue) = globals::registry_queue_init(conn)?; let queue_handle = event_queue.handle(); let registry_state = RegistryState::new(&globals); From cccfb8af87300826542b44176e8a97ab1c1d58fb Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Sat, 8 Aug 2026 15:48:09 -0300 Subject: [PATCH 4/9] log which socket answered when nothing named one The fallback was silent on success, so a host where it engaged looked identical to one where it was never reached. Debug level: it runs on a cache miss, not per frame. --- src/platform/linux.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 3d04d0639..343247136 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -515,7 +515,15 @@ fn probe_runtime_dir(dir: &Path) -> ResultType> { Ok(displays) if displays.is_empty() => { errs.push(format!("{}: no outputs yet", path.display())) } - Ok(displays) => return Ok(displays), + 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())), } } From abca153be9ecaf4dae2594a02ce2515d4e9d8965 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Mon, 10 Aug 2026 09:56:12 -0300 Subject: [PATCH 5/9] fix: isolate the socket probe in a subprocess, and bound the seat0 lookup Two review findings: - The release profile builds with panic=abort, so the in-thread isolation was an illusion: an sctk panic on malformed bytes from a scanned socket aborted the whole server before ProbeBusyGuard could run. The probe now runs in a child process spawned from the current executable, which the consumer binary dispatches to wayland_display_probe_child_main before any other startup work. A panic there kills only the child, and the deadline now kills the child instead of leaking a blocked thread. - seat0_runtime_dir ran loginctl through Command::output with no bound, before the worker and its timeout existed, while the caller held the DISPLAYS lock. The lookup now runs inside the child, under the same two second deadline as everything else. A binary that does not dispatch the probe arg fails a magic line handshake and the probe latches off for the process lifetime, so the fallback degrades to the pre-fallback behavior instead of spawning a full consumer process per enumeration cycle. The consumer wiring is one early dispatch in core_main: #[cfg(target_os = "linux")] if std::env::args().nth(1).as_deref() == Some(hbb_common::platform::linux::WAYLAND_DISPLAY_PROBE_ARG) { hbb_common::platform::linux::wayland_display_probe_child_main(); } --- src/platform/linux.rs | 129 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 25 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 343247136..8c94f7d74 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -365,7 +365,7 @@ pub fn system_message(title: &str, msg: &str, forever: bool) -> ResultType<()> { crate::bail!("failed to post system message"); } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde_derive::Serialize, serde_derive::Deserialize)] pub struct WaylandDisplayInfo { pub name: String, pub x: i32, @@ -379,12 +379,25 @@ pub struct WaylandDisplayInfo { #[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 even if the probe thread unwinds, so a panic does not disable the -/// fallback for the rest of the process. +/// Clears the in-flight flag on every exit path of the parent, error arms included. #[cfg(target_os = "linux")] struct ProbeBusyGuard; @@ -395,6 +408,37 @@ impl Drop for ProbeBusyGuard { } } +/// 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| 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); @@ -466,38 +510,73 @@ fn wayland_sockets_in(dir: &Path) -> Vec { /// 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. /// -/// Off this thread and bounded, because the caller holds a process-wide lock across the call while -/// `connect(2)` parks on a full backlog, sctk's roundtrip polls without a deadline, and sctk panics -/// on malformed output events. A probe that never returns leaves one thread behind, and every later -/// call then fails fast instead of stalling. +/// 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"); } - let dir = seat0_runtime_dir()?; + 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 of {} has not returned", dir.display()); + bail!("an earlier probe has not returned"); } - let (tx, rx) = std::sync::mpsc::sync_channel(1); - let probe_dir = dir.clone(); - // Builder, because `thread::spawn` PANICS when the thread cannot be created, and that would - // unwind through a caller holding a process-wide lock. - if let Err(err) = std::thread::Builder::new() - .name("wayland-socket-probe".into()) - .spawn(move || { - let _guard = ProbeBusyGuard; - let _ = tx.send(probe_runtime_dir(&probe_dir)); - }) - { - RUNTIME_DIR_PROBE_BUSY.store(false, Ordering::Release); - bail!("could not spawn the wayland socket probe: {err}"); + let _busy = ProbeBusyGuard; + let exe = std::env::current_exe()?; + 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()) + .spawn()?; + let deadline = std::time::Instant::now() + RUNTIME_DIR_PROBE_TIMEOUT; + let status = loop { + match child.try_wait()? { + Some(status) => break status, + None if std::time::Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + bail!("the wayland socket probe did not answer and was killed"); + } + 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); } - match rx.recv_timeout(RUNTIME_DIR_PROBE_TIMEOUT) { - Ok(res) => res, - Err(_) => bail!("no answer from a wayland socket in {}", dir.display()), + 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() { + bail!("wayland socket probe: {}", stderr.trim()); + } + let displays: Vec = serde_json::from_str(lines.next().unwrap_or_default())?; + // 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) } #[cfg(target_os = "linux")] From 6c86f88e1068979bbb55c8cb485d257fa770c6c9 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Mon, 10 Aug 2026 12:36:31 -0300 Subject: [PATCH 6/9] fix: latch an unwired consumer on the timeout path too A binary that does not dispatch the probe argument runs its normal startup; a long-running one outlives the deadline and was killed before the handshake check could run, so PROBE_UNSUPPORTED never latched and every enumeration cycle spawned a full consumer process again. The timeout path now judges the child by what it 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 the buffered bytes are read, non-blocking, because an EOF-seeking read could hang on a grandchild that inherited the write end of the pipe. Also report the exit status when a failed child left stderr empty, which panic=abort and signals do, and name the malformed-list case in the deserialization error. --- src/platform/linux.rs | 52 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 8c94f7d74..08ff6d1eb 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -543,6 +543,19 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType= deadline => { let _ = child.kill(); 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. + if first_buffered_line(child.stdout.take()).as_deref() + != Some(WAYLAND_PROBE_MAGIC) + { + PROBE_UNSUPPORTED.store(true, Ordering::Release); + bail!("the wayland socket probe timed out without the handshake; probe disabled"); + } bail!("the wayland socket probe did not answer and was killed"); } None => std::thread::sleep(std::time::Duration::from_millis(25)), @@ -564,9 +577,18 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType = serde_json::from_str(lines.next().unwrap_or_default())?; + 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() { @@ -579,6 +601,32 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType) -> 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); + String::from_utf8_lossy(&buf).lines().next().map(str::to_owned) + } + Err(_) => None, + } +} + #[cfg(target_os = "linux")] fn probe_runtime_dir(dir: &Path) -> ResultType> { use std::os::unix::net::UnixStream; From f69648753a58ffb28882516623ca674c3eb11dc4 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 12 Aug 2026 00:17:25 -0300 Subject: [PATCH 7/9] fix: contain the probe in a process group, drop its privileges, and format Addresses fufesou's #580 re-review: - The child runs in its own process group and the deadline kills the whole group, so a hung loginctl or a leaked grandchild cannot survive the child or keep the pipes open past the reads. Verified: a deliberately leaked sleep grandchild no longer outlives the probe. - The probe parses compositor-controlled data, so before touching the socket the child drops to the runtime directory's owner and refuses to probe at all if the drop fails, rather than parsing untrusted bytes as root. loginctl still runs as root first, since finding the seat needs it. - first_buffered_line now distinguishes an inspected-but-empty pipe (WouldBlock) from an uninspectable one (fcntl or read error): only a real absence of the handshake latches the binary unsupported, an inspection failure does not. - rustfmt-clean. --- src/platform/linux.rs | 91 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 08ff6d1eb..cb9b582f5 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -423,7 +423,10 @@ pub fn wayland_display_probe_child_main() -> ! { println!("{WAYLAND_PROBE_MAGIC}"); let _ = std::io::stdout().flush(); let code = match seat0_runtime_dir() - .and_then(|dir| probe_runtime_dir(&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) => { @@ -465,6 +468,33 @@ fn env_names_wayland_endpoint() -> bool { /// 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()) { @@ -530,18 +560,29 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType break status, + Some(status) => { + kill_probe_group(); + break status; + } None if std::time::Instant::now() >= deadline => { - let _ = child.kill(); + 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 @@ -550,13 +591,19 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType { + 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"); + } } - bail!("the wayland socket probe did not answer and was killed"); } None => std::thread::sleep(std::time::Duration::from_millis(25)), } @@ -584,11 +631,11 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType = match serde_json::from_str(lines.next().unwrap_or_default()) - { - Ok(displays) => displays, - Err(err) => bail!("wayland socket probe answered a malformed list: {err}"), - }; + 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() { @@ -603,9 +650,11 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType) -> Option { +fn first_buffered_line(pipe: Option) -> Option> { use std::io::Read; use std::os::fd::AsRawFd; let mut pipe = pipe?; @@ -621,8 +670,16 @@ fn first_buffered_line(pipe: Option) -> Option { buf.truncate(n); - String::from_utf8_lossy(&buf).lines().next().map(str::to_owned) + 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, } } From 6e27672774be3da7551085dcb207dc56579bf1a7 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 12 Aug 2026 01:48:27 -0300 Subject: [PATCH 8/9] 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("; ") + } + ) +} From 8ec37ed531684281d66bb4f9ff5253fe0747908b Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 12 Aug 2026 02:39:28 -0300 Subject: [PATCH 9/9] fix: bound the wait and the pipe reads against a group-escaping descendant Two P3 hardening items from the review: a descendant that changes its own process group escapes the deadline's group kill, and could then leak or block the parent. - The deadline path now also sends a pid-targeted SIGKILL to the direct child, so child.wait() is bounded even if the child left the group and the group kill missed it. - The normal-exit path drains stdout and stderr non-blocking instead of read_to_string: the child has exited so its output is already buffered, but an escaped grandchild holding a write end would keep the pipe from EOF and hang a blocking read. The drain is capped so a descendant that keeps writing cannot spin it. first_buffered_line now shares that drain. Verified: a probe child whose grandchild setpgid-escapes and holds the pipe returns in 25 ms instead of hanging, and a direct child that escapes and blocks is bounded to the deadline instead of its full sleep. --- src/platform/linux/wayland_probe.rs | 71 ++++++++++++++++------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/src/platform/linux/wayland_probe.rs b/src/platform/linux/wayland_probe.rs index 09e4541e7..7ed07218d 100644 --- a/src/platform/linux/wayland_probe.rs +++ b/src/platform/linux/wayland_probe.rs @@ -168,7 +168,6 @@ fn wayland_sockets_in(dir: &Path) -> Vec { 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"); @@ -204,6 +203,10 @@ pub(super) fn wayland_displays_from_runtime_dir( } None if std::time::Instant::now() >= deadline => { kill_probe_group(); + // The direct pid too, not only its group: if the child left the group its own + // kill would miss it, and the wait below would then block on a live child. A + // pid-targeted SIGKILL is uncatchable, so wait() is bounded either way. + let _ = child.kill(); 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 @@ -229,14 +232,11 @@ pub(super) fn wayland_displays_from_runtime_dir( 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); - } + // Drained non-blocking, not read_to_string: the child exited so its output is already + // buffered, but a descendant that escaped the process group could still hold a write end open + // and an EOF-seeking read would then hang here forever. + let stdout = drain_nonblocking(child.stdout.take()).unwrap_or_default(); + let stderr = drain_nonblocking(child.stderr.take()).unwrap_or_default(); 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 @@ -269,14 +269,12 @@ pub(super) fn wayland_displays_from_runtime_dir( 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; +/// Everything already buffered in the pipe, read strictly non-blocking and capped: a descendant +/// that escaped the probe's process group can hold a write end open, so a blocking read (even +/// after the child exits) could hang the enumeration forever. `None` means the pipe could not be +/// INSPECTED (missing handle or fcntl failure) and must not be read as evidence of anything; +/// `Some` is whatever bytes were buffered, whether or not EOF arrived. +fn drain_nonblocking(pipe: Option) -> Option { let mut pipe = pipe?; let fd = pipe.as_raw_fd(); unsafe { @@ -285,23 +283,32 @@ fn first_buffered_line(pipe: Option) -> Option