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.
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.
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_BINand 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.
Arm only where needed (read the script; unreadable ⇒ don't arm).
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.
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.
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-vdisplay137 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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
This fixes a regression that is live in canary now (
g13179011and 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:wlroots checks
/tmp/.X11-unixis "owned by root or us", seesnobody, and refuses:Three ~10 s failures feed
/tmp/chimeraos-short-session-tracker;gamescope-session-plusstops even trying, runssteamos-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:
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 withpunktfunk-gamescopeinstalled 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 mentionsGAMESCOPE_BINand names/usr/bin/gamescopeoutright. 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, andBindReadOnlyPaths=with a missing source fails the unit outright. It now lives in$XDG_RUNTIME_DIR, removal covers both the runtime and legacy$HOMEpath, andrestore_takeover_on_startupdoes that removal unconditionally at host start — that is the upgrade path for boxes already on canaryg13179011. Without it, updating the host would not un-brick them.3. A bind was armed even when
gamescope_bin()fell back to the bare namegamescope; the wrapper then execsgamescopethrough 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.
$XDG_RUNTIME_DIR/punktfunk-x11bound read-write over/tmp/.X11-unix, so the ownership check sees "us". Skipped when that directory is already ours or absent.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.PUNKTFUNK_GAMESCOPE_BINDis 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+pfhdr4build is patch level 4 — so on the very route where the bind arms, that reader is never constructed.Gates
cargo fmt --all --checkclean ·cargo clippy --workspace --all-targets --locked -- -D warningson linux/amd64 exit 0, non-vacuous (Checking pf-vdisplay,pf-host-config, 49 units) ·cargo test -p pf-vdisplay137 passed, 0 failed ·scripts/xcheck.sh windows clippyclean · noCargo.lockchange. 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: