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
Owner

Two fixes from a field session on home-nobara-1 (fc44, canary 0.27.0-0.ci12635.g003ce8be), where every connect produced a black screen. Both are root causes in their own right; the first is what made the second take an hour to find.

1. The takeover misdiagnosed itself, and prescribed two remedies that cannot work

Every connect degraded to ATTACH and 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. Verified on the box: the action is installed, is allow_any, its exec.path annotation matches the installed helper, and 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.

The upstream cause is packaging. The RPM creates the punktfunk group and adds nobody, and its post-install message mentions the group only for "the virtual Steam Deck pad (usbip)" — so a user without a Deck pad correctly skips it and lands here by following the instructions properly. This appears to fail on every RPM install, silently, because the takeover degrades rather than errors.

Now: .output(), with four failure modes that stay distinguishable because they need different fixes — helper not installed, pkexec could not run it, polkit denied it, and the helper ran and refused, whose stderr rides through verbatim. A startup preflight says it before a stream is being built rather than during one, gated on five conditions so it cannot nag a box that would never attempt a takeover. Packaging and docs now lead with Game Mode and record that creating the group is necessary and not sufficient.

Confirmed on glass: joining the group made the takeover succeed on the next connect (freed Steam: stopped the display manager for this stream), first time.

2. On Nobara, our patched gamescope could never run

With the takeover working, the session still came up as stock gamescope:

WARN the session ignored GAMESCOPE_BIN / the PATH shim and ran a stock gamescope —
     HDR and the in-node cursor are now off for this host process
     missing=--pipewire-composite-cursor --pipewire-composite-external-overlay

Because it structurally cannot do otherwise. On the box: grep GAMESCOPE_BIN /usr/share/gamescope-session-plus/gamescope-session-plusno matches; line 244 → GAMESCOPECMD="/usr/bin/gamescope \. An absolute hardcoded path, and the escape hatch is never read. No env var and no PATH entry can win.

So bind over it, inside the session unit's own mount namespace — 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 mechanism exists to deliver are injected by the wrapper, so a bound binary arrives with no flags and the guard refuses exactly as before.

Applied only where the escape hatch is structurally absent, decided by reading the script, never by distro name — SteamOS and Bazzite keep what already works and take no mount namespace they don't need. Availability is proven with a throwaway systemd-run --property=BindReadOnlyPaths=<real value> -- /bin/true, which matters: on .25 that fails 226/NAMESPACE while both relevant sysctls are permissive, because AppArmor blocks it. A sysctl check would have armed a bind that cannot work.

Verification

  • Fix 1 confirmed on glass (takeover succeeded after joining the group).
  • Fix 2 is NOT confirmed on glass. The test box has /usr/bin/gamescope manually symlinked to the patched build, which makes any bind test vacuous — and the new code deliberately no-ops in that state (fork-bomb guard: the wrapper execs the real binary, so shadowing the same path would exec itself forever). The box is being reset to a clean install to test this properly.
  • Gates: cargo fmt --all --check; cargo clippy --workspace --all-targets --locked -- -D warnings on linux/amd64 (non-vacuous — Checking pf-vdisplay); cargo test -p pf-vdisplay 134 passed / 0 failed; scripts/xcheck.sh windows clippy clean; docs-site bun run build clean. No Cargo.toml/Cargo.lock changes.
Two fixes from a field session on `home-nobara-1` (fc44, canary `0.27.0-0.ci12635.g003ce8be`), where **every connect produced a black screen**. Both are root causes in their own right; the first is what made the second take an hour to find. ## 1. The takeover misdiagnosed itself, and prescribed two remedies that cannot work Every connect degraded to ATTACH and 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.** Verified on the box: the action *is* installed, is `allow_any`, its `exec.path` annotation matches the installed helper, and 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. **The upstream cause is packaging.** The RPM *creates* the `punktfunk` group and adds nobody, and its post-install message mentions the group only for "the virtual Steam Deck pad (usbip)" — so a user without a Deck pad correctly skips it and lands here **by following the instructions properly**. This appears to fail on every RPM install, silently, because the takeover degrades rather than errors. Now: `.output()`, with four failure modes that stay distinguishable because they need different fixes — helper not installed, pkexec could not run it, polkit denied it, and *the helper ran and refused*, whose stderr rides through **verbatim**. A startup preflight says it before a stream is being built rather than during one, gated on five conditions so it cannot nag a box that would never attempt a takeover. Packaging and docs now lead with Game Mode and record that creating the group is necessary and **not sufficient**. **Confirmed on glass:** joining the group made the takeover succeed on the next connect (`freed Steam: stopped the display manager for this stream`), first time. ## 2. On Nobara, our patched gamescope could never run With the takeover working, the session still came up as **stock** gamescope: ``` WARN the session ignored GAMESCOPE_BIN / the PATH shim and ran a stock gamescope — HDR and the in-node cursor are now off for this host process missing=--pipewire-composite-cursor --pipewire-composite-external-overlay ``` Because it structurally cannot do otherwise. On the box: `grep GAMESCOPE_BIN /usr/share/gamescope-session-plus/gamescope-session-plus` → **no matches**; line 244 → `GAMESCOPECMD="/usr/bin/gamescope \`. An absolute hardcoded path, and the escape hatch is never read. No env var and no `PATH` entry can win. So bind over it, inside the session unit's own mount namespace — 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 mechanism exists to deliver are injected *by* the wrapper, so a bound binary arrives with no flags and the guard refuses exactly as before. Applied only where the escape hatch is structurally absent, decided by **reading the script**, never by distro name — SteamOS and Bazzite keep what already works and take no mount namespace they don't need. Availability is **proven** with a throwaway `systemd-run --property=BindReadOnlyPaths=<real value> -- /bin/true`, which matters: on `.25` that fails `226/NAMESPACE` while both relevant sysctls are permissive, because AppArmor blocks it. A sysctl check would have armed a bind that cannot work. ## Verification - **Fix 1 confirmed on glass** (takeover succeeded after joining the group). - **Fix 2 is NOT confirmed on glass.** The test box has `/usr/bin/gamescope` manually symlinked to the patched build, which makes any bind test vacuous — and the new code deliberately no-ops in that state (fork-bomb guard: the wrapper execs the real binary, so shadowing the same path would exec itself forever). The box is being reset to a clean install to test this properly. - Gates: `cargo fmt --all --check`; `cargo clippy --workspace --all-targets --locked -- -D warnings` on linux/amd64 (non-vacuous — `Checking pf-vdisplay`); `cargo test -p pf-vdisplay` 134 passed / 0 failed; `scripts/xcheck.sh windows clippy` clean; docs-site `bun run build` clean. No `Cargo.toml`/`Cargo.lock` changes.
enricobuehler added 2 commits 2026-08-09 21:13:13 +00:00
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.
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
647e2b8891
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.
enricobuehler closed this pull request 2026-08-09 21:17:06 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
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#147