diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs index 36e06a25..bf7c3d99 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs @@ -13,8 +13,11 @@ //! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship //! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`, //! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first -//! connects. The headless test path instead exposes it to bare clients via -//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement +//! connects. That identification is also why **the host binary must carry no file capability**: a +//! process holding capabilities KWin lacks is one the kernel will not let KWin resolve +//! `/proc//exe` for, so it can never be matched to a `.desktop` no matter how correctly the +//! file is installed (see [`capability_denial_hint`]). The headless test path instead exposes it to +//! bare clients via `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement //! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin //! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with //! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside @@ -1071,6 +1074,74 @@ impl Drop for StopOnDrop { } } +/// Extra sentence appended to every "KWin never advertised the screencast global" error when this +/// process carries capabilities — the one cause that is completely invisible from the Wayland side. +/// +/// KWin authorizes a restricted interface by resolving the *client's* `/proc//exe` and +/// matching it against an installed `.desktop`. The kernel refuses that readlink to any reader +/// whose effective set is not a superset of the target's **permitted** set +/// (`cap_ptrace_access_check`), and KWin has no capabilities at all. So a host binary carrying any +/// file capability is simply unidentifiable: `executablePath()` comes back empty, no `.desktop` can +/// match, and the global is never advertised — indistinguishable, from here, from a missing +/// `.desktop`. Neither half of the obvious workaround helps: `prctl(PR_SET_DUMPABLE, 1)` leaves the +/// permitted-set check failing, and moving the grant to systemd `AmbientCapabilities=` lands the +/// capability in the same permitted set. Only an uncapped binary is identifiable. +/// +/// This is not hypothetical: 0.26.0-1 setcap'd `cap_sys_nice` on the host for the GPU-priority +/// lever and took out desktop streaming on every KDE box until the capability was removed again. +fn capability_denial_hint() -> String { + let permitted = std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|status| permitted_caps_from_status(&status)); + match permitted { + Some(caps) if caps != 0 => format!( + " — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \ + its own to cause this: the kernel then refuses KWin the /proc//exe read it \ + identifies clients by, so no .desktop can match however correctly it is installed. \ + Clear them with `sudo setcap -r /usr/bin/punktfunk-host` and restart the host" + ), + _ => String::new(), + } +} + +/// The permitted-capability mask out of a `/proc//status` body, or `None` if the field is +/// absent/unparseable. The kernel prints it as a tab-separated 16-digit hex word with no `0x` +/// (`CapPrm:\t0000000000800000` = CAP_SYS_NICE), which is what the split-and-radix-16 parse below +/// expects — split out from [`capability_denial_hint`] purely so that shape is testable without a +/// capability-carrying process to point at. +fn permitted_caps_from_status(status: &str) -> Option { + let field = status.lines().find(|l| l.starts_with("CapPrm:"))?; + u64::from_str_radix(field.split_whitespace().nth(1)?, 16).ok() +} + +#[cfg(test)] +mod capability_hint_tests { + use super::*; + + /// Verbatim from a `cap_sys_nice=ep` process on CachyOS — the case that broke 0.26.0-1. + const CAPPED: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000800000\nCapEff:\t0000000000800000\n"; + /// ...and from the same binary with no capability, where the hint must stay silent. + const CLEAN: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\n"; + + #[test] + fn parses_the_kernels_permitted_mask() { + assert_eq!(permitted_caps_from_status(CAPPED), Some(0x0080_0000)); + assert_eq!(permitted_caps_from_status(CLEAN), Some(0)); + // CapPrm is not guaranteed present (older/again-different kernels): stay quiet, never panic. + assert_eq!(permitted_caps_from_status("Name:\tx\n"), None); + assert_eq!(permitted_caps_from_status("CapPrm:\tzzzz\n"), None); + assert_eq!(permitted_caps_from_status("CapPrm:\n"), None); + } + + /// A capability-free host must not append the hint — the message it decorates is also printed + /// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would + /// send the reader chasing a setcap that was never there. The test process has no capabilities. + #[test] + fn silent_without_capabilities() { + assert_eq!(capability_denial_hint(), ""); + } +} + /// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm /// the privileged `zkde_screencast` global is actually advertised. This is exactly what /// [`run`] needs before it can create a virtual output, so a session-bringup script can poll @@ -1090,7 +1161,8 @@ pub fn probe() -> Result<()> { it on the host's .desktop X-KDE-Wayland-Interfaces (install \ io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \ re-reads it — the grant is cached per-exe on first connect), or set \ - KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6" + KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6{}", + capability_denial_hint() ); } Ok(()) @@ -1134,7 +1206,9 @@ fn run_existing( anyhow!( "KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \ .desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \ - KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)" + KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \ + test){}", + capability_denial_hint() ) })?; @@ -1223,7 +1297,9 @@ fn run( anyhow!( "KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \ .desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \ - KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)" + KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \ + test){}", + capability_denial_hint() ) })?; diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index 2774590e..0890f62c 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -12,33 +12,48 @@ _ensure_punktfunk_group() { getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true } -# CAP_SYS_NICE on the host binary — the GPU-scheduling grant. +# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted. # -# WHY: PyroWave encodes on the GPU's shader cores, so a GPU-bound game starves it (measured: the -# encode dispatch goes from ~2 ms to 15-18 ms at 95 % game load). The fix is an elevated -# global-priority Vulkan queue, which the driver gates on CAP_SYS_NICE — measured 2026-08-08 on an -# RTX 5070 Ti: WITHOUT the capability every priority class is refused, WITH it the encoder is -# granted REALTIME on the first attempt. RADV is the same. Without this line the knob exists and -# does nothing. Same capability, same mechanism, as our gamescope package sets on its own binary. +# 0.26.0-1 ran `setcap cap_sys_nice=ep` here, to let the encoder open an elevated global-priority +# Vulkan queue (PyroWave shares the GPU's shader cores with the game; measured 2026-08-08 on an +# RTX 5070 Ti, the encode dispatch goes ~2 ms -> 15-18 ms at 95 % game load without it). That grant +# BROKE DESKTOP STREAMING ON EVERY KDE BOX, and it cannot be made to work — the two are mutually +# exclusive at the kernel level: # -# NARROW: CAP_SYS_NICE only permits raising scheduling priority (nice/ioprio/affinity/RT class). It -# grants no filesystem, network or user-switching privilege, and it is NOT setuid. +# KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1, which mints our +# virtual output, and org_kde_kwin_fake_input, which injects input) only to a client it can +# IDENTIFY, by resolving that client's /proc//exe and matching it against an installed +# .desktop's Exec= (ours is io.unom.Punktfunk.Host.desktop). The kernel refuses that readlink to +# any reader whose effective set is not a superset of the target's PERMITTED set +# (cap_ptrace_access_check), and KWin holds no capabilities. So the moment this binary carries a +# capability it becomes unidentifiable: KWin's executablePath() is empty, nothing matches, the +# globals are never advertised, and every session dies with +# "KWin does not expose zkde_screencast_unstable_v1 to this client" after 8 retries — while +# looking exactly like a missing or wrong .desktop file. # -# TWO CONSEQUENCES worth knowing before you debug something odd on this host: -# * a file capability makes the process AT_SECURE, so the dynamic loader IGNORES LD_LIBRARY_PATH -# and LD_PRELOAD for it. A library-path shim that used to work will silently stop. -# * core dumps are suppressed for capability-carrying binaries by default (fs.suid_dumpable). +# Verified on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target: +# no capability .............................. readlink /proc//exe OK +# capability ................................. EPERM +# capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate +# capability dropped + PR_SET_DUMPABLE(1) .... OK <- only a capability-free process works # -# Never fails the install: a box without libcap, or a filesystem that cannot store capabilities -# (some overlay/NFS setups), just runs at default priority exactly as before. -_grant_sched_capability() { - setcap 'cap_sys_nice=ep' usr/bin/punktfunk-host 2>/dev/null || true +# The third row also rules out the obvious "move it to the systemd unit": AmbientCapabilities= puts +# CAP_SYS_NICE in exactly the same permitted set and fails identically. Nothing short of not having +# the capability restores identification, so the host does not get one. The encoder already walks +# REALTIME -> HIGH -> default when the class is refused (pf-zerocopy vulkan.rs), so this costs +# pacing under a GPU-bound game and nothing else — 0.25.0's behaviour exactly. +# +# The removal below heals boxes that ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode +# and file capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade +# paths — cheap, and the failure it prevents is an 8-retry session death with a misleading message. +_revoke_sched_capability() { + setcap -r usr/bin/punktfunk-host 2>/dev/null || true } post_install() { _ensure_update_group _ensure_punktfunk_group - _grant_sched_capability + _revoke_sched_capability udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true # Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl). @@ -97,8 +112,8 @@ post_upgrade() { # root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so # this is a no-op on boxes that installed fresh. _ensure_punktfunk_group - # A replaced binary is a NEW inode — file capabilities do not survive the upgrade, so re-grant. - _grant_sched_capability + # Strip the cap_sys_nice 0.26.0-1 granted: it makes the host unidentifiable to KWin (see above). + _revoke_sched_capability udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true _warn_stale_firewall_ports