steam-backlog-enforcer/steam_backlog_enforcer/_desktop_env.py
Krzysztof kuhy Rudnicki bfdd69453e
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Pass XDG_RUNTIME_DIR when launching Steam as the desktop user
The enforcer runs as root under systemd and launches Steam via
`sudo -u <user> env DISPLAY=... XAUTHORITY=... DBUS_SESSION_BUS_ADDRESS=...`.
sudo uses env_reset, so that list is the child's entire environment - an
allowlist where every omission fails silently.

XDG_RUNTIME_DIR was missing. Without it Wine cannot reach
$XDG_RUNTIME_DIR/pulse/native, falls back from winepulse.drv to
winealsa.drv, and hands FMOD raw ALSA devices instead of the session sink.
Kingdom Come: Deliverance II died in its Bink startup video as a result.
Across 18 sessions the split was total: 9/9 runs logging "PulseAudio
Output" exited cleanly, 9/9 logging "HDA NVidia" crashed on the same line.

It presented as a corrupt install - the apparent cure was a 92GB reinstall -
but verify-integrity always passed. What actually fixed it was the Steam
restart the reinstall incidentally performed.

Extract the duplicated env construction into _desktop_env.desktop_env_args
and derive XDG_RUNTIME_DIR from the target uid, never from the ambient
environment: root's own value would point the child at /run/user/0.

PULSE_SERVER is deliberately not exported. pressure-vessel reads
XDG_RUNTIME_DIR to bind-mount the pulse socket into the Steam Linux Runtime
container, whereas a hardcoded unix:/run/user/<uid>/pulse/native names a
host path that need not resolve inside it.

Also gate the launch on the session existing. The enforcer is ordered only
After=network-online, while /run/user/<uid> is created by
user-runtime-dir@<uid>.service; on a measured boot the enforcer started 54ms
before that unit and won only by doing other work first. Launching in that
window would name a runtime dir that is not there and reintroduce the same
silent winealsa fallback for the whole session, so defer instead - the
enforce loop already retries every 3s, and DesktopSessionNotReadyError
subclasses SteamUnavailableError so existing callers degrade as before.
An ordering dependency would not be reliable here, since logind may start
user-runtime-dir outside the boot transaction.

Verified on the failing path rather than a dev shell: after restarting the
enforcer, the Steam it launched carried XDG_RUNTIME_DIR=/run/user/1000 and
KCD2 ran 15 minutes on "PulseAudio Output" with no crash artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:39:38 +02:00

73 lines
3.2 KiB
Python

"""Environment handed to processes launched into the user's desktop session.
The enforcer runs as root under systemd, so anything it starts on the desktop
user's behalf goes through ``sudo -u <user> env ...``. ``sudo`` resets the
environment, which makes the list below an *allowlist*: every variable the
desktop session needs must be named here explicitly, and every omission fails
silently rather than erroring.
Lives in its own module (like ``_steam_state``) so both ``library_hider`` and
``game_install`` can share one builder without forming an import cycle.
``XDG_RUNTIME_DIR`` is load-bearing and was missing here for a long time.
Without it Wine cannot reach ``$XDG_RUNTIME_DIR/pulse/native``, silently falls
back from ``winepulse.drv`` to ``winealsa.drv``, and every Proton game gets raw
ALSA devices instead of the session's PulseAudio sink. That crashed Kingdom
Come: Deliverance II during its startup video, and looked like a corrupt
install because restarting Steam by hand - the thing a reinstall incidentally
does - was what actually cleared it.
Only ``XDG_RUNTIME_DIR`` is exported for audio, deliberately: pressure-vessel
reads it to bind-mount the pulse socket into the Steam Linux Runtime container,
whereas a hardcoded ``PULSE_SERVER=unix:/run/user/<uid>/pulse/native`` would
name a *host* path that need not resolve inside that container.
"""
from __future__ import annotations
import os
from pathlib import Path
def desktop_runtime_dir(uid: int) -> str:
"""Path to the desktop user's XDG runtime directory."""
return f"/run/user/{uid}"
def desktop_session_ready(uid: int) -> bool:
"""Whether the desktop user's runtime directory exists yet.
The enforcer is a system service ordered only ``After=network-online``,
while ``/run/user/<uid>`` is created by ``user-runtime-dir@<uid>.service``
when logind opens the session. On a cold boot the enforcer can reach its
first launch before that directory exists — measured on one boot, it
started 54ms *before* the runtime-dir unit and only won by doing other
work first.
A Steam launched in that window would name a runtime dir that is not
there, which is exactly the winealsa fallback this module exists to
prevent, and it would stay broken for the whole session. Callers gate on
this and retry on the next enforce pass instead.
"""
return Path(desktop_runtime_dir(uid)).is_dir()
def desktop_env_args(user: str, uid: int) -> list[str]:
"""Build the ``env`` arguments for a command run as the desktop *user*.
Session paths are derived from *uid* rather than read from the current
environment: the enforcer normally runs as root, and inheriting root's
``XDG_RUNTIME_DIR`` would point the child at ``/run/user/0``, which it
cannot use.
"""
runtime_dir = desktop_runtime_dir(uid)
dbus_default = f"unix:path={runtime_dir}/bus"
dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default)
xauth = os.environ.get("XAUTHORITY", f"/home/{user}/.Xauthority")
return [
f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
f"XAUTHORITY={xauth}",
f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}",
f"XDG_RUNTIME_DIR={runtime_dir}",
]