The Game Mode takeover blamed polkit for a group it never named — and on Nobara our patched gamescope could never run at all #147

Closed
enricobuehler wants to merge 2 commits from worktree-dm-helper-diagnosis into main
2 Commits
Author SHA1 Message Date
enricobuehler 647e2b8891 fix(pf-vdisplay): bind our wrapper over the hardcoded gamescope path, where GAMESCOPE_BIN cannot reach
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 1m30s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m45s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 11m12s
Nobara's gamescope-session-plus HARDCODES an absolute gamescope path and never reads GAMESCOPE_BIN —
verified on the box: `grep GAMESCOPE_BIN` over the script returns nothing, and line 244 opens
`GAMESCOPECMD="/usr/bin/gamescope \`. So neither the wrapper nor a PATH shim can reach it, and the
managed session comes up as stock gamescope with none of our flags. `verify_managed_spawn_flags`
catches that and refuses HDR and the in-node cursor rather than streaming a session planned around
flags that never arrived — correct, but it leaves every Nobara-family box with no composited cursor
and no HDR, and the operator's only recourse is overwriting a distro-owned binary.

Bind it instead, inside the session unit's own mount namespace: the session gets our gamescope, and
nothing outside the unit changes — the distro still owns the file on disk.

⚠ The bind source is the WRAPPER, not the patched binary. Binding the binary would have been a
vacuous fix: the flags this whole mechanism exists to deliver are injected BY the wrapper, so a bound
binary arrives with no flags and `verify_managed_spawn_flags` refuses exactly as before. Binding the
wrapper reproduces what GAMESCOPE_BIN would have done had the script consulted it.

Applied only where the escape hatch is structurally absent, decided by READING the script rather than
by distro name: if `GAMESCOPE_BIN` appears anywhere in it, we keep the mechanism that already works
and take no mount namespace we don't need. Parsing is pure and unit-tested — the `GAMESCOPECMD+=`
appends and the `[ -z "$GAMESCOPECMD" ]` test must not be mistaken for the opening assignment, or the
bind would land over `$socket`. Three further refusals: the target must be a file; the resolved
source must be absolute and NOT the target (a fork-bomb guard — the wrapper execs the real binary, so
shadowing that same path would exec itself forever); and the preflight must pass.

Availability is PROVEN, not inferred, with a throwaway `systemd-run --user --wait --collect
--property=BindReadOnlyPaths=<the real value> -- /bin/true`. That matters: on .25 the bind fails with
`status=226/NAMESPACE` while `kernel.unprivileged_userns_clone=1` and `max_user_namespaces=29006` are
both permissive — AppArmor's `apparmor_restrict_unprivileged_userns` blocks it. A sysctl check would
have said "available" and armed a bind that cannot work. The payload is /bin/true, so the preflight
can never accidentally start a compositor.

Runtime backstop for anything the preflight cannot see: `ExecMainStatus == 226` disarms the bind and
relaunches plain, latching one-way per process.

The transient host-owned unit takes the setting via `systemd-run --property=`, which is atomic with
the start and leaves nothing behind if the host dies. The box's own unit needs a drop-in, so it gets
`zz-punktfunk-bind.conf` on the TEMPLATE, sorted last so nothing can take the setting back out;
removal targets that one filename only — never the directory, never a glob — because the operator's
`10-headless.conf` next to it is what lets game mode start on that box at all.
2026-08-09 23:11:00 +02:00
enricobuehler 3c096835c2 fix(pf-vdisplay): the takeover blamed polkit for a group it never named, and offered two remedies that cannot work
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:

    the packaged pf-dm-helper polkit action is missing or was denied (reinstall the punktfunk
    package, or install the display-manager polkit rule from the docs)

Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:

    pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
      Grant it with: sudo usermod -aG punktfunk nobara-user   (then re-login)

That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.

Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.

The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.

A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.

Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
2026-08-09 22:35:36 +02:00