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:
@@ -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()
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user