Pass XDG_RUNTIME_DIR when launching Steam as the desktop user
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

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>
This commit is contained in:
Krzysztof Rudnicki 2026-07-26 14:39:38 +02:00
parent 243e11bc00
commit bfdd69453e
5 changed files with 411 additions and 19 deletions

View File

@ -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 <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}",
]

View File

@ -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",
]

View File

@ -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/<uid>`` 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:

View File

@ -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()

View File

@ -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(