The gamescope bind is armed on every gamescope box, takes a user namespace that breaks Xwayland, and leaves a drop-in that bricks the next boot #151

Merged
enricobuehler merged 1 commits from worktree-bind-userns into main 2026-08-09 22:41:41 +00:00
Owner

This fixes a regression that is live in canary now (g13179011 and later). On a hardcoded-path distro it makes Steam Game Mode unstartable, and the box then latches itself to plasma.

Diagnosed on glass on home-nobara-1 (fc44) tonight.

What #144 does, and what goes wrong

The bind itself works — the patched build genuinely reaches a session script that hardcodes /usr/bin/gamescope. But a mount namespace in a systemd user unit is also a user namespace, in which only that uid is mapped. Measured on the box:

on disk / in a unit without the bind :  drwxrwxrwt 2 0 0          /tmp/.X11-unix
in a unit WITH the bind              :  drwxrwxrwt 2 65534 65534  /tmp/.X11-unix
uid_map inside                       :  1000 1000 1

wlroots checks /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses:

wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us     (×11)
wlserver: [xwayland/sockets.c:217] No display available in the first 33
→ SIGSEGV in run_pipewire

Three ~10 s failures feed /tmp/chimeraos-short-session-tracker; gamescope-session-plus stops even trying, runs steamos-session-select, and rewrites the session to plasma. So the report is "I got thrown onto KDE and can't get back" — two removes from the cause.

Reproduction, no punktfunk required:

systemd-run --user --wait --collect --pipe --property=BindReadOnlyPaths=/etc/hostname:/etc/hostname \
  -- /usr/bin/ls -ldn /tmp/.X11-unix     # → 65534 65534
systemd-run --user --wait --collect --pipe -- /usr/bin/ls -ldn /tmp/.X11-unix   # → 0 0

Two further bugs found while fixing it, both worse than the reported one

1. The bind was armed on every gamescope box. The condition was only gamescope_bin() != "/usr/bin/gamescope", so any box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite, SteamOS-likes, the Deck. The blast radius is every gamescope box, not just the hardcoded-path ones the mechanism exists for. The host now reads the session script and arms only where it never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright. Everything else is bit-for-bit pre-#144.

2. The drop-in outlived its sources — this is why the symptom survived a reboot. It was written to ~/.config/systemd/user/ on the template, so it also applied to the box's own autologin unit at every boot, while both paths it binds live in tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a missing source fails the unit outright. It now lives in $XDG_RUNTIME_DIR, removal covers both the runtime and legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host start — that is the upgrade path for boxes already on canary g13179011. Without it, updating the host would not un-brick them.

3. A bind was armed even when gamescope_bin() fell back to the bare name gamescope; the wrapper then execs gamescope through PATH inside the unit, onto the path we just bound the wrapper over. Fork bomb. Now refused ahead of even the operator's force.

The fix, in layers

