0.26.0-1 gave the host CAP_SYS_NICE, which made it invisible to KWin — every KDE desktop session died, on five packaging channels #136

Merged
enricobuehler merged 2 commits from worktree-kwin-capability-identification into main 2026-08-09 08:04:45 +00:00
Owner

Field reports on 0.26.0-1, CachyOS on NVIDIA and AMD, and Bazzite — surviving a clean reinstall of host and client:

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

Worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.

Cause

0.26.0-1 granted the host CAP_SYS_NICE for the PyroWave GPU-priority lever. That capability and the KWin desktop backend are mutually exclusive at the kernel level.

KWin advertises its restricted protocols — zkde_screencast_unstable_v1 (our virtual output) and org_kde_kwin_fake_input (input injection) — only to a client it can identify:

  1. KWinDisplay::allowInterface()fetchRequestedInterfaces(client->executablePath()) (Plasma/6.5 src/wayland_server.cpp)
  2. executablePath() is QFileInfo("/proc/<pid>/exe").symLinkTarget() (src/utils/executable_path_proc.cpp)
  3. the result is matched against an installed .desktop's Exec= (src/utils/serviceutils.h), empty list on no match

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 a capability-carrying host is unidentifiable: executablePath() empty → nothing matches → globals never advertised → 8 retries → dead session. It presents exactly as a missing .desktop, which is why reinstalling does nothing.

KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 bypasses precisely this check and nothing else, which is why it "fixed" it in the field.

Why the obvious fixes don't work

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

target process readlink /proc/<pid>/exe
no capability OK
capability EPERM
capability + prctl(PR_SET_DUMPABLE, 1) EPERM — dumpable is not the gate
capability dropped + PR_SET_DUMPABLE(1) OK — needs both

Row 3 is also the systemd AmbientCapabilities= case (CapPrm set, dumpable 1), so moving the grant to the unit file fails identically. Only an uncapped process is identifiable.

It was in five channels, not one

channel how it granted reach
packaging/arch/punktfunk-host.install setcap in post_install/post_upgrade Arch, CachyOS
packaging/rpm/punktfunk.spec %caps(cap_sys_nice=ep) in %files Fedora — and Bazzite via rpm-ostree layering
packaging/bazzite/build-sysext.sh setcap on the staging tree Bazzite sysext
packaging/debian/build-deb.sh setcap in postinst Debian/Ubuntu
packaging/nix/nixos-module.nix security.wrappers + capabilities= NixOS
scripts/steamdeck/install.sh setcap "$BIN" Steam Deck — voided the .desktop written six lines earlier

Bazzite is this bug, not a separate one. Verified by mounting the published punktfunk-0.26.0-1-x86-64.raw: getcap usr/bin/punktfunk-hostcap_sys_nice=ep, stored as security.capability in the squashfs. The comment in packaging/arch/build-sysext.sh claiming "file capabilities don't survive this squashfs path" is false — mksquashfs records them — and is corrected here.

NixOS does not dodge it either. A security.wrappers entry raises the capability into its ambient set before exec'ing the store binary, precisely so it survives — landing CAP_SYS_NICE in the exec'd process's permitted set. ExecStart now points at the store path directly, which is also the path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.

What this costs

Pacing only. pf-zerocopy already walks REALTIME → HIGH → default when a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op. This is exactly 0.25.0's behaviour — the behaviour that worked.

Changes

  • All six grant sites removed. Arch/deb/Deck actively setcap -r so boxes that already ran 0.26.0-1's scriptlet get healed; rpm applies caps from package metadata, so a package built without %caps() clears it on upgrade.
  • The sysext build now hard-fails if a capability is staged. A merged sysext's /usr is read-only squashfs — a bad image cannot be repaired on the box, and the spec was one %caps() away from baking one in again.
  • pf-vdisplay kwin.rs: all three "does not expose zkde_screencast" errors now read /proc/self/status and, if the process holds any capability, name it with its CapPrm mask and the setcap -r that repairs it. This failure is undiagnosable from the Wayland side, and a hand-rolled setcap reproduces it independently of our packaging.
  • Docs corrected — they advertised the capability as a feature: running-as-a-service §"GPU scheduling priority" rewritten, configuration.md's PYROWAVE_QUEUE_PRIORITY row, packaging/bazzite/README.md §6.5 (which still described kde-desktop-setup.sh writing the env var it now removes), packaging/arch/README.md, and the v0.26.0 CHANGELOG entry annotated with the correction rather than rewritten.

Compositor audit

