apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m34s
android / android (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 1m18s
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.
Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` requires
the reader's effective set to be a superset of the target's PERMITTED set. NixOS's own Plasma
module ships `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.
`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. It is consulted LAST and never
overrides a readable `exe` — it is the process's own claim about itself, and a same-uid process can
set it to anything; the worst a spoof achieves is aiming detection at a backend that then fails its
own availability probe.
MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:
/proc/<pid> owner ✅ real uid (so the uid filter upstream was never the problem)
comm ✅ readable (decorated + truncated, unusable alone)
exe ❌ EACCES
cmdline (argv[0]) ✅ readable
End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.
Also reached by the same rung: gamescope carries `cap_sys_nice` on a number of distros, so a
wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.
Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.
Refs #158, #136.
802 lines
38 KiB
Rust
802 lines
38 KiB
Rust
//! Time-bounded child-process helpers.
|
|
//!
|
|
//! Every compositor query this crate makes shells out to a helper (`kscreen-doctor`, `systemctl`,
|
|
//! `pw-dump`, …), and most of them are *clients of the very thing being diagnosed*: `kscreen-doctor`
|
|
//! is a Wayland client, so against a wedged KWin it blocks in its own connect and **never returns**.
|
|
//! `Command::status()` / `Command::output()` have no timeout, so one hung helper pinned the calling
|
|
//! thread forever — and on the host that thread is the session's stream thread, whose only way to
|
|
//! end a session is to return. A stuck query therefore became a permanently stuck session.
|
|
//!
|
|
//! These wrappers bound the wait: poll for exit until the budget runs out, then kill the child and
|
|
//! report [`std::io::ErrorKind::TimedOut`], so callers see a plain "the helper failed" error and
|
|
//! take their existing failure path instead of hanging.
|
|
//!
|
|
//! What the budget bounds is the whole **process tree**, not just the process we spawned — see
|
|
//! [`tree`] for why that distinction is the entire difference on Windows.
|
|
|
|
use std::io::{Error, ErrorKind, Result};
|
|
use std::process::{Command, ExitStatus, Output};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Poll interval while waiting for a child to exit. Short enough that a fast helper (the normal
|
|
/// case — `kscreen-doctor` answers in tens of ms) isn't measurably delayed.
|
|
const POLL: Duration = Duration::from_millis(20);
|
|
|
|
/// Run `cmd` to completion, killing it if it outlives `budget`.
|
|
///
|
|
/// Stdout/stderr are left as the caller configured them (inherited by default), so this is for
|
|
/// commands run for their exit status alone — see [`output_within`] when the output is read.
|
|
pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitStatus> {
|
|
let mut child = cmd.spawn()?;
|
|
let tree = tree::Guard::attach(&child);
|
|
let deadline = Instant::now() + budget;
|
|
loop {
|
|
match child.try_wait()? {
|
|
Some(status) => {
|
|
// The helper is gone; anything it left running is not something the caller asked
|
|
// for and still holds the stdio it inherited from us.
|
|
tree.terminate();
|
|
return Ok(status);
|
|
}
|
|
None if Instant::now() >= deadline => {
|
|
tree.terminate();
|
|
let _ = child.kill();
|
|
let _ = child.wait(); // reap it — never leave a zombie behind
|
|
return Err(timed_out(cmd, budget));
|
|
}
|
|
None => std::thread::sleep(POLL),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run `cmd` to completion and capture its stdout/stderr, killing it if it outlives `budget`.
|
|
///
|
|
/// The output is read only after the child has exited, so a helper that fills the pipe buffer and
|
|
/// stalls is caught by the budget rather than deadlocking the reader (these helpers emit at most a
|
|
/// few hundred KiB, well under any real pipe pressure).
|
|
pub(crate) fn output_within(cmd: &mut Command, budget: Duration) -> Result<Output> {
|
|
let mut child = cmd
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()?;
|
|
let tree = tree::Guard::attach(&child);
|
|
let deadline = Instant::now() + budget;
|
|
loop {
|
|
match child.try_wait()? {
|
|
Some(_) => {
|
|
// Exited: `wait_with_output` now only drains already-buffered pipes — but only if
|
|
// nothing else still holds their WRITE end. A grandchild that outlived the helper
|
|
// does, and `wait_with_output` reads to an EOF that would then never arrive, which
|
|
// is the one way this "bounded" helper could still hang forever. End the tree first.
|
|
tree.terminate();
|
|
return child.wait_with_output();
|
|
}
|
|
None if Instant::now() >= deadline => {
|
|
tree.terminate();
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(timed_out(cmd, budget));
|
|
}
|
|
None => std::thread::sleep(POLL),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn timed_out(cmd: &Command, budget: Duration) -> Error {
|
|
let program = cmd.get_program().to_string_lossy().to_string();
|
|
tracing::warn!(
|
|
program,
|
|
budget_ms = budget.as_millis() as u64,
|
|
"helper did not exit within its budget — killed it (a wedged compositor/session bus is the \
|
|
usual cause); treating it as a failed query"
|
|
);
|
|
Error::new(
|
|
ErrorKind::TimedOut,
|
|
format!("`{program}` did not exit within {budget:?}"),
|
|
)
|
|
}
|
|
|
|
/// The calling process's real uid.
|
|
///
|
|
/// Every session/gamescope lookup that derives `/run/user/<uid>` (or filters `/proc` to "our"
|
|
/// processes) needs this, and each one used to open its own `unsafe` block carrying a verbatim
|
|
/// copy of the same SAFETY note. `getuid()` is parameterless, always succeeds, and touches no
|
|
/// memory, so there is no contract for a caller to uphold — which makes it exactly the shape that
|
|
/// belongs behind a safe wrapper instead of being restated at every call site. One `unsafe` here,
|
|
/// none at the callers.
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) fn current_uid() -> u32 {
|
|
// SAFETY: parameterless POSIX call that always succeeds and touches no memory — it just
|
|
// returns the calling process's real uid. Nothing is aliased, read, or freed.
|
|
unsafe { libc::getuid() }
|
|
}
|
|
|
|
/// The longest `/proc/<pid>/comm` the kernel will report: `TASK_COMM_LEN` is 16 *including* the
|
|
/// NUL, so a name of exactly this many bytes may be a truncation of a longer one.
|
|
#[cfg(target_os = "linux")]
|
|
const COMM_MAX: usize = 15;
|
|
|
|
/// The executable name to identify a process by, with nixpkgs wrapper decoration undone.
|
|
///
|
|
/// `comm` is the kernel's name for the **executed file**, truncated to [`COMM_MAX`] bytes — it is
|
|
/// not `argv[0]` and not the command line. nixpkgs wraps essentially every graphical binary:
|
|
/// `wrapProgram` moves the real ELF aside to `.<name>-wrapped` and installs a shell wrapper under
|
|
/// the original name, and that wrapper `exec -a "$0"`s the hidden file. So `ps`/`pgrep -a` show a
|
|
/// perfectly ordinary `kwin_wayland` (they read argv) while the kernel reports `.kwin_wayland-w`
|
|
/// — 15 bytes of `.kwin_wayland-wrapped`, which can never equal `kwin_wayland`.
|
|
///
|
|
/// That is not a KDE-only detail. On NixOS `kwin_wayland`, `gamescope`, `gnome-shell` and
|
|
/// `Hyprland` are all wrapped, so an exact `comm` comparison made [`super::session`]'s probe
|
|
/// answer [`crate::ActiveKind::None`] on a visibly running desktop — and because the probe is the
|
|
/// *only* input to that decision, no environment variable could reach it: `WAYLAND_DISPLAY` was
|
|
/// correct, capture worked the moment detection was satisfied, and a `PUNKTFUNK_COMPOSITOR` pin
|
|
/// turned the miss into a hard error via `pinned_at_a_dead_session`. (sway survives by accident —
|
|
/// nixpkgs' wrapper execs a real binary that is itself still called `sway`.)
|
|
///
|
|
/// The `comm` fast path is kept for every ordinary distro: one read, no readlink. Only a name that
|
|
/// *could* be decorated or truncated — it starts with `.`, or it is exactly [`COMM_MAX`] bytes —
|
|
/// is re-resolved, first through `/proc/<pid>/exe` and then, when the kernel refuses that link,
|
|
/// through `argv[0]`.
|
|
///
|
|
/// 🛑 **That last rung is not defensive padding — without it this resolver misses the exact box it
|
|
/// was written for.** Reading `/proc/<pid>/exe` is *not* merely a matter of owning the process: the
|
|
/// kernel gates it behind `cap_ptrace_access_check`, which demands the reader's effective set be a
|
|
/// superset of the target's PERMITTED set. A compositor holding a capability is therefore opaque to
|
|
/// our (deliberately uncapped — see the KWin identification note in `pf-encode`) host, same uid or
|
|
/// not. And NixOS's own Plasma module ships exactly that:
|
|
/// `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`. So on NixOS + KDE the
|
|
/// two traps compose — the name needs `exe` *because* nixpkgs wrapped it, and `exe` is denied
|
|
/// *because* NixOS capped it — and the session probe went straight back to
|
|
/// [`crate::ActiveKind::None`] on a running desktop.
|
|
///
|
|
/// Measured (Linux 6.x, same-uid reader, target holding `cap_sys_nice`), for a file capability and
|
|
/// for the ambient-capability form `security.wrappers` actually uses, identically:
|
|
///
|
|
/// | probe | capped target |
|
|
/// |---|---|
|
|
/// | `/proc/<pid>` owner | ✅ still the real uid — the uid filter upstream is unaffected |
|
|
/// | `comm` | ✅ readable (decorated/truncated, so still unusable on its own) |
|
|
/// | `exe` | ❌ **EACCES** |
|
|
/// | `cmdline` (`argv[0]`) | ✅ readable |
|
|
///
|
|
/// `argv[0]` is only consulted when the kernel has refused the authoritative answer, because it is
|
|
/// the process's own claim about itself rather than the kernel's: a same-uid process can set it to
|
|
/// anything. The exposure that buys is small and one-directional — the worst a spoof achieves is
|
|
/// aiming detection at a compositor backend that then fails its own availability probe — whereas
|
|
/// without the rung a capped compositor is simply invisible. It reads correctly here for the same
|
|
/// reason `ps` does: make-wrapper's generated wrapper `exec -a "$0"`s the hidden binary, so
|
|
/// `argv[0]` survives the decoration that `comm` does not.
|
|
///
|
|
/// `pid_path` is a `/proc/<pid>` directory. `None` when the process vanished mid-scan.
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) fn match_name(pid_path: &std::path::Path) -> Option<String> {
|
|
let comm = std::fs::read_to_string(pid_path.join("comm")).ok()?;
|
|
let comm = comm.trim();
|
|
// An undecorated name short enough to be complete is already the answer.
|
|
if !comm.starts_with('.') && comm.len() < COMM_MAX {
|
|
return Some(comm.to_string());
|
|
}
|
|
// The authoritative rung: the kernel's own record of the executed file, untruncated. Absent for
|
|
// a kernel thread and for a process exiting under us, and REFUSED for a capability-holding one.
|
|
let exe = std::fs::read_link(pid_path.join("exe")).ok();
|
|
if let Some(full) = exe
|
|
.as_deref()
|
|
.and_then(|p| p.file_name())
|
|
.and_then(|n| n.to_str())
|
|
{
|
|
return Some(undecorate(full).to_string());
|
|
}
|
|
// Refused or gone: fall back to what the process calls itself, then to the truncated `comm`.
|
|
match argv0_name(pid_path) {
|
|
Some(name) => {
|
|
tracing::debug!(
|
|
comm = %comm,
|
|
resolved = %name,
|
|
"/proc/<pid>/exe unreadable (a capability-holding process refuses it); \
|
|
identified via argv[0]"
|
|
);
|
|
Some(name)
|
|
}
|
|
None => Some(comm.to_string()),
|
|
}
|
|
}
|
|
|
|
/// The file name in `argv[0]`, with nixpkgs decoration undone — the last rung of [`match_name`].
|
|
///
|
|
/// `/proc/<pid>/cmdline` is NUL-separated, so the first field is `argv[0]` whole, with no splitting
|
|
/// on whitespace to get wrong. `None` when it is unreadable or empty, which is the normal state for
|
|
/// a kernel thread and for a zombie.
|
|
#[cfg(target_os = "linux")]
|
|
fn argv0_name(pid_path: &std::path::Path) -> Option<String> {
|
|
let raw = std::fs::read(pid_path.join("cmdline")).ok()?;
|
|
let argv0 = raw.split(|b| *b == 0).next()?;
|
|
// A process that rewrote its own argv (setproctitle-style) can leave anything here, including
|
|
// something that is not a path at all — `file_name` simply yields it unchanged and it fails to
|
|
// match any compositor name, which is the correct outcome.
|
|
let argv0 = std::str::from_utf8(argv0).ok()?;
|
|
let name = std::path::Path::new(argv0).file_name()?.to_str()?;
|
|
(!name.is_empty()).then(|| undecorate(name).to_string())
|
|
}
|
|
|
|
/// Strip nixpkgs `wrapProgram` decoration: `.<name>-wrapped`, plus the `_` suffixes make-wrapper
|
|
/// appends when that hidden name is already taken (a doubly-wrapped app — Qt *and* GApps).
|
|
///
|
|
/// **Both** halves are required, and that is the load-bearing part rather than pedantry: KWin
|
|
/// ships its own real binary called `kwin_wayland_wrapper` (the session's parent process), so a
|
|
/// rule that merely stripped a `wrapper`-ish suffix would rewrite it into `kwin_wayland` and hand
|
|
/// the session probe the wrong PID. Demanding the leading `.` as well keeps it — and any genuine
|
|
/// `foo-wrapped` — under its real name.
|
|
#[cfg(target_os = "linux")]
|
|
fn undecorate(name: &str) -> &str {
|
|
let Some(rest) = name.strip_prefix('.') else {
|
|
return name;
|
|
};
|
|
match rest.trim_end_matches('_').strip_suffix("-wrapped") {
|
|
Some(real) if !real.is_empty() => real,
|
|
_ => name,
|
|
}
|
|
}
|
|
|
|
/// Ending the *tree* the helper started, not just the process we spawned.
|
|
///
|
|
/// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the
|
|
/// process we launched. On Unix that is the whole story here — `kscreen-doctor`, `systemctl`,
|
|
/// `pw-dump` and friends are single processes we exec directly, and none of them forks a worker
|
|
/// that outlives it.
|
|
///
|
|
/// On Windows it is not, because there is no direct exec: every helper is reached through a shell
|
|
/// (`cmd /c …`, `powershell -Command "… | pnputil …"`), so the process that actually hangs is a
|
|
/// **grandchild**. Killing the shell leaves it running — holding the stdio handles and the working
|
|
/// directory it inherited from us — and a budget that leaves that behind has not bounded anything.
|
|
/// This is not theoretical: it is how a fully green `cargo test -p pf-vdisplay` still failed its CI
|
|
/// job. The suite's own hung-helper case orphaned a 60-second `ping.exe`, which kept the build
|
|
/// step's stdout pipe open past the runner's 10 s `WaitDelay` and pinned `crates\pf-vdisplay` so
|
|
/// the workspace could not be cleaned up.
|
|
///
|
|
/// A Job object is the mechanism Windows provides for exactly this: job membership is inherited
|
|
/// across `CreateProcess`, so assigning the child enrolls every descendant it goes on to spawn, and
|
|
/// one `TerminateJobObject` ends all of them. `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes that hold
|
|
/// even on paths that never reach [`Guard::terminate`] — an early `?`, a panic — because closing
|
|
/// the last handle to the job is then itself the kill.
|
|
///
|
|
/// Best-effort by construction: if the job cannot be created or the child cannot be assigned, the
|
|
/// helpers degrade to the single-process kill they did before rather than failing the query, which
|
|
/// is the same stance as the already-ignored result of `Child::kill`.
|
|
#[cfg(windows)]
|
|
mod tree {
|
|
use std::os::windows::io::AsRawHandle;
|
|
use std::process::Child;
|
|
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
|
use windows::Win32::System::JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
|
SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
};
|
|
|
|
/// Owns a Job object holding the spawned helper and everything it spawns. `None` when the job
|
|
/// could not be set up (see the module doc: degrade, don't fail).
|
|
pub(super) struct Guard(Option<HANDLE>);
|
|
|
|
impl Guard {
|
|
/// Enroll `child` — and, transitively, its descendants — in a fresh kill-on-close job.
|
|
pub(super) fn attach(child: &Child) -> Self {
|
|
// SAFETY: an unnamed job object with default security attributes; no pointer is passed
|
|
// and none is retained. The handle it yields is owned by the `Guard` constructed from
|
|
// it on the next line and closed exactly once, in that `Guard`'s `Drop`.
|
|
let job = match unsafe { CreateJobObjectW(None, None) } {
|
|
Ok(job) => job,
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "no job object for this helper — a hung one's \
|
|
grandchildren will outlive its budget");
|
|
return Self(None);
|
|
}
|
|
};
|
|
// Owned from here on, so both fallible steps below can bail without leaking it.
|
|
let guard = Self(Some(job));
|
|
if let Err(e) = guard.enroll(child) {
|
|
tracing::debug!(error = %e, "could not enroll this helper in its job object — a \
|
|
hung one's grandchildren will outlive its budget");
|
|
}
|
|
guard
|
|
}
|
|
|
|
fn enroll(&self, child: &Child) -> windows::core::Result<()> {
|
|
let Some(job) = self.0 else { return Ok(()) };
|
|
let info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
|
BasicLimitInformation:
|
|
windows::Win32::System::JobObjects::JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
|
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
// SAFETY: `job` is the live handle this `Guard` owns. The pointer is to a fully
|
|
// initialised local of exactly the type `JobObjectExtendedLimitInformation` selects,
|
|
// passed with that type's own size, and the kernel copies the limits out before
|
|
// returning — `info` is not retained past the call.
|
|
unsafe {
|
|
SetInformationJobObject(
|
|
job,
|
|
JobObjectExtendedLimitInformation,
|
|
std::ptr::from_ref(&info).cast(),
|
|
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
)?;
|
|
}
|
|
// SAFETY: `job` is the live handle this `Guard` owns. `child` is borrowed for the call,
|
|
// so the process handle it lends is open and stays open for the duration — `Child`
|
|
// closes it only in its own `Drop`. The kernel duplicates what it needs; we keep
|
|
// ownership of both handles.
|
|
unsafe { AssignProcessToJobObject(job, HANDLE(child.as_raw_handle())) }
|
|
}
|
|
|
|
/// End every process still in the job. A no-op once they have all exited, so this is safe
|
|
/// to call on the success path as well as the timeout one.
|
|
pub(super) fn terminate(&self) {
|
|
let Some(job) = self.0 else { return };
|
|
// SAFETY: `job` is this `Guard`'s live, owned handle — it is closed only in `Drop`,
|
|
// which cannot have run while `&self` is borrowed. The exit code is arbitrary; nothing
|
|
// reads it, because a killed tree is reported to callers as the budget's `TimedOut`.
|
|
let _ = unsafe { TerminateJobObject(job, 1) };
|
|
}
|
|
}
|
|
|
|
impl Drop for Guard {
|
|
fn drop(&mut self) {
|
|
let Some(job) = self.0.take() else { return };
|
|
// SAFETY: `job` is the handle created in `attach` and owned solely by this `Guard`;
|
|
// `take` makes this the one and only close. Per the module doc this close is also the
|
|
// backstop kill — with KILL_ON_JOB_CLOSE set, dropping the last handle terminates
|
|
// whatever is still in the job, so no early return can leak the tree.
|
|
let _ = unsafe { CloseHandle(job) };
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The Unix half: `Child::kill` already ends the only process there is (see the Windows module doc
|
|
/// for why that is not true there). Kept as a real type rather than `cfg`ing the call sites, so the
|
|
/// two platforms read as one flow.
|
|
#[cfg(not(windows))]
|
|
mod tree {
|
|
pub(super) struct Guard;
|
|
|
|
impl Guard {
|
|
pub(super) fn attach(_child: &std::process::Child) -> Self {
|
|
Self
|
|
}
|
|
pub(super) fn terminate(&self) {}
|
|
}
|
|
}
|
|
|
|
// `unix` gate, not `test` alone: this module is compiled on every platform (lib.rs declares it
|
|
// unconditionally), but the cases below spawn `sleep`/`true`/`echo` as EXECUTABLES. On Windows
|
|
// `echo` is a shell builtin and there is no `sleep.exe`, so an ungated module turns a green suite
|
|
// red the first time anyone runs it there. The Windows twin is below.
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A helper that never exits must be killed at the budget and reported as `TimedOut` — the
|
|
/// whole point of the module (an unbounded `status()` here is what wedged a whole session).
|
|
#[test]
|
|
fn a_hung_child_is_killed_at_the_budget() {
|
|
let started = Instant::now();
|
|
let err = status_within(Command::new("sleep").arg("30"), Duration::from_millis(150))
|
|
.expect_err("must time out");
|
|
assert_eq!(err.kind(), ErrorKind::TimedOut);
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(5),
|
|
"must return at its budget, not the child's lifetime (took {:?})",
|
|
started.elapsed()
|
|
);
|
|
}
|
|
|
|
/// The normal path is unaffected: a quick command still yields its status and its output.
|
|
#[test]
|
|
fn a_quick_child_returns_normally() {
|
|
let st = status_within(&mut Command::new("true"), Duration::from_secs(5)).expect("ran");
|
|
assert!(st.success());
|
|
|
|
let out = output_within(
|
|
Command::new("echo").arg("punktfunk"),
|
|
Duration::from_secs(5),
|
|
)
|
|
.expect("ran");
|
|
assert!(out.status.success());
|
|
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "punktfunk");
|
|
}
|
|
}
|
|
|
|
/// The `comm`-vs-real-name resolution ([`match_name`]). Linux-only, because the trap it exists for
|
|
/// is a Linux kernel detail (`comm` names the executed FILE, truncated to 15 bytes) crossed with a
|
|
/// nixpkgs packaging convention.
|
|
///
|
|
/// Driven against **fixture** `/proc/<pid>` directories rather than spawned processes, for the same
|
|
/// reason the `/proc` matcher in `punktfunk-host` learned the hard way: a stand-in has to be a real
|
|
/// ELF that tolerates being *renamed*, and `/bin/sleep` is not one. Modern coreutils (uutils on
|
|
/// Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary — copied to `.kwin_wayland-wrapped` it
|
|
/// prints "unknown program" and exits before `/proc` can be read, and restoring `argv[0]` does not
|
|
/// save it. That reads exactly like this resolver being broken. The truncation the fixtures encode
|
|
/// is not guessed: the strings below were measured from a live kernel (`.kwin_wayland-w`,
|
|
/// `.kwin_wayland_w`, `.gamescope-wrap` — all 15 bytes) against binaries installed and exec'd the
|
|
/// way nixpkgs does it.
|
|
#[cfg(all(test, target_os = "linux"))]
|
|
mod name_tests {
|
|
use super::*;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// A fake `/proc/<pid>` directory: a `comm` file and, optionally, the `exe` symlink and a
|
|
/// `cmdline`. Removed on drop.
|
|
///
|
|
/// An absent `exe` stands in for **both** ways the real link yields nothing: a process exiting
|
|
/// under the scan, and — the case that matters here — a capability-holding one, whose link the
|
|
/// kernel refuses with EACCES. `match_name` cannot tell those apart and does not need to.
|
|
struct FakePid {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl FakePid {
|
|
/// `comm` is written exactly as the kernel would report it — i.e. already truncated.
|
|
fn new(tag: &str, comm: &str, exe: Option<&str>) -> FakePid {
|
|
FakePid::with_cmdline(tag, comm, exe, None)
|
|
}
|
|
|
|
/// `cmdline` is the NUL-separated argument vector the kernel exposes; the fixture is given
|
|
/// just `argv[0]` and appends the terminator, as a real one carries.
|
|
fn with_cmdline(tag: &str, comm: &str, exe: Option<&str>, argv0: Option<&str>) -> FakePid {
|
|
let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("fixture dir");
|
|
std::fs::write(dir.join("comm"), format!("{comm}\n")).expect("comm");
|
|
if let Some(exe) = exe {
|
|
// The target need not exist: `read_link` reports the link's contents, and a real
|
|
// `/proc/<pid>/exe` routinely points at a path that has since been replaced.
|
|
std::os::unix::fs::symlink(
|
|
format!("/nix/store/eeee-kwin-6.5.0/bin/{exe}"),
|
|
dir.join("exe"),
|
|
)
|
|
.expect("exe symlink");
|
|
}
|
|
if let Some(argv0) = argv0 {
|
|
std::fs::write(dir.join("cmdline"), format!("{argv0}\0--session\0")).expect("cmd");
|
|
}
|
|
FakePid { dir }
|
|
}
|
|
fn path(&self) -> &Path {
|
|
&self.dir
|
|
}
|
|
}
|
|
|
|
impl Drop for FakePid {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
|
|
/// The decoration table. The `kwin_wayland_wrapper` rows are the ones that earn their keep: it
|
|
/// is a REAL KWin binary (the session's parent process), so the rule must leave it under its own
|
|
/// name in both its plain and its wrapped form rather than collapsing either into
|
|
/// `kwin_wayland` and handing the session probe the wrong PID.
|
|
#[test]
|
|
fn undecorate_strips_only_a_real_nixpkgs_wrapper() {
|
|
for (raw, want) in [
|
|
(".kwin_wayland-wrapped", "kwin_wayland"),
|
|
(".gamescope-wrapped", "gamescope"),
|
|
(".gnome-shell-wrapped", "gnome-shell"),
|
|
(".Hyprland-wrapped", "Hyprland"),
|
|
// make-wrapper appends `_`s when the hidden name is already taken (a Qt + GApps
|
|
// double-wrap), so the underscores come off before the suffix does.
|
|
(".kwin_wayland-wrapped_", "kwin_wayland"),
|
|
(".kwin_wayland-wrapped__", "kwin_wayland"),
|
|
// Not decoration — every one of these keeps its exact name.
|
|
("kwin_wayland", "kwin_wayland"),
|
|
("kwin_wayland_wrapper", "kwin_wayland_wrapper"),
|
|
(".kwin_wayland_wrapper-wrapped", "kwin_wayland_wrapper"),
|
|
("foo-wrapped", "foo-wrapped"),
|
|
(".hidden", ".hidden"),
|
|
(".-wrapped", ".-wrapped"),
|
|
] {
|
|
assert_eq!(undecorate(raw), want, "undecorate({raw:?})");
|
|
}
|
|
}
|
|
|
|
/// The whole bug. Every compositor the session probe matches on is wrapped by nixpkgs, so the
|
|
/// kernel reports a truncated, decorated `comm` that can never equal the name being compared —
|
|
/// which is why `detect_active_session` answered `ActiveKind::None` on a *running* KDE desktop
|
|
/// and every connect died "no usable compositor".
|
|
#[test]
|
|
fn a_nixpkgs_wrapped_compositor_resolves_to_its_real_name() {
|
|
for (tag, comm, exe, want) in [
|
|
(
|
|
"kwin",
|
|
".kwin_wayland-w",
|
|
".kwin_wayland-wrapped",
|
|
"kwin_wayland",
|
|
),
|
|
(
|
|
"gamescope",
|
|
".gamescope-wrap",
|
|
".gamescope-wrapped",
|
|
"gamescope",
|
|
),
|
|
(
|
|
"gnome",
|
|
".gnome-shell-wr",
|
|
".gnome-shell-wrapped",
|
|
"gnome-shell",
|
|
),
|
|
("hypr", ".Hyprland-wrapp", ".Hyprland-wrapped", "Hyprland"),
|
|
] {
|
|
let p = FakePid::new(tag, comm, Some(exe));
|
|
assert_eq!(
|
|
match_name(p.path()).as_deref(),
|
|
Some(want),
|
|
"a nixpkgs-wrapped {want} must resolve to the name the session probe matches"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// KWin's own `kwin_wayland_wrapper` is a real binary that runs *alongside* `kwin_wayland`, and
|
|
/// its wrapped `comm` (`.kwin_wayland_w`) differs from the compositor's by a single byte. It
|
|
/// must NOT resolve to `kwin_wayland`: the probe would then match the parent process and carry
|
|
/// its PID as the compositor identity, which drives restart detection.
|
|
#[test]
|
|
fn kwins_own_wrapper_binary_does_not_masquerade_as_the_compositor() {
|
|
let p = FakePid::new(
|
|
"kwrap",
|
|
".kwin_wayland_w",
|
|
Some(".kwin_wayland_wrapper-wrapped"),
|
|
);
|
|
assert_eq!(
|
|
match_name(p.path()).as_deref(),
|
|
Some("kwin_wayland_wrapper")
|
|
);
|
|
}
|
|
|
|
/// The other half of the 15-byte limit, with no nix involved: a long name is truncated too, and
|
|
/// has to be recovered from `exe` rather than matched short.
|
|
#[test]
|
|
fn a_long_name_is_recovered_untruncated() {
|
|
let p = FakePid::new(
|
|
"long",
|
|
"a-very-long-com",
|
|
Some("a-very-long-compositor-name"),
|
|
);
|
|
assert_eq!(
|
|
match_name(p.path()).as_deref(),
|
|
Some("a-very-long-compositor-name")
|
|
);
|
|
}
|
|
|
|
/// The fast path answers without consulting `exe` at all — which is what keeps this probe at one
|
|
/// read per process on every ordinary distro, and what lets it answer for a process whose `exe`
|
|
/// is unreadable in the first place.
|
|
#[test]
|
|
fn an_ordinary_short_name_never_needs_the_exe_link() {
|
|
let p = FakePid::new("plain", "kwin_wayland", None);
|
|
assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland"));
|
|
}
|
|
|
|
/// A decorated-or-truncated name with neither `exe` nor `cmdline` to fall back on (a kernel
|
|
/// thread, or a process exiting under the scan) degrades to the truncated `comm` instead of
|
|
/// failing the whole entry.
|
|
#[test]
|
|
fn an_unreadable_exe_falls_back_to_comm() {
|
|
let p = FakePid::new("noexe", ".kwin_wayland-w", None);
|
|
assert_eq!(match_name(p.path()).as_deref(), Some(".kwin_wayland-w"));
|
|
}
|
|
|
|
/// **The NixOS + KDE field bug in one assertion.** nixpkgs wraps the binary, so `comm` is
|
|
/// `.kwin_wayland-w` and only `exe` carries the real name — and NixOS's own Plasma module hands
|
|
/// KWin `cap_sys_nice+ep` through `security.wrappers`, so the kernel refuses that link to our
|
|
/// uncapped host. Both traps at once is not a hypothetical combination: it is the default
|
|
/// install. `argv[0]` is what survives, because make-wrapper's wrapper `exec -a "$0"`s the
|
|
/// hidden binary.
|
|
#[test]
|
|
fn a_capped_wrapped_compositor_is_identified_by_argv0() {
|
|
for (tag, comm, argv0, want) in [
|
|
// Plasma's own startup execs the wrapper by absolute path.
|
|
(
|
|
"capkwin",
|
|
".kwin_wayland-w",
|
|
"/run/wrappers/bin/kwin_wayland",
|
|
"kwin_wayland",
|
|
),
|
|
// …and a bare name is just as ordinary.
|
|
("capbare", ".kwin_wayland-w", "kwin_wayland", "kwin_wayland"),
|
|
// gamescope carries `cap_sys_nice` on a great many distros, wrapped or not.
|
|
(
|
|
"capgame",
|
|
".gamescope-wrap",
|
|
"/nix/store/aaaa-gamescope/bin/gamescope",
|
|
"gamescope",
|
|
),
|
|
// A wrapper that passes the hidden path through as `argv[0]` still undecorates.
|
|
(
|
|
"capraw",
|
|
".kwin_wayland-w",
|
|
"/nix/store/eeee-kwin/bin/.kwin_wayland-wrapped",
|
|
"kwin_wayland",
|
|
),
|
|
] {
|
|
let p = FakePid::with_cmdline(tag, comm, None, Some(argv0));
|
|
assert_eq!(
|
|
match_name(p.path()).as_deref(),
|
|
Some(want),
|
|
"a capped, wrapped {want} must still be identified from argv[0]"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `exe` outranks `argv[0]` whenever the kernel allows it: `argv[0]` is the process's own claim
|
|
/// about itself and a same-uid process can set it to anything, so it may never override the
|
|
/// kernel's answer — only stand in when there is none.
|
|
#[test]
|
|
fn a_readable_exe_outranks_a_lying_argv0() {
|
|
let p = FakePid::with_cmdline(
|
|
"liar",
|
|
".gamescope-wrap",
|
|
Some(".gamescope-wrapped"),
|
|
Some("kwin_wayland"),
|
|
);
|
|
assert_eq!(match_name(p.path()).as_deref(), Some("gamescope"));
|
|
}
|
|
|
|
/// A zombie's `cmdline` is empty, and `argv[0]` can be an empty string even when it is not —
|
|
/// neither may yield an empty name (which would then be compared against, and could match, a
|
|
/// compositor name only by accident).
|
|
#[test]
|
|
fn an_empty_cmdline_does_not_produce_a_name() {
|
|
for (tag, cmdline) in [("zombie", ""), ("nulls", "\0\0")] {
|
|
let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("fixture dir");
|
|
std::fs::write(dir.join("comm"), ".kwin_wayland-w\n").expect("comm");
|
|
std::fs::write(dir.join("cmdline"), cmdline).expect("cmdline");
|
|
assert_eq!(match_name(&dir).as_deref(), Some(".kwin_wayland-w"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|
|
|
|
/// A pid directory that does not exist yields `None`, not a bogus name — the scans `continue`.
|
|
#[test]
|
|
fn a_vanished_process_yields_none() {
|
|
assert_eq!(match_name(Path::new("/proc/0")), None);
|
|
}
|
|
|
|
/// The one thing a fixture cannot establish: that reading `/proc/<pid>/exe` is actually
|
|
/// *permitted* for a process of our own uid. Checked against the only such process guaranteed
|
|
/// to be running — this one. ⚠ It holds because *we* are uncapped, and says nothing about the
|
|
/// processes being scanned: a capped target refuses this same link, which is what
|
|
/// [`match_name`]'s `argv[0]` rung exists for.
|
|
#[test]
|
|
fn our_own_exe_link_is_readable() {
|
|
let me = Path::new("/proc/self");
|
|
let exe = std::fs::read_link(me.join("exe"))
|
|
.expect("/proc/self/exe must be readable for our own uid");
|
|
let name = exe.file_name().and_then(|n| n.to_str()).expect("exe name");
|
|
let got = match_name(me).expect("our own name");
|
|
// Whichever rung answered, it must agree with the real binary: the fast path returns the
|
|
// (short, undecorated) comm, which is a prefix of it; the exe path returns it outright.
|
|
assert!(
|
|
name.starts_with(got.as_str()) || got == name,
|
|
"resolved {got:?} disagrees with our real binary {name:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The same two cases through `cmd /c`, so the budget logic is covered on the platform whose
|
|
/// process model differs most (job objects, no `SIGKILL`). `ping -n` is the standard Windows
|
|
/// no-extra-tooling sleep.
|
|
#[cfg(all(test, windows))]
|
|
mod tests_windows {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_hung_child_is_killed_at_the_budget() {
|
|
let started = Instant::now();
|
|
let err = status_within(
|
|
Command::new("cmd").args(["/c", "ping -n 60 127.0.0.1 >NUL"]),
|
|
Duration::from_millis(150),
|
|
)
|
|
.expect_err("must time out");
|
|
assert_eq!(err.kind(), ErrorKind::TimedOut);
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(5),
|
|
"must return at its budget, not the child's lifetime (took {:?})",
|
|
started.elapsed()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_quick_child_returns_normally() {
|
|
let st = status_within(
|
|
Command::new("cmd").args(["/c", "exit 0"]),
|
|
Duration::from_secs(5),
|
|
)
|
|
.expect("ran");
|
|
assert!(st.success());
|
|
|
|
let out = output_within(
|
|
Command::new("cmd").args(["/c", "echo punktfunk"]),
|
|
Duration::from_secs(5),
|
|
)
|
|
.expect("ran");
|
|
assert!(out.status.success());
|
|
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "punktfunk");
|
|
}
|
|
|
|
/// A scratch directory for the tree test's marker files, removed on drop. `remove_dir_all` is
|
|
/// deliberately un-asserted: if the tree *did* survive it still has this directory as its
|
|
/// working directory and the removal fails — which is the failure the test itself reports.
|
|
struct Scratch(std::path::PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new() -> Self {
|
|
let dir = std::env::temp_dir().join(format!("pf-vd-proc-tree-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("scratch dir");
|
|
Self(dir)
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// The budget must end the whole tree, not just the process we spawned.
|
|
///
|
|
/// Every Windows helper is reached through a shell, so the process that actually hangs is a
|
|
/// grandchild that `Child::kill` cannot see. Left alive it keeps our stdio handles and our
|
|
/// working directory — which is how a green test run still failed its CI job before the job
|
|
/// object went in (the orphan held the build step's pipe open past the runner's `WaitDelay`
|
|
/// and pinned the crate directory against cleanup).
|
|
#[test]
|
|
fn the_budget_ends_the_whole_tree_not_just_the_child() {
|
|
let scratch = Scratch::new();
|
|
let ran = scratch.0.join("grandchild-ran");
|
|
let survived = scratch.0.join("grandchild-survived");
|
|
let script = scratch.0.join("grandchild.cmd");
|
|
// `%~dp0` — the script's own directory, resolved by cmd at run time — rather than the paths
|
|
// interpolated in. A .cmd file is read in the OEM code page, so an absolute path baked into
|
|
// it is mangled the moment the temp dir contains a non-ASCII character (`C:\Users\Enrico
|
|
// Bühler\…` arrives as `B?hler`) and every redirect in it fails with "path not found".
|
|
std::fs::write(
|
|
&script,
|
|
format!(
|
|
"@echo off\r\n\
|
|
echo up>\"%~dp0{}\"\r\n\
|
|
ping -n 4 127.0.0.1 >NUL\r\n\
|
|
echo up>\"%~dp0{}\"\r\n",
|
|
ran.file_name().expect("marker name").to_string_lossy(),
|
|
survived.file_name().expect("marker name").to_string_lossy(),
|
|
),
|
|
)
|
|
.expect("write the grandchild script");
|
|
|
|
// `cmd /c cmd /c <script>`: the OUTER cmd is our child, the inner one is the grandchild
|
|
// that `Child::kill` alone would leave running.
|
|
let err = status_within(
|
|
Command::new("cmd").args(["/c", "cmd", "/c", &script.display().to_string()]),
|
|
Duration::from_millis(2500),
|
|
)
|
|
.expect_err("must time out");
|
|
assert_eq!(err.kind(), ErrorKind::TimedOut);
|
|
|
|
// Without this the test could pass vacuously — a grandchild killed before it ever started
|
|
// proves nothing about killing trees.
|
|
assert!(
|
|
ran.exists(),
|
|
"the grandchild never got as far as its first marker, so this run proves nothing"
|
|
);
|
|
|
|
// Outlast the grandchild's own sleep: a surviving tree writes the second marker.
|
|
std::thread::sleep(Duration::from_secs(5));
|
|
assert!(
|
|
!survived.exists(),
|
|
"a grandchild outlived the budget — the shell was killed but not the tree under it"
|
|
);
|
|
}
|
|
}
|