fix(packaging): the host's CAP_SYS_NICE made it invisible to KWin, killing every KDE session

0.26.0-1 setcap'd `cap_sys_nice=ep` on /usr/bin/punktfunk-host so the encoder could open an
elevated global-priority Vulkan queue. On every KDE box that ended desktop streaming outright:

    KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client

reported from CachyOS on NVIDIA and on AMD, surviving a clean reinstall of host and client, and
worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.

The two cannot coexist. KWin hands out its restricted 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/<pid>/exe and matching it against an installed
.desktop's Exec=. 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 instant the binary carries one, KWin's executablePath() is empty, nothing matches, and the
global is never advertised — presenting exactly as a missing or mis-installed .desktop file.

Measured on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:

    no capability .............................. readlink /proc/<pid>/exe OK
    capability ................................. EPERM
    capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM   <- dumpable is NOT the gate
    capability dropped + PR_SET_DUMPABLE(1) .... OK      <- only an uncapped process works

The third row also rules out the reflex fix of moving the grant to systemd AmbientCapabilities=,
which lands CAP_SYS_NICE in the very same permitted set. Nothing short of not holding the
capability restores identification, so the host does not get one.

The cost is pacing only. pf-zerocopy's device create already walks REALTIME -> HIGH -> default when
a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op without
the capability — so this is 0.25.0's behaviour exactly, which is the behaviour that worked.

  * packaging/arch/punktfunk-host.install: grant -> revoke. post_upgrade strips the capability from
    boxes that already 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.
  * pf-vdisplay kwin.rs: all three "KWin does not expose zkde_screencast" errors now read
    /proc/self/status and, if this process holds ANY capability, name it with its CapPrm mask and
    the `setcap -r` that repairs it. The failure stays impossible to diagnose from the Wayland side
    otherwise, and it is not unique to our own packaging — a hand-rolled setcap does it too.

Verified on 192.168.1.21 (CachyOS): the capability/dumpable matrix above; cargo check and
cargo clippy --all-targets -- -D warnings clean for pf-vdisplay; both new unit tests pass; and the
hint itself exercised end-to-end, silent uncapped and firing with CapPrm=0x0000000000800000 under
cap_sys_nice=ep. The shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst was unpacked to confirm its
.INSTALL carries the setcap on both post_install and post_upgrade.

Ships as 0.26.0-2 — packaging plus one crate, no version bump.
This commit is contained in:
2026-08-09 09:37:30 +02:00
parent 55f361cb92
commit 42ee6c5628
2 changed files with 116 additions and 25 deletions
+81 -5
View File
@@ -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/<pid>/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/<pid>/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/<pid>/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/<pid>/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<u64> {
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()
)
})?;
+35 -20
View File
@@ -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/<pid>/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/<pid>/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