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.
This commit is contained in:
2026-08-09 23:11:00 +02:00
parent 3c096835c2
commit 647e2b8891
2 changed files with 497 additions and 20 deletions
@@ -218,6 +218,12 @@ fn clear_takeover() {
/// box back to gaming mode). No-op when no takeover file exists (a clean start). Call once from
/// `serve` alongside [`start_restore_worker`].
pub fn restore_takeover_on_startup() {
// A stale gamescope bind drop-in means a previous host died mid-stream (a clean exit removes
// it via [`restore_takeover_now`]). Unlike the takeover statics it is a FILE on the operator's
// box, so it outlives the process that wrote it and would otherwise keep reshaping the box's
// own game mode with no host to own it. Drop-ins apply at unit start, so removing it here
// cannot disturb a session that is already running.
remove_session_bind_dropin();
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
};
@@ -1084,6 +1090,17 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
&format!("SCREEN_HEIGHT={}", mode.height),
&format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)),
]);
// On a box whose session script hardcodes an absolute gamescope path, this restart is also the
// one chance to make that path mean the patched binary — the drop-in only takes effect at unit
// start. It is a no-op everywhere the `GAMESCOPE_BIN` wrapper already works, and on a box that
// cannot take the bind at all ([`session_bind_setting`] returns `None`), so the restart below
// is unchanged from today's behaviour in both of those cases.
//
// HDR is deliberately not requested here. This is the DEGRADED attach path — the host does not
// own this session's lifecycle, never claimed its capabilities, and does not run
// `verify_managed_spawn_flags` against it — so the bind is used for the one thing that is
// unambiguously right either way: the client's refresh rate reaching the compositor.
let mut bound = arm_session_bind_dropin(game_hz(mode.refresh_hz));
systemctl_user(&["restart", &unit]);
// Wait for the relaunched session to come up at the new size and publish its capture node. The
// node appears when gamescope is up (well before Steam finishes booting); the caller's
@@ -1101,6 +1118,27 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
return Ok(node);
}
}
// The bind's one catastrophic failure mode, on the unit where it would hurt most: this is
// the box's OWN game mode, so a unit that cannot build its mount namespace leaves the
// operator with no Game Mode until someone deletes a file by hand. Take the drop-in back
// out (which reloads systemd) and restart plain — the box lands exactly where it would
// have without this mechanism.
//
// Only once the unit has actually SETTLED: `ExecMainStatus` reports the last main process
// to exit, so consulting it mid-`activating` can read the previous run's number and disarm
// a bind that is working.
if bound && !unit_starting_or_active(&unit) && unit_failed_namespace(&unit) {
tracing::warn!(
%unit,
"gamescope: the box's game-mode unit could not be given a mount namespace \
(exit {EXIT_NAMESPACE}) — removing the gamescope bind drop-in and restarting \
without it"
);
note_session_bind_unusable();
remove_session_bind_dropin();
bound = false;
systemctl_user(&["restart", &unit]);
}
if Instant::now() >= deadline {
bail!(
"box game-mode session did not come up at {}x{} within 45s after relaunch \
@@ -1303,19 +1341,31 @@ fn verify_managed_spawn_flags(hdr: bool) -> Result<()> {
return Ok(());
}
note_spawn_flags_lost();
// This check is also the post-hoc oracle for the mount-namespace bind ([`session_bind_setting`]):
// a unit that FAILED to start is a different fault with its own detector ([`unit_failed_namespace`]),
// but a unit that started and still has no flags means the bind was armed and did not deliver
// — and an operator told to "install punktfunk-gamescope" on a box where it is already
// installed and bound would be sent the wrong way, which is the mistake this file has made
// before. So say which of the two mechanisms was actually in play.
let route = if session_bind_setting().is_some() {
"the session's hardcoded gamescope path was bind-mounted onto our wrapper and the flags \
still did not arrive"
} else {
"it ignored GAMESCOPE_BIN / the PATH shim and ran a stock gamescope"
};
// Warn as well as erroring: the latch is a process-wide capability change, and whichever
// caller consumes this error decides on its own how loudly to report it.
tracing::warn!(
missing = %missing.join(" "),
"gamescope: 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"
%route,
"gamescope: the session did not receive our flags — HDR and the in-node cursor are now off \
for this host process"
);
Err(anyhow!(
"the gamescope session started without {} — it ignored GAMESCOPE_BIN / the PATH shim and \
ran a stock gamescope. Refusing it rather than streaming a session whose shape was \
planned around flags that never arrived (a missing cursor flag has no symptom but an \
absent pointer). Those capabilities are off for this host now; reconnect for a plain SDR \
session, or install punktfunk-gamescope as the box's `gamescope`",
"the gamescope session started without {} — {route}. Refusing it rather than streaming a \
session whose shape was planned around flags that never arrived (a missing cursor flag \
has no symptom but an absent pointer). Those capabilities are off for this host now; \
reconnect for a plain SDR session, or install punktfunk-gamescope as the box's `gamescope`",
missing.join(" ")
))
}
@@ -2384,6 +2434,10 @@ fn takeover_live() -> bool {
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
/// that would serve them is exiting. No-op when nothing was taken over.
pub fn restore_takeover_now() {
// Unconditionally, and BEFORE the takeover gate: the bind drop-in is armed on the degraded
// attach path too, which takes nothing over and so would leave the file behind on every clean
// host shutdown. It is ours by name and a no-op when absent.
remove_session_bind_dropin();
if !takeover_live() {
return;
}
@@ -2417,6 +2471,12 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
/// cancelled+reconnected window keeps the list for a later real restore.
fn do_restore_tv_session() {
// FIRST, and outside every branch below: the gamescope bind drop-in is armed on the box's OWN
// game-mode unit, so it must come back out however this restore ends — including the early
// returns for "nothing was taken over" and "a desktop session is active", which are exactly
// the shapes the degraded attach path produces. It removes only our own file (the operator's
// drop-ins in that directory are untouched) and is a no-op when we never armed it.
remove_session_bind_dropin();
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
@@ -2693,6 +2753,330 @@ fn write_gamescope_bin_wrapper() -> Result<std::path::PathBuf> {
Ok(path)
}
/// systemd's exit status for "I could not set up this unit's mount namespace" (`EXIT_NAMESPACE`).
/// A unit carrying [`session_bind_setting`] exits with it — before its `ExecStart` ever runs — when
/// the box cannot give a **user** unit a mount namespace: no unprivileged user namespaces
/// (`user.max_user_namespaces=0`, a hardened `kernel.unprivileged_userns_clone=0`, a container
/// without the capability), or a source/target that cannot be bound.
///
/// It is the one outcome that is strictly WORSE than the bug the bind fixes. Without the bind the
/// box streams a working session that merely lacks HDR and a cursor; with a bind that cannot be
/// established the unit does not start at all, and Game Mode is simply gone. So every site that
/// arms the bind also watches for this status and retries without it.
const EXIT_NAMESPACE: i32 = 226;
/// Set once a unit carrying the bind has actually failed to start with [`EXIT_NAMESPACE`] — the
/// runtime counterpart to the [`bind_mount_usable`] preflight, for the box that passes the probe
/// and then fails the real thing anyway (a probe runs `/bin/true`; the session unit carries the
/// distro's own drop-ins, and one of them may add sandboxing that interacts badly).
///
/// One-way and process-wide, exactly like [`note_spawn_flags_lost`]: nothing we can observe proves
/// the next launch would fare better, and the cost of trying again is a session that will not
/// start. Once set, [`session_bind_setting`] answers `None` forever and every path is back to
/// today's behaviour.
static BIND_UNUSABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Record that a unit carrying the bind failed with [`EXIT_NAMESPACE`] — see [`BIND_UNUSABLE`].
fn note_session_bind_unusable() {
BIND_UNUSABLE.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// The gamescope binary `gamescope-session-plus` HARDCODES, on a box where the `GAMESCOPE_BIN`
/// escape hatch does not exist **at all**. `None` when the script honours `GAMESCOPE_BIN` (Bazzite
/// and the SteamOS-like images — the wrapper works there, and a box that does not need a mount
/// namespace must not be given one), when there is no session-plus, or when the script's shape is
/// not one we recognise.
///
/// Field evidence, Nobara 44 (`home-nobara-1`, 2026-08-09):
/// `grep GAMESCOPE_BIN /usr/share/gamescope-session-plus/gamescope-session-plus` returns **no
/// matches at all** — the variable is never read — and line 244 reads
/// `GAMESCOPECMD="/usr/bin/gamescope \`. Against that, no environment variable and no `PATH` entry
/// can win: the host writes its wrapper, exports it, and the session execs the distro's stock
/// gamescope with none of our flags. That is not a misconfiguration to warn about, it is a
/// structural property of the script on disk — and it is *readable*, which is what makes it safe
/// to act on rather than guess at.
fn session_plus_hardcoded_gamescope() -> Option<std::path::PathBuf> {
let script = std::fs::read_to_string(SESSION_PLUS_BIN).ok()?;
hardcoded_gamescope_in(&script).map(std::path::PathBuf::from)
}
/// [`session_plus_hardcoded_gamescope`]'s parser. Split out pure and unit-tested because both of
/// its answers are load-bearing and one of them arms a bind mount over a distro-owned path.
///
/// A script that mentions `GAMESCOPE_BIN` *anywhere* is left alone: that is the sanctioned hook,
/// the host already drives it, and the bind exists only for scripts that offer no hook at all.
///
/// Only the `GAMESCOPECMD=` assignment that OPENS the command line is read, and only when its
/// first word is an absolute path. The same script contains `if [ -z "$GAMESCOPECMD" ]` (a test)
/// and several `GAMESCOPECMD+=" -R $socket"` (appends); neither is an assignment of the binary,
/// and mistaking one for it would bind a mount over `$socket`.
fn hardcoded_gamescope_in(script: &str) -> Option<&str> {
if script.contains("GAMESCOPE_BIN") {
return None;
}
script.lines().find_map(|line| {
let rest = line.trim_start().strip_prefix("GAMESCOPECMD=")?;
let path = rest
.trim_start_matches(['"', '\''])
.split_whitespace()
.next()?;
path.starts_with('/').then_some(path)
})
}
/// The `BindReadOnlyPaths=` value that makes a hardcoded gamescope path resolve to the host's
/// `GAMESCOPE_BIN` wrapper **inside the session unit's own mount namespace** — or `None` when the
/// bind is either unnecessary or unproven, in which case every caller behaves exactly as it did
/// before this mechanism existed.
///
/// ## Why a mount namespace, when two indirections already exist
///
/// The wrapper ([`write_gamescope_bin_wrapper`]) and the SteamOS PATH shim
/// ([`write_headless_shim`]) both work by *asking* the session script to run something else. A
/// script that hardcodes `/usr/bin/gamescope` never asks. The only remaining lever that does not
/// require overwriting a distro-owned binary is to change what that path MEANS, for this unit and
/// nothing else — which is what a bind mount in a private mount namespace is. Outside the unit the
/// box is bit-for-bit unchanged; `dnf` still owns `/usr/bin/gamescope`, and a package upgrade,
/// another user's session, and the operator's own desktop all see the stock binary.
///
/// ## Why the WRAPPER is the source, not the patched binary
///
/// Binding `punktfunk-gamescope` straight over `/usr/bin/gamescope` would fix the binary and lose
/// the point: the flags this whole path exists to deliver — `--pipewire-composite-cursor`,
/// `--pipewire-composite-external-overlay`, `--hdr-enabled`, `--nested-refresh` — are injected by
/// the wrapper, not baked into the binary. A session bound to the bare binary still runs without
/// them, [`verify_managed_spawn_flags`] still refuses it, and the box still streams SDR with no
/// pointer. Binding the *wrapper* reproduces exactly what `GAMESCOPE_BIN` would have done had the
/// script consulted it, which is the whole objective.
///
/// ## Where it applies
///
/// Only where the wrapper is *structurally* lost, decided by reading the script on this box
/// ([`session_plus_hardcoded_gamescope`]) rather than by distro name. Bazzite and SteamOS keep the
/// mechanism that already works there and never take a mount namespace they do not need; a box
/// with no session-plus at all (the bare-spawn path builds its own argv) never reaches here.
fn session_bind_setting() -> Option<&'static str> {
if BIND_UNUSABLE.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
static SETTING: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
SETTING.get_or_init(resolve_session_bind_setting).as_deref()
}
/// [`session_bind_setting`]'s one-time resolution. Every `None` here is a deliberate refusal to
/// arm a bind, and each is logged at the level its cause deserves.
fn resolve_session_bind_setting() -> Option<String> {
let dst = session_plus_hardcoded_gamescope()?;
// A bind mount cannot CREATE the target, and a unit whose bind fails does not start — so a
// hardcoded path that is not a file on this box is a refusal, not an attempt.
if !dst.is_file() {
tracing::warn!(
path = %dst.display(),
"gamescope: gamescope-session-plus hardcodes a gamescope that does not exist here — \
not binding over it"
);
return None;
}
// FORK-BOMB GUARD, and the reason this is a hard gate rather than an optimization: the source
// is a wrapper whose last act is `exec`ing [`gamescope_bin`]. If that resolved to the very
// path we are about to shadow, the wrapper would exec *itself*, forever, inside the session.
// It does exactly that on a box with no `punktfunk-gamescope` installed — which is also the
// box with nothing to gain here, since the flags the wrapper injects need the patched binary.
let bin = std::path::Path::new(gamescope_bin());
if !bin.is_absolute() || bin == dst {
tracing::debug!(
bin = %bin.display(),
hardcoded = %dst.display(),
"gamescope: the session script hardcodes the same gamescope we would run — nothing to \
bind (install punktfunk-gamescope for HDR and the in-node cursor)"
);
return None;
}
// Written here rather than assumed present: this resolution is cached for the process, so a
// first call that happened to precede the wrapper's own write would latch `None` forever.
// The write is idempotent and its body is constant for the process.
let src = write_gamescope_bin_wrapper()
.map_err(|e| tracing::warn!(error = %format!("{e:#}"), "gamescope: no wrapper to bind"))
.ok()?;
let paths = format!("{}:{}", src.display(), dst.display());
if !bind_mount_usable(&paths) {
tracing::warn!(
%paths,
"gamescope: this box's gamescope-session-plus hardcodes an absolute gamescope path and \
reads GAMESCOPE_BIN nowhere, but a systemd --user unit here cannot take a mount \
namespace — so the patched gamescope cannot be reached without overwriting a \
distro-owned binary. Sessions stay 8-bit SDR with a host-composited cursor. (Check \
`sysctl user.max_user_namespaces` / `kernel.unprivileged_userns_clone`.)"
);
return None;
}
tracing::info!(
%paths,
"gamescope: this box's gamescope-session-plus hardcodes an absolute gamescope path and \
reads GAMESCOPE_BIN nowhere — binding the punktfunk wrapper over it inside the session \
unit's own mount namespace (nothing outside the unit changes)"
);
Some(paths)
}
/// Can a `systemd --user` unit on this box actually take this bind? Proven — once per host
/// process, before anything depends on it — by starting a throwaway transient unit carrying the
/// exact `BindReadOnlyPaths=` value and running `/bin/true` inside it.
///
/// This is proven rather than assumed because the failure is worse than the bug (see
/// [`EXIT_NAMESPACE`]). Everything the real bind needs is exercised: the user manager's ability to
/// unshare a mount namespace at all, the source, and the target. Nothing else is: the payload is
/// `/bin/true`, so no gamescope is spawned, no session is touched, and the unit is `--collect`ed
/// the moment it exits.
///
/// It deliberately does NOT try to prove that the *substitution* took — that the gamescope the
/// session ends up running is ours. That answer already exists downstream and is a better one:
/// [`verify_managed_spawn_flags`] reads the running compositor's `/proc/<pid>/cmdline` and refuses
/// a session missing our flags, whatever the reason. Proving it here would mean executing the
/// bound path, and a preflight that can accidentally start a compositor on someone's box is not a
/// preflight.
fn bind_mount_usable(paths: &str) -> bool {
let out = Command::new("systemd-run")
.args(["--user", "--wait", "--collect", "--quiet"])
.arg(format!("--property=BindReadOnlyPaths={paths}"))
// A probe that can hang is a connect that can hang: `/bin/true` returns instantly, so any
// wait at all means something is wrong and the answer we want is "no".
.arg("--property=RuntimeMaxSec=15")
.args(["--", "/bin/true"])
.output();
match out {
// `--wait` propagates the payload's own exit status, so success here means systemd built
// the namespace, took the bind, and ran the command in it.
Ok(o) if o.status.success() => true,
Ok(o) => {
tracing::debug!(
status = %o.status,
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"gamescope: the bind-mount preflight unit failed"
);
false
}
Err(e) => {
tracing::debug!(error = %e, "gamescope: could not run the bind-mount preflight");
false
}
}
}
/// Did `unit` fail because systemd could not set up its mount namespace ([`EXIT_NAMESPACE`])?
///
/// The distinction this draws is the whole of the graceful degradation: a unit that started
/// without our flags is the OLD bug (loud, recoverable, and [`verify_managed_spawn_flags`] already
/// owns it), while a unit that never started at all is the NEW one, and only the second may retry
/// without the bind. `false` whenever the answer cannot be read — an unreadable status must not
/// disarm a bind that is working.
fn unit_failed_namespace(unit: &str) -> bool {
let Ok(out) = Command::new("systemctl")
.args(["--user", "show", "-p", "ExecMainStatus", "--value", unit])
.output()
else {
return false;
};
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<i32>()
.ok()
== Some(EXIT_NAMESPACE)
}
/// Path of the drop-in that arms [`session_bind_setting`] on the BOX's own game-mode unit.
///
/// On the **template** (`gamescope-session-plus@.service.d`), not on an instance: the box chooses
/// its own instance name (`@steam`, `@ogui-steam`, …), the host restarts whichever one is live,
/// and a per-instance file would miss the next one.
///
/// `zz-` so it sorts LAST. systemd merges drop-ins in filename order and the last assignment of a
/// setting wins, so no distro or operator file can silently take the bind back out from under us.
/// The reference box's own operator drop-in is named `10-headless.conf` for the other half of that
/// contract — a distinct name and an earlier sort position — and [`remove_session_bind_dropin`]
/// removes only the single file named here, so a restore leaves it standing.
fn session_bind_dropin_path() -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok().filter(|h| !h.is_empty())?;
Some(
std::path::Path::new(&home)
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf"),
)
}
/// Write the bind drop-in for the box's own game-mode unit and make systemd re-read it. Returns
/// whether the bind is now armed; `false` means the caller proceeds exactly as it did before.
///
/// The `daemon-reload` is not optional and not deferrable: a drop-in systemd has not re-read is
/// **inert**, so the restart that follows would start the unit from the old configuration and the
/// entire mechanism would silently do nothing while looking like it worked.
fn arm_session_bind_dropin(hz: u32) -> bool {
let Some(setting) = session_bind_setting() else {
return false;
};
let Some(path) = session_bind_dropin_path() else {
return false;
};
let write = || -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
// `PF_HZ` rides along because the wrapper reads it for `--nested-refresh` — the flag
// gamescope-session-plus does not expose and the one a headless session reports as its
// ONE refresh rate (60 Hz when it never arrives). Binding the wrapper without it would
// hand every game a 60 Hz display on a 120 Hz stream.
let body = format!(
"# Written by punktfunk for the duration of a stream; removed on restore.\n\
#\n\
# This box's gamescope-session-plus HARDCODES an absolute gamescope path and reads\n\
# GAMESCOPE_BIN nowhere, so neither the wrapper nor a PATH shim can reach it. Bind\n\
# the wrapper over that path inside THIS UNIT's own mount namespace instead: the\n\
# session gets the patched gamescope and our flags, and nothing outside the unit\n\
# changes — the distro still owns the binary on disk.\n\
[Service]\n\
BindReadOnlyPaths={setting}\n\
Environment=PF_HZ={hz}\n"
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))
};
if let Err(e) = write() {
tracing::warn!(
error = %format!("{e:#}"),
"gamescope: could not write the gamescope bind drop-in — the box's session will run \
the distro's stock gamescope (no HDR, no in-node cursor)"
);
return false;
}
systemctl_user(&["daemon-reload"]);
tracing::info!(
path = %path.display(),
"gamescope: armed the punktfunk gamescope bind on the box's own game-mode unit"
);
true
}
/// Remove the bind drop-in (restore, host shutdown, and a stale one found at startup). Best-effort.
///
/// It takes **only our own file** — `remove_file` on the one name we wrote, never the directory.
/// The drop-in directory is shared with the operator: the reference box keeps a `10-headless.conf`
/// in there without which its game-mode unit cannot start at all (no connected DRM connector), and
/// an `rm -r` of the directory would take that with it and leave the box unable to reach Game Mode
/// by any route. The `daemon-reload` runs only when a file was actually removed, so a restore on a
/// box that never armed the bind touches nothing.
fn remove_session_bind_dropin() {
let Some(path) = session_bind_dropin_path() else {
return;
};
if std::fs::remove_file(&path).is_ok() {
systemctl_user(&["daemon-reload"]);
tracing::info!(
path = %path.display(),
"gamescope: removed the punktfunk gamescope bind drop-in (the box's game-mode unit is \
back to the distro's own gamescope)"
);
}
}
/// Launch `gamescope-session-plus <client>` headless at `mode` as a transient `systemd --user`
/// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via
/// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and
@@ -2731,9 +3115,20 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
r.dedup();
r.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
};
let start_unit = || -> Result<()> {
let status = Command::new("systemd-run")
.args(["--user", "--collect", &format!("--unit={unit_name}")])
// The bind is passed as a transient PROPERTY rather than through a drop-in file, because this
// unit is one we create: `systemd-run` applies it atomically with the start, there is no
// `daemon-reload` window in which it could be inert, and — the part that matters after a host
// crash — there is no file left behind on a box we no longer own. The box's OWN game-mode unit
// (which we only restart) has no such option and takes the drop-in instead; both carry the
// identical `BindReadOnlyPaths=` value from [`session_bind_setting`].
let mut bind = session_bind_setting();
let start_unit = |bind: Option<&str>| -> Result<()> {
let mut cmd = Command::new("systemd-run");
cmd.args(["--user", "--collect", &format!("--unit={unit_name}")]);
if let Some(paths) = bind {
cmd.arg(format!("--property=BindReadOnlyPaths={paths}"));
}
let status = cmd
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
// that would abort gamescope at startup.
@@ -2770,7 +3165,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
}
Ok(())
};
start_unit()?;
start_unit(bind)?;
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
let deadline = Instant::now() + Duration::from_secs(45);
loop {
@@ -2799,11 +3194,29 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
// and the transient unit has no Restart= — without supervision the rest of this poll would
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
if !unit_starting_or_active(unit_name) {
tracing::warn!(
unit = unit_name,
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
readiness window?) — relaunching"
);
// Before blaming the readiness window: did the unit fail because systemd could not
// build its mount namespace? That is a unit which never reached its ExecStart, so
// relaunching it identically would loop until the deadline and leave the box with no
// Game Mode at all — strictly worse than the missing-flags bug the bind is here to
// fix. Disarm and relaunch plain; the session then comes up exactly as it did before
// this mechanism existed, and `verify_managed_spawn_flags` refuses HDR + the in-node
// cursor as it always has.
if bind.is_some() && unit_failed_namespace(unit_name) {
tracing::warn!(
unit = unit_name,
"gamescope session: the unit could not be given a mount namespace \
(exit {EXIT_NAMESPACE}) — dropping the gamescope bind and relaunching \
without it. This session streams SDR with a host-composited cursor"
);
note_session_bind_unusable();
bind = None;
} else {
tracing::warn!(
unit = unit_name,
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
readiness window?) — relaunching"
);
}
// Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init,
// and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays
// the reclaim serialization on top of device init and misses the 5 s window again.
@@ -2811,7 +3224,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
let _ = Command::new("systemctl")
.args(["--user", "reset-failed", unit_name])
.status();
start_unit()?;
start_unit(bind)?;
}
std::thread::sleep(Duration::from_millis(500));
}
@@ -3152,9 +3565,9 @@ impl Drop for GamescopeProc {
mod tests {
use super::{
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, sentinel_advanced,
shape_dedicated_command, DmHelperError,
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz,
hardcoded_gamescope_in, hdr_args, is_steam_launch, missing_flags, mode_mismatch,
nested_wrapper_script, sentinel_advanced, shape_dedicated_command, DmHelperError,
};
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
@@ -3431,6 +3844,58 @@ mod tests {
assert!(!cgroup_is_punktfunk_owned(""));
}
/// The gate that decides whether a mount namespace is armed over a distro-owned path at all.
/// Both answers matter: a false `Some` binds a mount on a box that never needed one, and a
/// false `None` is the Nobara bug (a session that silently runs a stock gamescope).
#[test]
fn hardcoded_gamescope_is_read_only_where_there_is_no_escape_hatch() {
// THE field case, Nobara 44 / `home-nobara-1` 2026-08-09: `GAMESCOPE_BIN` appears nowhere
// in the script, and line 244 opens the command line with an absolute path. Verbatim
// shape, tabs and trailing backslash included.
let nobara = "\
#!/bin/bash\n\
if [ -z \"$GAMESCOPECMD\" ]; then\n\
\tGAMESCOPECMD=\"/usr/bin/gamescope \\\n\
\t\t$CURSOR \\\n\
\t\t$RESOLUTION\"\n\
fi\n\
GAMESCOPECMD+=\" -R $socket -T $stats\"\n\
$GAMESCOPECMD >\"${HOME}\"/.gamescope-stdout.log 2>&1 &\n";
assert_eq!(hardcoded_gamescope_in(nobara), Some("/usr/bin/gamescope"));
// A script that reads GAMESCOPE_BIN anywhere is LEFT ALONE — that is the sanctioned hook,
// the wrapper already drives it (Bazzite / SteamOS-like), and a box where it works must
// not be given a mount namespace it does not need.
let bazzite = "\
GAMESCOPE_BIN=${GAMESCOPE_BIN:-gamescope}\n\
GAMESCOPECMD=\"/usr/bin/gamescope $RESOLUTION\"\n";
assert_eq!(hardcoded_gamescope_in(bazzite), None);
// An APPEND (`+=`) is not the assignment of the binary. Reading one would bind a mount
// over `$socket`, which is the worst available outcome here.
assert_eq!(
hardcoded_gamescope_in("GAMESCOPECMD+=\" -R /tmp/sock\"\n"),
None
);
// Nor is the emptiness TEST that guards the assignment.
assert_eq!(
hardcoded_gamescope_in("if [ -z \"$GAMESCOPECMD\" ]; then\nfi\n"),
None
);
// A relative or variable command is not something we can bind over.
assert_eq!(
hardcoded_gamescope_in("GAMESCOPECMD=\"gamescope -W 1920\"\n"),
None
);
assert_eq!(
hardcoded_gamescope_in("GAMESCOPECMD=\"$GS -W 1920\"\n"),
None
);
// Nothing recognisable at all (a script we do not understand) stays hands-off.
assert_eq!(hardcoded_gamescope_in("exec gamescope \"$@\"\n"), None);
assert_eq!(hardcoded_gamescope_in(""), None);
}
/// The silent-60Hz guard. A headless gamescope reports `--nested-refresh` as its ONE refresh
/// rate and falls back to 60 Hz when the flag never arrives, so a session that lost the
/// `GAMESCOPE_BIN` wrapper streams at the client's rate while telling every game it is 60 —
+12
View File
@@ -240,6 +240,18 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that
menu. If the host log says *"the session did not start at the mode we asked for"*, a file in
`/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`.
- **Some distros' session script ignores `GAMESCOPE_BIN` entirely — the host works around it with a
bind mount.** Nobara's `gamescope-session-plus` never reads that variable and hardcodes
`/usr/bin/gamescope` outright, so neither the variable nor a `PATH` entry can point the session at
`punktfunk-gamescope`; every session ran the stock binary, which cost HDR *and* the in-stream
cursor. The host now detects that shape by reading the script, and binds its own wrapper over the
hardcoded path **inside the session unit's own mount namespace** — the session gets the patched
gamescope and the flags, and nothing outside that unit changes (the distro still owns
`/usr/bin/gamescope`; you do not need to overwrite it). You will see *"binding the punktfunk
wrapper over it"* in the log when this engages. It needs unprivileged user namespaces; on a box
without them the host logs that it cannot take a mount namespace and streams SDR with a
host-composited cursor, exactly as before. Boxes whose script does honour `GAMESCOPE_BIN`
(Bazzite, SteamOS) are untouched by any of this.
- **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp,
which gamescope draws as an *external overlay* — a layer upstream's capture composite has never
included on any version, so on a stock gamescope you can turn the overlay on and it simply will