Measured blast radius of holding a capability (same-uid reader): /proc/PID/exe EPERM, /proc/PID/root/* EPERM, /proc/PID/environ EPERM; cgroup, status, cmdline still readable.

  • KWin — broken outright. Proven, field-confirmed.
  • gamescope — no identity gate, unaffected. Matches the field: only Desktop mode was ever reported.
  • Mutter — drives Mutter's own D-Bus API, not the portal. Looks unaffected.
  • wlroots / Hyprland — go through the ScreenCast portal, whose app-id resolution reads /proc/<pid>/root/.flatpak-info, a path the capability blocks. A real exposure, not reproduced end to end — flagging honestly rather than claiming it.

Verification

On 192.168.1.21 (CachyOS 7.1.6):

  • the capability/dumpable matrix above, and the /proc read matrix
  • cargo check -p pf-vdisplay --all-targets and cargo clippy -p pf-vdisplay --all-targets -- -D warnings clean
  • 2 new unit tests pass; the diagnostic hint exercised end-to-end — silent uncapped, firing with CapPrm=0x0000000000800000 under cap_sys_nice=ep
  • shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst unpacked, .INSTALL confirmed to carry the setcap on both post_install and post_upgrade
  • published punktfunk-0.26.0-1-x86-64.raw mounted, capability read out of the squashfs
  • bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and packages.nix
  • getcap on an uncapped file exits 0 with empty output, so the new sysext assertion cannot false-positive

Shipping

Ships as 0.26.0-2 — packaging, docs, one crate; no version bump. Same pkgrel-repair shape as the FFmpeg soname break.

⚠ Two things worth deciding:

  • Bazzite users on 0.26.0-1 cannot be repaired in place (read-only /usr) — they need a rebuilt image, so the sysext feed has to be republished, not just the packages.
  • I'd hold the 0.26.0 announcement until -2 is out, since -1 bricks desktop streaming on every KDE box.

Merging is yours to click.

Field reports on 0.26.0-1, CachyOS on NVIDIA **and** AMD, and Bazzite — surviving a clean reinstall of host and client: ``` KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client ``` Worked around only by `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. ## Cause 0.26.0-1 granted the host `CAP_SYS_NICE` for the PyroWave GPU-priority lever. **That capability and the KWin desktop backend are mutually exclusive at the kernel level.** KWin advertises its restricted protocols — `zkde_screencast_unstable_v1` (our virtual output) and `org_kde_kwin_fake_input` (input injection) — only to a client it can *identify*: 1. `KWinDisplay::allowInterface()` → `fetchRequestedInterfaces(client->executablePath())` (Plasma/6.5 `src/wayland_server.cpp`) 2. `executablePath()` is `QFileInfo("/proc/<pid>/exe").symLinkTarget()` (`src/utils/executable_path_proc.cpp`) 3. the result is matched against an installed `.desktop`'s `Exec=` (`src/utils/serviceutils.h`), empty list on no match 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 a capability-carrying host is unidentifiable: `executablePath()` empty → nothing matches → globals never advertised → 8 retries → dead session. It presents exactly as a missing `.desktop`, which is why reinstalling does nothing. `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` bypasses precisely this check and nothing else, which is why it "fixed" it in the field. ## Why the obvious fixes don't work Measured on CachyOS kernel 7.1.6, same-uid reader, `cap_sys_nice=ep` on the target: | target process | `readlink /proc/<pid>/exe` | |---|---| | no capability | ✅ OK | | capability | ❌ EPERM | | capability + `prctl(PR_SET_DUMPABLE, 1)` | ❌ EPERM — **dumpable is not the gate** | | capability dropped + `PR_SET_DUMPABLE(1)` | ✅ OK — needs **both** | Row 3 is also the systemd `AmbientCapabilities=` case (CapPrm set, dumpable 1), so moving the grant to the unit file fails identically. Only an uncapped process is identifiable. ## It was in five channels, not one | channel | how it granted | reach | |---|---|---| | `packaging/arch/punktfunk-host.install` | `setcap` in post_install/post_upgrade | Arch, CachyOS | | `packaging/rpm/punktfunk.spec` | `%caps(cap_sys_nice=ep)` in `%files` | Fedora — **and Bazzite** via rpm-ostree layering | | `packaging/bazzite/build-sysext.sh` | `setcap` on the staging tree | Bazzite sysext | | `packaging/debian/build-deb.sh` | `setcap` in postinst | Debian/Ubuntu | | `packaging/nix/nixos-module.nix` | `security.wrappers` + `capabilities=` | NixOS | | `scripts/steamdeck/install.sh` | `setcap "$BIN"` | Steam Deck — voided the `.desktop` written six lines earlier | **Bazzite is this bug, not a separate one.** Verified by mounting the published `punktfunk-0.26.0-1-x86-64.raw`: `getcap usr/bin/punktfunk-host` → `cap_sys_nice=ep`, stored as `security.capability` in the squashfs. The comment in `packaging/arch/build-sysext.sh` claiming *"file capabilities don't survive this squashfs path"* is false — mksquashfs records them — and is corrected here. **NixOS does not dodge it either.** A `security.wrappers` entry raises the capability into its *ambient* set before exec'ing the store binary, precisely so it survives — landing `CAP_SYS_NICE` in the exec'd process's permitted set. `ExecStart` now points at the store path directly, which is also the path `packages.nix` substitutes into the `.desktop`'s `Exec=`, so the two finally agree. ## What this costs Pacing only. `pf-zerocopy` already walks REALTIME → HIGH → default when a priority class is refused, and `pf-frame`'s thread nice is a documented best-effort no-op. This is exactly 0.25.0's behaviour — the behaviour that worked. ## Changes - **All six grant sites removed.** Arch/deb/Deck actively `setcap -r` so boxes that already ran 0.26.0-1's scriptlet get healed; rpm applies caps from package metadata, so a package built without `%caps()` clears it on upgrade. - **The sysext build now hard-fails** if a capability is staged. A merged sysext's `/usr` is read-only squashfs — a bad image cannot be repaired on the box, and the spec was one `%caps()` away from baking one in again. - **`pf-vdisplay` kwin.rs**: all three "does not expose zkde_screencast" errors now read `/proc/self/status` and, if the process holds any capability, name it with its `CapPrm` mask and the `setcap -r` that repairs it. This failure is undiagnosable from the Wayland side, and a hand-rolled `setcap` reproduces it independently of our packaging. - **Docs corrected** — they advertised the capability as a feature: `running-as-a-service` §"GPU scheduling priority" rewritten, `configuration.md`'s `PYROWAVE_QUEUE_PRIORITY` row, `packaging/bazzite/README.md` §6.5 (which still described `kde-desktop-setup.sh` *writing* the env var it now *removes*), `packaging/arch/README.md`, and the v0.26.0 CHANGELOG entry annotated with the correction rather than rewritten. ## Compositor audit Measured blast radius of holding a capability (same-uid reader): `/proc/PID/exe` **EPERM**, `/proc/PID/root/*` **EPERM**, `/proc/PID/environ` **EPERM**; `cgroup`, `status`, `cmdline` still readable. - **KWin** — broken outright. Proven, field-confirmed. - **gamescope** — no identity gate, unaffected. Matches the field: only Desktop mode was ever reported. - **Mutter** — drives Mutter's own D-Bus API, not the portal. Looks unaffected. - **wlroots / Hyprland** — go through the ScreenCast portal, whose app-id resolution reads `/proc/<pid>/root/.flatpak-info`, a path the capability blocks. A real exposure, **not** reproduced end to end — flagging honestly rather than claiming it. ## Verification On 192.168.1.21 (CachyOS 7.1.6): - the capability/dumpable matrix above, and the `/proc` read matrix - `cargo check -p pf-vdisplay --all-targets` and `cargo clippy -p pf-vdisplay --all-targets -- -D warnings` clean - 2 new unit tests pass; the diagnostic hint exercised end-to-end — silent uncapped, firing with `CapPrm=0x0000000000800000` under `cap_sys_nice=ep` - shipped `punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst` unpacked, `.INSTALL` confirmed to carry the setcap on both `post_install` and `post_upgrade` - published `punktfunk-0.26.0-1-x86-64.raw` mounted, capability read out of the squashfs - `bash -n` on all five changed shell files; `nix-instantiate --parse` on `nixos-module.nix` and `packages.nix` - `getcap` on an uncapped file exits 0 with empty output, so the new sysext assertion cannot false-positive ## Shipping Ships as **0.26.0-2** — packaging, docs, one crate; no version bump. Same pkgrel-repair shape as the FFmpeg soname break. ⚠ Two things worth deciding: - **Bazzite users on 0.26.0-1 cannot be repaired in place** (read-only `/usr`) — they need a rebuilt image, so the sysext feed has to be republished, not just the packages. - I'd hold the 0.26.0 announcement until `-2` is out, since `-1` bricks desktop streaming on every KDE box. Merging is yours to click.
enricobuehler added 2 commits 2026-08-09 07:58:12 +00:00
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.
fix(packaging): the same CAP_SYS_NICE broke KDE on FIVE channels, not one — Bazzite included
ci / bun-nix (pull_request) Successful in 17s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m31s
ci / rust (pull_request) Failing after 9m2s
nix / flake (pull_request) Successful in 12m24s
4d383811c0
The Arch fix in the previous commit was incomplete. 0.26.0-1 granted the host CAP_SYS_NICE through
every Linux channel we ship, and each one breaks KWin identification the same way:

  * packaging/rpm/punktfunk.spec .......... %caps(cap_sys_nice=ep) in %files  <- Fedora AND Bazzite
                                            via rpm-ostree layering
  * packaging/bazzite/build-sysext.sh ..... setcap on the staging tree, recorded by mksquashfs
  * packaging/debian/build-deb.sh ......... setcap in the postinst
  * packaging/nix/nixos-module.nix ........ security.wrappers with capabilities = "cap_sys_nice=ep"
  * scripts/steamdeck/install.sh .......... setcap on $BIN, six lines after writing the .desktop
                                            whose Exec= it thereby voids

Bazzite was NOT a separate fault, as first reported here — it is this one. Verified by mounting the
published punktfunk-0.26.0-1-x86-64.raw: `getcap usr/bin/punktfunk-host` reports cap_sys_nice=ep,
stored as security.capability in the squashfs. The claim in packaging/arch/build-sysext.sh that
"file capabilities don't survive this squashfs path" is false and is corrected here; mksquashfs
records them, which is exactly why the image shipped one.

NixOS deserves its own note: a security.wrappers entry does not dodge the problem. The wrapper
raises the capability into its AMBIENT set before exec'ing the store binary, precisely so it
survives — which lands CAP_SYS_NICE in the exec'd process's permitted set and fails the readlink
identically to a file capability. ExecStart now points at the store path directly, which is also the
path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.

Measured blast radius of holding a capability, same-uid reader, CachyOS kernel 7.1.6:

    /proc/PID/exe ....... EPERM   <- KWin's identification. Desktop sessions die.
    /proc/PID/root/* .... EPERM   <- xdg-desktop-portal reads .flatpak-info here to resolve an
                                     app id; the wlroots and Hyprland backends go through it
    /proc/PID/environ ... EPERM
    /proc/PID/cgroup .... OK
    /proc/PID/status .... OK
    /proc/PID/cmdline ... OK

Compositor backends, by exposure: KWin is broken outright (proven, field-confirmed). gamescope has
no identity gate and was never affected, which matches the field — only Desktop mode was reported.
Mutter drives Mutter's own D-Bus API, not the portal, and looks unaffected. wlroots and Hyprland go
through the ScreenCast portal, whose app-id resolution reads a path the capability blocks — a real
exposure, not something I reproduced end to end.

The sysext build now HARD-FAILS if a capability is staged, rather than trusting that the RPM payload
never carries one: a merged sysext's /usr is read-only squashfs, so a bad image cannot be repaired
on the box, and the spec was one %caps() away from baking one in again.

Docs corrected, because they advertised the capability as a feature:
  * docs-site running-as-a-service "GPU scheduling priority" — rewritten: the host carries no
    capability, why it must not, and how to clear a 0.26.0-1 install (Bazzite needs a new image)
  * docs-site configuration.md — the PYROWAVE_QUEUE_PRIORITY row no longer claims the packages grant it
  * packaging/bazzite/README.md — §6.5 still described the kde-desktop-setup.sh behaviour from
    before it stopped writing KWIN_WAYLAND_NO_PERMISSION_CHECKS and started REMOVING it; plus a
    note that 0.26.0-1 Desktop mode cannot be repaired in place
  * packaging/arch/README.md — the false "capabilities don't survive the sysext" line
  * CHANGELOG v0.26.0 PW1 — annotated with the 0.26.0-2 correction rather than rewritten, and the
    owed PyroWave-under-load A/B now says it needs a gamescope-only box

Verified: bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and
packages.nix; the published 0.26.0-1 sysext mounted and its capability read; getcap on an uncapped
file exits 0 with empty output, so the new build assertion cannot false-positive.
enricobuehler merged commit 651a7a82a1 into main 2026-08-09 08:04:45 +00:00
enricobuehler deleted branch worktree-kwin-capability-identification 2026-08-09 08:04:48 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#136