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.
This commit is contained in:
Mariano Abad
2026-08-10 12:36:31 -03:00
parent abca153be9
commit 6c86f88e10
+50 -2
View File
@@ -543,6 +543,19 @@ fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType<Vec<Way
None if std::time::Instant::now() >= 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<Vec<Way
bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}; probe disabled");
}
if !status.success() {
bail!("wayland socket probe: {}", stderr.trim());
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}");
}
let displays: Vec<WaylandDisplayInfo> = serde_json::from_str(lines.next().unwrap_or_default())?;
bail!("wayland socket probe failed ({status}): {detail}");
}
let displays: Vec<WaylandDisplayInfo> = 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<Vec<Way
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.
#[cfg(target_os = "linux")]
fn first_buffered_line(pipe: Option<std::process::ChildStdout>) -> Option<String> {
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<Vec<WaylandDisplayInfo>> {
use std::os::unix::net::UnixStream;