From bfdd69453e53afeb61a3c6c8ad1669d977760f5c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 26 Jul 2026 14:39:38 +0200 Subject: [PATCH] Pass XDG_RUNTIME_DIR when launching Steam as the desktop user The enforcer runs as root under systemd and launches Steam via `sudo -u 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//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/ is created by user-runtime-dir@.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) --- steam_backlog_enforcer/_desktop_env.py | 72 ++++++ steam_backlog_enforcer/game_install.py | 21 +- steam_backlog_enforcer/library_hider.py | 54 +++- .../tests/test_desktop_env.py | 235 ++++++++++++++++++ .../tests/test_game_install.py | 48 ++++ 5 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 steam_backlog_enforcer/_desktop_env.py create mode 100644 steam_backlog_enforcer/tests/test_desktop_env.py diff --git a/steam_backlog_enforcer/_desktop_env.py b/steam_backlog_enforcer/_desktop_env.py new file mode 100644 index 0000000..0824fb5 --- /dev/null +++ b/steam_backlog_enforcer/_desktop_env.py @@ -0,0 +1,72 @@ +"""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 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//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/`` is created by ``user-runtime-dir@.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}", + ] diff --git a/steam_backlog_enforcer/game_install.py b/steam_backlog_enforcer/game_install.py index 609aff1..9a81c0d 100644 --- a/steam_backlog_enforcer/game_install.py +++ b/steam_backlog_enforcer/game_install.py @@ -15,6 +15,11 @@ import sys import time from steam_backlog_enforcer._allowed_games import allowed_games +from steam_backlog_enforcer._desktop_env import ( + desktop_env_args, + desktop_runtime_dir, + desktop_session_ready, +) from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH from steam_backlog_enforcer._whitelist import get_approved_exception_ids from steam_backlog_enforcer.config import State @@ -265,18 +270,20 @@ def _ensure_steam_running() -> None: try: if os.geteuid() == 0 and real_user and real_user != "root": uid, _ = _get_uid_gid_for_user(real_user) - dbus_default = f"unix:path=/run/user/{uid}/bus" - dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default) - xauth_default = f"/home/{real_user}/.Xauthority" - xauth = os.environ.get("XAUTHORITY", xauth_default) + # Defer rather than launch a Steam that would come up without a + # runtime dir, and so without working audio, for the session. + if not desktop_session_ready(uid): + logger.info( + "Deferring Steam start: %s does not exist yet.", + desktop_runtime_dir(uid), + ) + return cmd = [ "sudo", "-u", real_user, "env", - f"DISPLAY={os.environ.get('DISPLAY', ':0')}", - f"XAUTHORITY={xauth}", - f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}", + *desktop_env_args(real_user, uid), "steam", "-silent", ] diff --git a/steam_backlog_enforcer/library_hider.py b/steam_backlog_enforcer/library_hider.py index 990538f..4872ec6 100644 --- a/steam_backlog_enforcer/library_hider.py +++ b/steam_backlog_enforcer/library_hider.py @@ -29,6 +29,11 @@ import time import requests import websockets +from steam_backlog_enforcer._desktop_env import ( + desktop_env_args, + desktop_runtime_dir, + desktop_session_ready, +) from steam_backlog_enforcer._steam_state import steam_update_in_progress logger = logging.getLogger(__name__) @@ -75,6 +80,28 @@ class SteamUpdateInProgressError(SteamUnavailableError): """ +class DesktopSessionNotReadyError(SteamUnavailableError): + """Raised to defer a Steam launch until the user's session exists. + + Subclasses :class:`SteamUnavailableError` so existing callers already + degrade gracefully (skip this pass, retry next loop). Launching Steam + before ``/run/user/`` exists hands every Wine child a runtime dir + that is not there, so it falls back from winepulse to winealsa and stays + that way for the whole session — the Kingdom Come: Deliverance II startup + crash. Waiting a pass costs 3s; getting it wrong costs a reboot. + """ + + +def _desktop_uid(user: str | None) -> int: + """Resolve *user* to a uid, defaulting to the usual first desktop uid.""" + if user: + try: + return pwd.getpwnam(user).pw_uid + except KeyError: + pass + return 1000 + + def steam_is_installed() -> bool: """Return True if the real Steam client binary is present.""" return Path(_STEAM_BINARY).exists() @@ -270,6 +297,19 @@ def ensure_steam_debug_port() -> None: msg = f"Steam is not installed ({_STEAM_BINARY} does not exist)" raise SteamUnavailableError(msg) + # Never launch Steam into a session that does not exist yet: it would get + # a missing XDG_RUNTIME_DIR, silently fall back to winealsa, and stay + # audio-broken until the next restart. Defer instead — the loop retries. + real_user = _resolve_desktop_user() + if os.geteuid() == 0 and not desktop_session_ready(_desktop_uid(real_user)): + msg = ( + "Deferring Steam launch: the desktop session's runtime directory " + f"({desktop_runtime_dir(_desktop_uid(real_user))}) does not exist " + "yet. Launching now would leave Steam without audio; will retry." + ) + logger.info(msg) + raise DesktopSessionNotReadyError(msg) + logger.info("Steam CDP port not available — (re)starting Steam...") if _is_steam_running(): # Never bounce a running Steam while a game update is downloading or @@ -494,23 +534,13 @@ def _run_as_user(cmd: list[str], user: str | None) -> None: """Run a command, dropping to *user* if currently root.""" _reap_spawned() if os.geteuid() == 0 and user and user != "root": - try: - pw = pwd.getpwnam(user) - uid = pw.pw_uid - except KeyError: - uid = 1000 - - dbus_default = f"unix:path=/run/user/{uid}/bus" - dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default) - xauth = os.environ.get("XAUTHORITY", f"/home/{user}/.Xauthority") + uid = _desktop_uid(user) full_cmd = [ "sudo", "-u", user, "env", - f"DISPLAY={os.environ.get('DISPLAY', ':0')}", - f"XAUTHORITY={xauth}", - f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}", + *desktop_env_args(user, uid), *cmd, ] else: diff --git a/steam_backlog_enforcer/tests/test_desktop_env.py b/steam_backlog_enforcer/tests/test_desktop_env.py new file mode 100644 index 0000000..3b3515a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_desktop_env.py @@ -0,0 +1,235 @@ +"""Tests for _desktop_env — the env allowlist for desktop-session launches.""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer._desktop_env import ( + desktop_env_args, + desktop_runtime_dir, + desktop_session_ready, +) +from steam_backlog_enforcer.library_hider import ( + DesktopSessionNotReadyError, + _desktop_uid, + _run_as_user, + ensure_steam_debug_port, +) + +PKG = "steam_backlog_enforcer.library_hider" +ENV_PKG = "steam_backlog_enforcer._desktop_env" + + +def _env_value(args: list[str], name: str) -> str | None: + """Return the value assigned to *name* in a list of ``VAR=value`` args.""" + prefix = f"{name}=" + for arg in args: + if arg.startswith(prefix): + return arg[len(prefix) :] + return None + + +class TestDesktopEnvArgs: + """Tests for desktop_env_args.""" + + def test_exports_xdg_runtime_dir_for_uid(self) -> None: + """XDG_RUNTIME_DIR must be present — Wine needs it to find PulseAudio. + + Without it winepulse.drv cannot reach $XDG_RUNTIME_DIR/pulse/native, + Wine silently falls back to winealsa.drv, and Proton games get raw + ALSA devices instead of the session sink. + """ + args = desktop_env_args("bob", 1000) + assert _env_value(args, "XDG_RUNTIME_DIR") == "/run/user/1000" + + def test_runtime_dir_follows_uid(self) -> None: + """The path is derived from the target uid, not hardcoded.""" + args = desktop_env_args("bob", 1234) + assert _env_value(args, "XDG_RUNTIME_DIR") == "/run/user/1234" + + def test_root_runtime_dir_does_not_leak(self) -> None: + """Root's own XDG_RUNTIME_DIR must never reach the desktop child. + + The enforcer runs as root under systemd. Reading the ambient value + would hand the child /run/user/0, which it cannot use. + """ + with patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/run/user/0"}): + args = desktop_env_args("bob", 1000) + assert _env_value(args, "XDG_RUNTIME_DIR") == "/run/user/1000" + + def test_dbus_address_defaults_to_uid_path(self) -> None: + """DBUS falls back to the uid-derived socket when unset.""" + env_copy = os.environ.copy() + env_copy.pop("DBUS_SESSION_BUS_ADDRESS", None) + with patch.dict(os.environ, env_copy, clear=True): + args = desktop_env_args("bob", 1000) + expected = "unix:path=/run/user/1000/bus" + assert _env_value(args, "DBUS_SESSION_BUS_ADDRESS") == expected + + def test_dbus_address_prefers_ambient_value(self) -> None: + """An explicit DBUS address in the environment still wins.""" + with patch.dict( + os.environ, + {"DBUS_SESSION_BUS_ADDRESS": "unix:path=/custom/bus"}, + ): + args = desktop_env_args("bob", 1000) + assert _env_value(args, "DBUS_SESSION_BUS_ADDRESS") == "unix:path=/custom/bus" + + def test_xauthority_defaults_to_user_home(self) -> None: + """XAUTHORITY falls back to the target user's home.""" + env_copy = os.environ.copy() + env_copy.pop("XAUTHORITY", None) + with patch.dict(os.environ, env_copy, clear=True): + args = desktop_env_args("bob", 1000) + assert _env_value(args, "XAUTHORITY") == "/home/bob/.Xauthority" + + def test_xauthority_prefers_ambient_value(self) -> None: + """An explicit XAUTHORITY in the environment still wins.""" + with patch.dict(os.environ, {"XAUTHORITY": "/custom/xauth"}): + args = desktop_env_args("bob", 1000) + assert _env_value(args, "XAUTHORITY") == "/custom/xauth" + + def test_display_defaults_to_zero(self) -> None: + """DISPLAY falls back to :0.""" + env_copy = os.environ.copy() + env_copy.pop("DISPLAY", None) + with patch.dict(os.environ, env_copy, clear=True): + args = desktop_env_args("bob", 1000) + assert _env_value(args, "DISPLAY") == ":0" + + def test_display_prefers_ambient_value(self) -> None: + """An explicit DISPLAY in the environment still wins.""" + with patch.dict(os.environ, {"DISPLAY": ":1"}): + args = desktop_env_args("bob", 1000) + assert _env_value(args, "DISPLAY") == ":1" + + +class TestRunAsUserUsesSharedBuilder: + """The privileged launch path must go through desktop_env_args.""" + + def test_launch_command_carries_runtime_dir(self) -> None: + """A root-launched Steam gets XDG_RUNTIME_DIR for the desktop uid. + + This is the regression guard for the KCD2 startup crash: the enforcer + launched Steam without XDG_RUNTIME_DIR, so every Proton child fell + back to raw ALSA and the game died in its Bink startup video. + """ + mock_pw = MagicMock() + mock_pw.pw_uid = 1000 + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam"], "bob") + cmd = mock_popen.call_args[0][0] + assert cmd[:4] == ["sudo", "-u", "bob", "env"] + assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd + assert cmd[-1] == "steam" + + def test_unknown_user_falls_back_to_uid_1000(self) -> None: + """An unresolvable user still yields a usable runtime dir.""" + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam"], "ghost") + cmd = mock_popen.call_args[0][0] + assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd + + +class TestDesktopUid: + """Tests for _desktop_uid.""" + + def test_resolves_known_user(self) -> None: + mock_pw = MagicMock() + mock_pw.pw_uid = 1234 + with patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw): + assert _desktop_uid("bob") == 1234 + + def test_unknown_user_falls_back(self) -> None: + with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError): + assert _desktop_uid("ghost") == 1000 + + def test_no_user_falls_back(self) -> None: + assert _desktop_uid(None) == 1000 + + +class TestDesktopSessionReady: + """Tests for desktop_session_ready.""" + + def test_ready_when_runtime_dir_exists(self) -> None: + with patch(f"{ENV_PKG}.Path.is_dir", return_value=True): + assert desktop_session_ready(1000) is True + + def test_not_ready_when_runtime_dir_missing(self) -> None: + with patch(f"{ENV_PKG}.Path.is_dir", return_value=False): + assert desktop_session_ready(1000) is False + + def test_runtime_dir_path(self) -> None: + assert desktop_runtime_dir(1000) == "/run/user/1000" + + +class TestLaunchDefersUntilSessionReady: + """The enforcer must not launch Steam into a session that does not exist. + + Regression guard for the boot race: the enforcer is a system service + ordered only after the network, and on a measured boot it started 54ms + before user-runtime-dir@1000. A Steam launched in that window comes up + with a runtime dir that is not there, silently falls back to winealsa, + and stays audio-broken until something restarts it. + """ + + def test_defers_when_runtime_dir_missing(self) -> None: + with ( + patch(f"{PKG}._steam_has_debug_port", return_value=False), + patch(f"{PKG}.steam_is_installed", return_value=True), + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}._resolve_desktop_user", return_value="bob"), + patch(f"{PKG}._desktop_uid", return_value=1000), + patch(f"{PKG}.desktop_session_ready", return_value=False), + patch(f"{PKG}._shutdown_steam") as mock_shutdown, + patch(f"{PKG}._launch_steam_with_debug") as mock_launch, + pytest.raises(DesktopSessionNotReadyError), + ): + ensure_steam_debug_port() + + # Neither bounce a working Steam nor start a broken one. + mock_launch.assert_not_called() + mock_shutdown.assert_not_called() + + def test_launches_when_runtime_dir_present(self) -> None: + with ( + patch(f"{PKG}._steam_has_debug_port", return_value=False), + patch(f"{PKG}.steam_is_installed", return_value=True), + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}._resolve_desktop_user", return_value="bob"), + patch(f"{PKG}._desktop_uid", return_value=1000), + patch(f"{PKG}.desktop_session_ready", return_value=True), + patch(f"{PKG}._is_steam_running", return_value=False), + patch(f"{PKG}._launch_steam_with_debug") as mock_launch, + patch(f"{PKG}._wait_for_cdp_ready", return_value=True), + patch(f"{PKG}._wait_for_collections_ready", return_value=True), + ): + ensure_steam_debug_port() + mock_launch.assert_called_once() + + def test_non_root_does_not_gate(self) -> None: + """Run interactively, the ambient session is already the user's.""" + with ( + patch(f"{PKG}._steam_has_debug_port", return_value=False), + patch(f"{PKG}.steam_is_installed", return_value=True), + patch(f"{PKG}.os.geteuid", return_value=1000), + patch(f"{PKG}._resolve_desktop_user", return_value="bob"), + patch(f"{PKG}.desktop_session_ready", return_value=False), + patch(f"{PKG}._is_steam_running", return_value=False), + patch(f"{PKG}._launch_steam_with_debug") as mock_launch, + patch(f"{PKG}._wait_for_cdp_ready", return_value=True), + patch(f"{PKG}._wait_for_collections_ready", return_value=True), + ): + ensure_steam_debug_port() + mock_launch.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_game_install.py b/steam_backlog_enforcer/tests/test_game_install.py index aff3d58..096640c 100644 --- a/steam_backlog_enforcer/tests/test_game_install.py +++ b/steam_backlog_enforcer/tests/test_game_install.py @@ -259,11 +259,59 @@ class TestEnsureSteamRunning: "steam_backlog_enforcer.game_install._get_uid_gid_for_user", return_value=(1000, 1000), ), + patch( + "steam_backlog_enforcer.game_install.desktop_session_ready", + return_value=True, + ), patch("steam_backlog_enforcer.game_install.time.sleep"), ): _ensure_steam_running() mock_popen.assert_called_once() + # Assert the env vector, not just that a launch happened: dropping + # XDG_RUNTIME_DIR here is silent, and cost a Proton game its audio + # backend (winepulse -> winealsa) and a crash on startup. + cmd = mock_popen.call_args[0][0] + assert cmd[:4] == ["sudo", "-u", "alice", "env"] + assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd + + def test_defers_when_runtime_dir_missing(self) -> None: + """Do not start Steam before the desktop session's runtime dir exists. + + Launching in that window yields a Steam whose Wine children cannot + find PulseAudio, and it stays that way for the whole session. The + enforce loop retries every 3s, so deferring is nearly free. + """ + mock_result = MagicMock(returncode=1) + with ( + patch( + "steam_backlog_enforcer.game_install.subprocess.run", + return_value=mock_result, + ), + patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen, + patch( + "steam_backlog_enforcer.game_install.os.geteuid", + return_value=0, + ), + patch( + "steam_backlog_enforcer.game_install._get_real_user", + return_value="alice", + ), + patch( + "steam_backlog_enforcer.game_install._get_uid_gid_for_user", + return_value=(1000, 1000), + ), + patch( + "steam_backlog_enforcer.game_install.desktop_session_ready", + return_value=False, + ), + patch("steam_backlog_enforcer.game_install.time.sleep") as mock_sleep, + ): + _ensure_steam_running() + + mock_popen.assert_not_called() + mock_sleep.assert_not_called() + def test_pgrep_not_found(self) -> None: with ( patch(