docs: the capability moves to the encode worker, and the host must never be given one

Rewrites the "GPU scheduling priority" section around the split: punktfunk-encode-worker carries
cap_sys_nice=ep, punktfunk-host carries nothing on any channel, ever. The KWin identification
mechanism is spelled out in plain words and the failure line is quoted verbatim
("KWin does not expose zkde_screencast_unstable_v1 to this client") so someone searching for their
symptom lands on the explanation.

The warning names all three ways an operator would reach for the capability — hand setcap, a systemd
AmbientCapabilities= line, a NixOS security.wrappers entry — because all three put it in the same
permitted set and all three cost KDE desktop streaming. That is the failure mode that made this
worth documenting: it looks exactly like a missing .desktop and survives reinstalling both ends.

configuration.md gains PUNKTFUNK_ENCODE_WORKER (path, or `off` to force the in-process encoder) and
re-describes PYROWAVE_QUEUE_PRIORITY as an intent forwarded to whichever process does the encode.
kde.md gains one line on the troubleshooting bullet someone actually lands on: getcap on the host
must print nothing.

The published 0.26.0 notes are deliberately untouched — they are the record of what shipped. The
flipped phrasing lives in v0.27.0's notes instead; v0.26.0.md:37 ("a system privilege that turns out
to stop KDE recognising the host at all") is the line that goes stale when this ships.
This commit is contained in:
2026-08-09 12:51:01 +02:00
parent 4f8cce6751
commit f26d21125d
5 changed files with 155 additions and 24 deletions
+78
View File
@@ -12,6 +12,84 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.27.0 — in development
Sections accumulate here as work lands. The version table and the commit count are written at the
version bump, alongside `docs/releases/v0.27.0.md` — see `docs/releases/README.md`.
### `punktfunk-encode-worker` — the GPU-priority capability moves off the host
0.26.0 left the PyroWave priority ladder wired and inert: it needs `CAP_SYS_NICE`, and 0.26.0-1
proved the host can never hold one — see **PyroWave on Linux — Wave 2**, PW1, under v0.26.0 below. A
capability-carrying process cannot be identified by KWin (`cap_ptrace_access_check` refuses
`/proc/<pid>/exe` to a reader whose effective set is not a superset of the target's **permitted**
set), so it never gets `zkde_screencast_unstable_v1` and every KDE desktop session dies. Neither
`prctl(PR_SET_DUMPABLE, 1)` nor systemd `AmbientCapabilities=` nor a NixOS `security.wrappers` entry
changes that — all three land the capability in the same permitted set.
The capability therefore moves to a process that fronts nothing. **`punktfunk-encode-worker`** is a
new workspace member and a new installed binary: it owns the priority-elevated Vulkan device for
PyroWave sessions, receives capture dmabufs over a `SOCK_SEQPACKET` pair from its parent, and returns
compressed access units. It connects to no compositor, no D-Bus and no network, so its
non-dumpability costs nothing and its blast radius is one socket to the host that spawned it.
🛑 **The invariant, for anyone packaging this:** the worker is a **separate file**. Never a hardlink
to `punktfunk-host` and never a subcommand of it — a shared inode shares the file capability, which
silently re-creates 0.26.0-1 on every KDE box. `punktfunk-host` carries no capability, on any
channel, ever.
- **The grants are re-targeted, not re-introduced.** Every channel that granted in 0.26.0-1 grants
again, at the worker: Arch `.install` (`post_install` **and** `post_upgrade` — a replaced binary is
a new inode), RPM `%caps(cap_sys_nice=ep)` in `%files` (never a `%post setcap`; this covers Fedora
and Bazzite layering), the Bazzite sysext staging tree pre-`mksquashfs` (which does record
`security.capability`), the deb `postinst`, the Deck installer, and NixOS
`security.wrappers.punktfunk-encode-worker`. Every #136 host-side removal stays verbatim, including
the sysext's host hard-fail.
- **The sysext assertion is amended, not removed** — host must be empty (hard fail), worker must
carry **exactly** `cap_sys_nice=ep`. A *missing* worker capability is not an error: the grant is
best-effort everywhere.
- **A new release-CI leg asserts the getcap matrix** on the built Arch package, the deb and the
mounted sysext raw. The 0.26.0-1 lesson was "verify the package, never the board"; this is that,
mechanized, and it is what would have caught the original break.
- **On NixOS the env override is load-bearing**, not a convenience: a file capability cannot live on
a read-only store path, so the module wraps the worker and sets `PUNKTFUNK_ENCODE_WORKER` to the
wrapper path in the unit. An ambient grant is fine *here* — the worker is not a KWin client. The
host's `ExecStart` stays on the plain store path (the #136 fix stands).
**Fallback ladder — no rung can kill a negotiated session.** Binary not found → spawn failure →
handshake timeout → protocol or workspace-version mismatch → socket EOF mid-session all fall back to
the **in-process encoder exactly as today**, at default priority, with one warning. Host and worker
are different files now, so the version check is load-bearing rather than decorative; they ship
lockstep in every channel. The in-process path stays compiled and tested — it is the floor, not dead
code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **explicitly** in the
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
still means one thing on both platforms.
### Host and client environment variables
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
order: this variable → alongside `/proc/self/exe``PATH`. `off` forces the in-process encoder,
the debug escape hatch that makes the A/B a one-line change. Load-bearing on NixOS (above).
- **`PYROWAVE_QUEUE_PRIORITY`** *(unchanged grammar, new consumer)* — the *intent*, forwarded to the
worker; the granted class comes back in the handshake and the host logs it centrally, so the
in-process INERT warning does not double-fire. When the worker is uncapped as well — an operator
stripped it, or the filesystem cannot store the capability — the same INERT wording fires, now
naming the worker binary rather than the host.
### Documentation
- `docs-site` **Running as a service → GPU scheduling priority** rewritten around the split: the
worker carries the capability, the host never does, and `setcap` on `punktfunk-host` is called out
as the thing an operator must never do, with the `zkde_screencast_unstable_v1` symptom spelled out
so anyone who already did it can self-diagnose. The anchor is unchanged, so existing links hold.
- `configuration.md` gains the `PUNKTFUNK_ENCODE_WORKER` row and rewrites `PYROWAVE_QUEUE_PRIORITY`
off "the packages deliberately do not grant this".
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
never to the host — lives in `docs/releases/v0.27.0.md`.
---
## v0.26.0
52 commits since v0.25.0.
+2 -1
View File
@@ -241,7 +241,8 @@ notes for context.
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. This needs the `CAP_SYS_NICE` capability, which the packages deliberately do **not** grant: a host holding a capability cannot be identified by KWin and loses desktop streaming entirely (see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority)). The request is therefore refused on a stock install and the host says so once at session start. Set `off` if you see the desktop stutter while streaming. |
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only — the *intent*, forwarded to whichever process does the encode. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. Granting the request needs the `CAP_SYS_NICE` capability, which the Linux packages give to `punktfunk-encode-worker` and **never** to `punktfunk-host` a host holding any capability cannot be identified by KWin and loses desktop streaming entirely. Do not `setcap` the host to "make this work"; see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority). Set `off` if you see the desktop stutter while streaming. |
| `PUNKTFUNK_ENCODE_WORKER` | path · `off` | Where the host looks for `punktfunk-encode-worker`, the small capability-carrying helper that owns the priority-elevated [PyroWave](/docs/pyrowave) encode (previous row). Unset, the host looks beside its own binary and then on `PATH`, which is right for every package — set it only when the worker lives somewhere unusual. **NixOS needs it and the module sets it for you:** a file capability cannot live on a read-only nix store path, so the worker is exposed through `security.wrappers` and this points the host at that wrapper. `off` forces the encode back into the host process at default priority — a debug escape hatch, not a tuning knob. Every failure short of that is already handled: a missing binary, a worker that will not start, or one that dies mid-session falls back to encoding in-process with one line in the log, and never drops the session. |
## Diagnostics
+4
View File
@@ -140,6 +140,10 @@ The session unit brings up headless KWin; the host unit follows it and starts li
inside the session. It exits 0 only when KWin is up *and* advertising the grant to the host, and
prints the reason when it isn't — after a fresh install that is usually just the missing log out
and back in. On the headless appliance session, also check `kwin_wayland --version` is ≥ 6.5.6.
The other cause has nothing to do with the `.desktop` file: `getcap /usr/bin/punktfunk-host` must
print **nothing at all**. A host binary carrying a Linux capability cannot be identified by KWin
and is never offered the protocol, however correctly its grant is installed — see
[GPU scheduling priority](/docs/running-as-a-service#gpu-scheduling-priority).
- **Black screen / no picture:** confirm you're on a Wayland session (not X11) and, on NVIDIA, that
the GL userspace is installed. More in [Troubleshooting](/docs/troubleshooting).
+43 -23
View File
@@ -207,41 +207,61 @@ a Windows host, run `punktfunk-host service status` from an elevated prompt on t
## GPU scheduling priority
The host binary carries **no Linux capability**, and on a KDE desktop it must not.
The [PyroWave](/docs/pyrowave) codec encodes on the same GPU shader cores your game is using, so a
demanding game can crowd it out and the stream's frame rate drops with it. The fix is to ask the
driver to schedule the encode ahead of the game, and every driver we tested gates that request on
`CAP_SYS_NICE`. Version 0.26.0-1 granted it for that reason — and it broke desktop streaming on
every KDE box, so 0.26.0-2 takes it away again. The other codecs use a separate video engine on the
GPU and were never affected.
driver to schedule that encode ahead of the game, and every driver we tested gates the request on a
single Linux capability, `CAP_SYS_NICE`. The other codecs use a separate video engine on the GPU and
are unaffected either way.
The two cannot coexist. To hand the host its virtual display, KWin first has to work out *which*
program is asking, which it does by reading the connecting process's `/proc/<pid>/exe` and matching
it against the `.desktop` file the packages install. Linux refuses that read for any process holding
a capability the reader does not also hold — and KWin holds none. So a host with `CAP_SYS_NICE` is a
host KWin cannot identify, and every session fails with:
That capability cannot live on the host, so it lives next to it. Every way of installing a Linux
host — apt, dnf, pacman, the Bazzite sysext, the NixOS module, the Steam Deck script — ships a
second, deliberately small program alongside it, **`punktfunk-encode-worker`**, and grants
`cap_sys_nice=ep` to *that*. The host starts one per PyroWave session, hands it the captured frames,
and takes the compressed video back; the worker talks to nothing else, not your desktop and not the
network.
`punktfunk-host` itself carries **no capability, on any channel** — the posture it has had since
0.25.0, and the one KDE needs.
> **Never `setcap` `punktfunk-host`.** Not by hand, not through a systemd `AmbientCapabilities=`
> line, not through a NixOS `security.wrappers` entry. All three put the capability in the same
> place, and all three take KDE desktop streaming away completely. There is no capability the host
> wants: the worker is the thing that needs one, and your packages already gave it one.
Here is why a privileged host is so much worse than a privileged worker. To hand the host its
virtual display, KWin first has to work out *which* program is asking, which it does by reading the
connecting process's `/proc/<pid>/exe` and matching it against the `.desktop` file the packages
install. Linux refuses that read unless the reader already holds every capability the target holds —
and KWin holds none. So a host carrying a capability is a host KWin cannot identify, its restricted
protocols are never offered, and every session fails at capture with:
```
KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client
```
which looks exactly like a missing `.desktop` file and cannot be fixed by reinstalling. Moving the
grant into the systemd unit does not help either — same capability, same refused read.
If you are on 0.26.0-1, update. On the Bazzite image the `/usr` is read-only, so the only repair is
the next image (`sudo punktfunk-sysext update`). Elsewhere you can clear it by hand:
which reads exactly like a missing or mis-installed `.desktop` file and survives reinstalling both
ends. Version 0.26.0-1 granted the host the capability for the reason above and shipped precisely
this, on every Linux channel; 0.26.0-2 revoked it everywhere. If you ever see that error, check the
binaries before anything else — the host's own message names the capability when it finds one:
```sh
getcap /usr/bin/punktfunk-host # prints nothing when correct
sudo setcap -r /usr/bin/punktfunk-host # clear it, then restart the host
getcap /usr/bin/punktfunk-host # correct output is nothing at all
getcap /usr/bin/punktfunk-encode-worker # /usr/bin/punktfunk-encode-worker cap_sys_nice=ep
sudo setcap -r /usr/bin/punktfunk-host # clear it, then restart the host
```
Losing the capability costs frame pacing under a GPU-bound game and nothing else — the host asks for
the elevated priority, is refused, and encodes at the normal one. `PYROWAVE_QUEUE_PRIORITY=off`
stops it asking at all. If you stream only with gamescope (Steam Gaming Mode) you can grant the
capability yourself and keep the pacing, at the cost of desktop streaming; gamescope has no such
identity check.
On the Bazzite image `/usr` is read-only, so there is nothing to repair in place — take the next
image (`sudo punktfunk-sysext update`). On NixOS the worker *is* wrapped, because a file capability
cannot live on a read-only store path: the module creates the wrapper and points the host at it, and
the host's own `ExecStart` stays on the plain store path.
**The grant is best-effort, and no session depends on it.** A worker without the capability still
encodes — it asks for the elevated priority, is refused, says so once, and runs at the normal one.
So does a host that cannot find or start a worker at all: it encodes in-process, logs one line, and
streams. The only thing at stake is frame pacing under a GPU-bound game.
`PYROWAVE_QUEUE_PRIORITY=off` stops the host asking for priority, and `PUNKTFUNK_ENCODE_WORKER=off`
keeps the encode in the host process — both on
[Configuration](/docs/configuration#advanced-performance-tuning).
## Stopping and removing
+28
View File
@@ -0,0 +1,28 @@
<!--
Accumulating ahead of the version bump, per docs/releases/README.md: entries are added as work
lands, and the lead-in, the TL;DR and any "Before you update" section are written at the bump,
when the shape of the whole release is known. The angle-bracket lines below are the TEMPLATE.md
placeholders and must be replaced before the tag is pushed. docs/releases/whatsnew/v0.27.0.txt is
still owed and is a hard gate on the android job.
-->
Wire-compatible with 0.26.x — everything you have already paired keeps working, and you can update one side at a time. <one sentence on how older/newer clients negotiate or fall back, so nobody fears updating.>
<Lead-in: one or two sentences naming the headline change of this release — everything above the first `##` is what the Discord #releases embed shows, so make it read as a standalone summary.>
## TL;DR
- **<The one thing you would tell someone in a sentence.>**
- **<Second. Anything that needs the reader to ACT goes here too, and in `## Before you update`.>**
- **<Third. Three to six bullets, one line each. This is what a reader sees instead of giving up.>**
## Improved
- **The wavelet codec can ask your graphics card for priority again — on every desktop this time.** Under a demanding game it encodes on the same graphics cores the game is using and gets crowded out, and asking the driver to put that work first is worth about a third off the worst frames. It needs a system privilege, and 0.26 could not use it: an interim build gave that privilege to the host, KDE stopped recognising the host as a result, and desktop streaming failed outright — so the privilege was taken away everywhere and the switch sat dormant. The encoding now happens in a small separate program installed alongside the host, and the privilege is granted to **that** program rather than to the host. The host still holds nothing at all, so KDE keeps recognising it, and the priority request works on KDE, GNOME, gamescope and everything else alike. Nothing to switch on. There is also nothing to grant by hand — in particular, do not give the host that privilege yourself, which is still the one thing that takes KDE desktop streaming away. Linux hosts, wavelet-codec sessions only.
- **A host that cannot start that helper simply carries on.** If the program is missing, refuses to start, is a mismatched version, or stops mid-session, the host does the encoding itself exactly as it did before, notes one line in the log, and the stream keeps running. The same is true if the privilege was never granted: the request is refused, the encoding runs at normal priority, and the only cost is frame pacing under a game that is already saturating the graphics card.
## For developers
Protocol, ABI, driver and add-on detail — including the version table and any breaking changes — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.27.0/CHANGELOG.md).
If you package Punktfunk, one thing changes shape: there is a second installed binary, `punktfunk-encode-worker`, and it is the **only** file that may carry `cap_sys_nice=ep`. It must stay a separate file — never a hardlink to `punktfunk-host`, never a subcommand of it — because a shared inode shares the file capability and re-creates the KDE breakage. `punktfunk-host` carries no capability, on any channel, ever.