Each layer alone would prevent the field failure.

  1. Arm only where needed (read the script; unreadable ⇒ don't arm).
  2. Compensate when armed — a user-owned $XDG_RUNTIME_DIR/punktfunk-x11 bound read-write over /tmp/.X11-unix, so the ownership check sees "us". Skipped when that directory is already ours or absent.
  3. Ask the box, don't trust the reasoning — before arming, run the reproduction above with the real property set and stat -c %u /tmp/.X11-unix. Arms only if the answer is our uid; anything else means stock gamescope (no HDR, no in-node cursor) but a session that starts.
  4. Runtime backstop — a session launched with the bind armed that produces no node in its window disarms and relaunches, latching one-way per process.

PUNKTFUNK_GAMESCOPE_BIND is now three-valued: unset = auto, 0 = never, 1 = force (skips only the script probe, never the correctness gates or the backstop).

The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side X client is spawned under plan.gamescope_cursor = gamescope && !gamescope_composites_cursor(), and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader is never constructed.

Gates

cargo fmt --all --check clean · cargo clippy --workspace --all-targets --locked -- -D warnings on linux/amd64 exit 0, non-vacuous (Checking pf-vdisplay, pf-host-config, 49 units) · cargo test -p pf-vdisplay 137 passed, 0 failed · scripts/xcheck.sh windows clippy clean · no Cargo.lock change. An earlier clippy attempt went red on a real finding and was fixed, so the gate is not a rubber stamp.

⚠ Unverified — no hardware was available

The reference box was being wiped for a clean install, so none of this ran on glass. Most important unknowns:

  • That the compensating bind makes wlroots accept the directory. Reasoned from the measurement above; never observed. Mitigated by layer 3, which measures exactly this on the box before arming.
  • That the rest of a session survives inside that namespace — Steam, the Steam Linux Runtime's own bubblewrap (a nested userns), games. The field crash happened before Steam started, so there is no evidence either way. This is the largest remaining unknown and the probe does not cover it; only the backstop does, after a 45 s failure.
  • Whether the backstop can beat the short-session tracker — three ~10 s crashes fit inside one 45 s window, so a box can still reach plasma before the host disarms. An earlier trigger would false-positive on slow NVIDIA cold starts. Layers 1/3 are the real defence.
  • Script detection was written against the two lines quoted in the field note, not the real ~30 KiB file.
**This fixes a regression that is live in canary now** (`g13179011` and later). On a hardcoded-path distro it makes Steam Game Mode **unstartable**, and the box then latches itself to plasma. Diagnosed on glass on `home-nobara-1` (fc44) tonight. ## What #144 does, and what goes wrong The bind itself **works** — the patched build genuinely reaches a session script that hardcodes `/usr/bin/gamescope`. But a mount namespace in a systemd **user** unit is also a **user** namespace, in which only that uid is mapped. Measured on the box: ``` on disk / in a unit without the bind : drwxrwxrwt 2 0 0 /tmp/.X11-unix in a unit WITH the bind : drwxrwxrwt 2 65534 65534 /tmp/.X11-unix uid_map inside : 1000 1000 1 ``` wlroots checks `/tmp/.X11-unix` is "owned by root or us", sees `nobody`, and refuses: ``` wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us (×11) wlserver: [xwayland/sockets.c:217] No display available in the first 33 → SIGSEGV in run_pipewire ``` Three ~10 s failures feed `/tmp/chimeraos-short-session-tracker`; `gamescope-session-plus` stops even trying, runs `steamos-session-select`, and rewrites the session to **plasma**. So the report is *"I got thrown onto KDE and can't get back"* — two removes from the cause. **Reproduction, no punktfunk required:** ``` systemd-run --user --wait --collect --pipe --property=BindReadOnlyPaths=/etc/hostname:/etc/hostname \ -- /usr/bin/ls -ldn /tmp/.X11-unix # → 65534 65534 systemd-run --user --wait --collect --pipe -- /usr/bin/ls -ldn /tmp/.X11-unix # → 0 0 ``` ## Two further bugs found while fixing it, both worse than the reported one **1. The bind was armed on every gamescope box.** The condition was only `gamescope_bin() != "/usr/bin/gamescope"`, so any box with `punktfunk-gamescope` installed took a namespace it has no use for — Bazzite, SteamOS-likes, the Deck. The blast radius is every gamescope box, not just the hardcoded-path ones the mechanism exists for. The host now **reads the session script** and arms only where it never mentions `GAMESCOPE_BIN` *and* names `/usr/bin/gamescope` outright. Everything else is bit-for-bit pre-#144. **2. The drop-in outlived its sources — this is why the symptom survived a reboot.** It was written to `~/.config/systemd/user/` on the **template**, so it also applied to the box's own autologin unit at every boot, while both paths it binds live in **tmpfs**. After a reboot the drop-in survives and its sources do not, and `BindReadOnlyPaths=` with a missing source fails the unit outright. It now lives in `$XDG_RUNTIME_DIR`, removal covers both the runtime and legacy `$HOME` path, and `restore_takeover_on_startup` does that removal unconditionally at host start — **that is the upgrade path for boxes already on canary `g13179011`.** Without it, updating the host would not un-brick them. **3.** A bind was armed even when `gamescope_bin()` fell back to the bare name `gamescope`; the wrapper then execs `gamescope` through PATH *inside* the unit, onto the path we just bound the wrapper over. Fork bomb. Now refused ahead of even the operator's force. ## The fix, in layers Each layer alone would prevent the field failure. 1. **Arm only where needed** (read the script; unreadable ⇒ don't arm). 2. **Compensate when armed** — a user-owned `$XDG_RUNTIME_DIR/punktfunk-x11` bound read-write over `/tmp/.X11-unix`, so the ownership check sees "us". Skipped when that directory is already ours or absent. 3. **Ask the box, don't trust the reasoning** — before arming, run the reproduction above with the *real* property set and `stat -c %u /tmp/.X11-unix`. Arms only if the answer is our uid; anything else means stock gamescope (no HDR, no in-node cursor) but a session that **starts**. 4. **Runtime backstop** — a session launched with the bind armed that produces no node in its window disarms and relaunches, latching one-way per process. `PUNKTFUNK_GAMESCOPE_BIND` is now three-valued: unset = auto, `0` = never, `1` = force (skips only the script probe, never the correctness gates or the backstop). The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side X client is spawned under `plan.gamescope_cursor` = `gamescope && !gamescope_composites_cursor()`, and our shipped `+pfhdr4` build is patch level 4 — so on the very route where the bind arms, that reader is never constructed. ## Gates `cargo fmt --all --check` clean · `cargo clippy --workspace --all-targets --locked -- -D warnings` on linux/amd64 exit 0, non-vacuous (`Checking pf-vdisplay`, `pf-host-config`, 49 units) · `cargo test -p pf-vdisplay` **137 passed, 0 failed** · `scripts/xcheck.sh windows clippy` clean · no `Cargo.lock` change. An earlier clippy attempt went red on a real finding and was fixed, so the gate is not a rubber stamp. ## ⚠ Unverified — no hardware was available The reference box was being wiped for a clean install, so none of this ran on glass. Most important unknowns: - **That the compensating bind makes wlroots accept the directory.** Reasoned from the measurement above; never observed. *Mitigated by layer 3, which measures exactly this on the box before arming.* - **That the rest of a session survives inside that namespace** — Steam, the Steam Linux Runtime's own bubblewrap (a nested userns), games. The field crash happened *before* Steam started, so there is no evidence either way. This is the largest remaining unknown and the probe does not cover it; only the backstop does, after a 45 s failure. - **Whether the backstop can beat the short-session tracker** — three ~10 s crashes fit inside one 45 s window, so a box can still reach plasma before the host disarms. An earlier trigger would false-positive on slow NVIDIA cold starts. Layers 1/3 are the real defence. - Script detection was written against the two lines quoted in the field note, not the real ~30 KiB file.
enricobuehler added 1 commit 2026-08-09 22:40:02 +00:00
fix(pf-vdisplay): the gamescope bind took a user namespace that broke Xwayland, and left a drop-in that bricked the next boot
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 4m4s
ci / rust (pull_request) Canceled after 4m13s
6695300b67
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box
was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that
hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace,
and only this uid is mapped in it. Measured on the box:

    on disk / in a unit without the bind :  drwxrwxrwt 2 0 0          /tmp/.X11-unix
    in a unit WITH the bind              :  drwxrwxrwt 2 65534 65534  /tmp/.X11-unix
    uid_map inside                       :  1000 1000 1

wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses:

    wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us
    wlserver: [xwayland/sockets.c:217] No display available in the first 33
    -> SIGSEGV in run_pipewire

Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and
steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is
"thrown onto KDE and I can't get back" — two removes from the cause.

Two further bugs found while fixing it, each worse than the one reported:

  * THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`,
    so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite,
    SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path
    ones the mechanism exists for. Now the host READS the session script and arms only where it
    never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is
    bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm.

  * THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so
    it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in
    tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a
    missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now
    lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the
    legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host
    start — which is the upgrade path for every box already running canary g13179011. Without it,
    updating the host would not un-brick them.

  * A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper
    execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over.
    Fork bomb. Refused ahead of even the operator's force.

Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11
bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees
"us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are
pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away.

And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field
reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same
args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a
rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock
gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a
session launched with the bind armed produces no node in its window, latching one-way per process.

The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side
X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`,
and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader
is never constructed.
enricobuehler merged commit 8508f8f3c3 into main 2026-08-09 22:41:41 +00:00
enricobuehler deleted branch worktree-bind-userns 2026-08-09 22:41:42 +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#151