fast install: never report a failed run as success; queue the manifest first

The BG3 fast install of 2026-10-23 downloaded 160 GB, was refused by the
installer, and the web job still ended on a full bar, "Done!" and Succeeded
while Steam restarted the download from 0%.

- A failed run's reason is read from the installer's own run log (its ERROR
  lines). A waiting job prints "Failed: fast install of <game>: <reason>",
  which fails the job; `install` no longer says "Done!" after it.
- Daemon-started failures stay on the Dashboard's Fast install card (and in
  ctl status, `error`) until a new run starts or the game is installed.
- install_game writes Steam's "needs downloading" manifest before the
  installer runs: it only finishes a manifest it finds, so fresh installs
  were refused for want of one.
- Command building moved to _fast_install_cmd (250-line cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYmCezqhUjUmMisF5YtM4F
This commit is contained in:
Krzysztof Rudnicki 2026-10-24 08:36:57 +02:00
parent 0d93539944
commit dc22bd557c
23 changed files with 506 additions and 137 deletions

View File

@ -205,9 +205,12 @@ hard-codes it.
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
`gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`.
- `status` also lists the daemon's live steam-game-installer runs as
`fast_installs: [{app_id, name, phase, percent, elapsed_seconds}]`, read
from its log by the enforce loop; `percent` is the current depot's, or null.
- `status` also lists the daemon's steam-game-installer runs as
`fast_installs: [{app_id, name, phase, percent, elapsed_seconds, error}]`,
read from its log by the enforce loop; `percent` is the current depot's, or
null. A failed run stays listed with `phase: "failed"` and `error` set to
the installer's own ERROR lines until a new run starts or the game is fully
installed; `error` is null while a run is live.
## Verification record (2026-10-10)

View File

@ -140,6 +140,7 @@ def _fast_install_info(run: dict[str, object]) -> FastInstallInfo:
phase=str(run["phase"]),
percent=None if percent is None else float(cast("float", percent)),
elapsed_seconds=int(cast("int", run["elapsed_seconds"])),
error=None if run.get("error") is None else str(run["error"]),
)

View File

@ -22,6 +22,8 @@ class FastInstallInfo:
phase: str
percent: float | None
elapsed_seconds: int
# Why the run failed (``phase`` is then ``failed``); None while it runs.
error: str | None = None
@dataclass(frozen=True)

View File

@ -27,29 +27,29 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
import os
from pathlib import Path
import pwd
import re
import shutil
import signal
import subprocess
import sys
import time
from typing import TYPE_CHECKING
from steam_backlog_enforcer import _fast_install_progress as _fip
from steam_backlog_enforcer import config as _config
from steam_backlog_enforcer._desktop_env import desktop_user_cmd, resolve_desktop_user
from steam_backlog_enforcer._desktop_env import resolve_desktop_user
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._fast_install_cmd import build_cmd, installer_path
from steam_backlog_enforcer._fast_install_failure import failure_reason
from steam_backlog_enforcer._steam_restart_guard import game_is_running
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
from steam_backlog_enforcer._steam_state import is_game_fully_installed
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
logger = logging.getLogger(__name__)
_INSTALLER = Path("src", "steam-game-installer", "run.sh")
_INSTALLER_LOG = "~/.local/state/steam-game-installer/latest.log"
# Depot content only: the app itself is ours to choose, the language is not
# worth a prompt nobody is there to answer.
_LANGUAGE = "english"
# depotdownloader prints a line per finished chunk, so a healthy download is
# never silent this long; a login prompt waiting on /dev/null is.
STALL_SECONDS = 15 * 60
@ -67,39 +67,10 @@ class _DetachedRun:
_RUNS: list[_DetachedRun] = []
# Apps whose installer run failed in this process. They go straight to the
# steam:// fallback from then on, so a broken login cannot close Steam on
# every 3-second pass.
_FAILED: set[int] = set()
def _installer_path(user: str | None) -> Path | None:
"""steam-game-installer's ``run.sh`` in the desktop user's home, if present.
Resolved from the passwd entry rather than ``Path.home()``: the daemon runs
as root, and the installer, its venv and its credentials are per-user.
"""
try:
home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home()
except KeyError:
return None
path = home / _INSTALLER
return path if path.is_file() else None
def _steam_account_name() -> str | None:
"""The account Steam itself last signed in with, from ``loginusers.vdf``.
Read from Steam's own state rather than a copy in the enforcer config, so
it cannot drift from the account that actually owns the games.
"""
path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf"
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
match = re.search(r'"AccountName"\s+"([^"]+)"', content)
return match.group(1) if match else None
# Apps whose installer run failed in this process, with the reason. They go
# straight to the steam:// fallback from then on, so a broken login cannot
# close Steam on every 3-second pass.
_FAILED: dict[int, str] = {}
def _refusal() -> str | None:
@ -109,32 +80,16 @@ def _refusal() -> str | None:
return None
def _build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]:
"""The installer invocation, dropped to the desktop user when root."""
cmd = [
str(installer),
# install.sh reaches for yay; dependencies are installed once, by hand.
"--no-deps",
"--appid",
str(app_id),
"--yes",
"--language",
_LANGUAGE,
# Finishes the manifest from depotdownloader's own data, under the same
# fail-closed checks: no second login, no 70 GB re-hash by steamcmd.
"--skip-steamcmd",
]
account = _steam_account_name()
if account:
cmd += ["--username", account]
return desktop_user_cmd(cmd, user)
def is_fast_installing(app_id: int) -> bool:
"""Whether this process has a live detached installer run for *app_id*."""
return any(run.app_id == app_id for run in _RUNS)
def fast_install_failure(app_id: int) -> str | None:
"""Why this process's installer run for *app_id* failed, if it did."""
return _FAILED.get(app_id)
def _stalled(run: _DetachedRun) -> bool:
"""Whether a live run has printed nothing for :data:`STALL_SECONDS`."""
try:
@ -167,15 +122,16 @@ def poll_fast_installs() -> None:
if returncode == 0:
logger.info("steam-game-installer finished AppID=%d.", run.app_id)
continue
_FAILED.add(run.app_id)
_FAILED[run.app_id] = failure_reason(run.log_path, returncode)
_fip.mark_failed(run.app_id, _FAILED[run.app_id])
logger.warning(
"steam-game-installer failed for AppID=%d (exit %d); falling back "
"to Steam's own download. Log: %s",
"steam-game-installer failed for AppID=%d: %s. Falling back to "
"Steam's own download. Log: %s",
run.app_id,
returncode,
_FAILED[run.app_id],
run.log_path,
)
_fip.poll_trackers({run.app_id for run in _RUNS})
_fip.poll_trackers({run.app_id for run in _RUNS}, is_game_fully_installed)
def _spawn_detached(cmd: list[str], app_id: int, label: str = "") -> None:
@ -211,12 +167,26 @@ def _wait_detached(app_id: int, label: str) -> bool:
time.sleep(_WAIT_POLL_SECONDS)
poll_fast_installs()
reporter.report(time.monotonic())
return app_id not in _FAILED
if app_id not in _FAILED:
return True
# "Failed" first: a job takes such a line as its verdict, so the run can
# no longer end on a full progress bar and "Succeeded".
_echo(
f"Failed: fast install of {label}: {_FAILED[app_id]}. Steam's own "
f"download takes over (log: {_INSTALLER_LOG})."
)
return False
def fast_install(app_id: int, label: str) -> bool:
def fast_install(
app_id: int, label: str, prepare: Callable[[], object] = lambda: None
) -> bool:
"""Install *app_id* with steam-game-installer.
*prepare* runs once the run is sure to start, before it does: the
installer only finishes a manifest it finds, so this is where a fresh
install gets one.
Returns:
True if the game was installed (foreground) or the run was started
(detached); False if the caller should fall back to Steam's download.
@ -224,7 +194,7 @@ def fast_install(app_id: int, label: str) -> bool:
if app_id in _FAILED:
return False
user = resolve_desktop_user()
installer = _installer_path(user)
installer = installer_path(user)
if installer is None:
logger.warning("steam-game-installer not found; using Steam's download.")
return False
@ -233,7 +203,8 @@ def fast_install(app_id: int, label: str) -> bool:
_echo(f" Fast install of {label} skipped: {reason}.")
return False
cmd = _build_cmd(installer, app_id, user)
cmd = build_cmd(installer, app_id, user)
prepare()
if not sys.stdin.isatty():
_spawn_detached(cmd, app_id, label)
return os.geteuid() == 0 or _wait_detached(app_id, label)
@ -242,9 +213,9 @@ def fast_install(app_id: int, label: str) -> bool:
returncode = subprocess.run(cmd, check=False).returncode
if returncode == 0:
return True
_FAILED.add(app_id)
_FAILED[app_id] = f"exit {returncode}"
_echo(
f" steam-game-installer failed (exit {returncode}); falling back to "
f"Steam's own download. Log: {_INSTALLER_LOG}"
f"Failed: fast install of {label} (exit {returncode}, reasons above). "
f"Steam's own download takes over (log: {_INSTALLER_LOG})."
)
return False

View File

@ -0,0 +1,71 @@
"""How steam-game-installer is invoked: where it lives, as whom, with what argv.
Split out of :mod:`_fast_install`, which runs and reaps the installer; this
half only decides the command line, every choice it would otherwise prompt for
pinned in advance.
"""
from __future__ import annotations
from pathlib import Path
import pwd
import re
from steam_backlog_enforcer._desktop_env import desktop_user_cmd
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
_INSTALLER = Path("src", "steam-game-installer", "run.sh")
# Depot content only: the app itself is ours to choose, the language is not
# worth a prompt nobody is there to answer.
_LANGUAGE = "english"
def installer_path(user: str | None) -> Path | None:
"""steam-game-installer's ``run.sh`` in the desktop user's home, if present.
Resolved from the passwd entry rather than ``Path.home()``: the daemon runs
as root, and the installer, its venv and its credentials are per-user.
"""
try:
home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home()
except KeyError:
return None
path = home / _INSTALLER
return path if path.is_file() else None
def steam_account_name() -> str | None:
"""The account Steam itself last signed in with, from ``loginusers.vdf``.
Read from Steam's own state rather than a copy in the enforcer config, so
it cannot drift from the account that actually owns the games.
"""
path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf"
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
match = re.search(r'"AccountName"\s+"([^"]+)"', content)
return match.group(1) if match else None
def build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]:
"""The installer invocation, dropped to the desktop user when root."""
cmd = [
str(installer),
# install.sh reaches for yay; dependencies are installed once, by hand.
"--no-deps",
"--appid",
str(app_id),
"--yes",
"--language",
_LANGUAGE,
# Finishes the manifest from depotdownloader's own data, under the same
# fail-closed checks: no second login, no 70 GB re-hash by steamcmd.
"--skip-steamcmd",
]
account = steam_account_name()
if account:
cmd += ["--username", account]
return desktop_user_cmd(cmd, user)

View File

@ -0,0 +1,55 @@
"""Why a steam-game-installer run failed, in the installer's own words.
A bare exit status hid the BG3 run of 2026-10-23: 160 GB downloaded, then a
refusal to mark it installed, and the job still read "Succeeded". The console
output ends with ``Run log (send this): <path>``; that run log tags every line
with its level, so its ERROR lines are exactly the reasons the run gave up.
Both files are read from the end only: a depotdownloader run writes a line
per downloaded file, tens of megabytes of them.
"""
from __future__ import annotations
from pathlib import Path
import re
from typing import Final
# The reasons and the run log line are printed last; 64 KiB holds them.
_TAIL_BYTES: Final = 1 << 16
_RUN_LOG: Final = re.compile(r"^Run log(?: \(send this\))?: (/.+)$")
_ERROR: Final = re.compile(r"^\S+ \S+ ERROR\s+[\w.]+: (.*)$")
def _tail_lines(path: Path) -> list[str]:
"""The last lines of *path* (empty if unreadable), first partial one dropped."""
try:
with path.open("rb") as log:
size = log.seek(0, 2)
log.seek(max(0, size - _TAIL_BYTES))
data = log.read()
except OSError:
return []
lines = data.decode("utf-8", "replace").splitlines()
return lines[1:] if size > _TAIL_BYTES else lines
def _run_log_errors(console: list[str]) -> list[str]:
"""ERROR messages of the run log the console output names last."""
for line in reversed(console):
if match := _RUN_LOG.match(line.strip()):
run_log = _tail_lines(Path(match.group(1)))
return [m.group(1).strip() for ln in run_log if (m := _ERROR.match(ln))]
return []
def failure_reason(console_log: Path, returncode: int) -> str:
"""One line saying why the run that just wrote *console_log* failed."""
console = _tail_lines(console_log)
errors = _run_log_errors(console)
if errors:
# Callers add their own full stop.
return " ".join(errors).rstrip(".")
# Died before the installer could log a reason (a traceback, a kill).
last = next((ln.strip() for ln in reversed(console) if ln.strip()), "")
return f"exit {returncode}: {last}" if last else f"exit {returncode}"

View File

@ -40,6 +40,7 @@ from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._progress import current_progress
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
STARTING: Final = "starting"
@ -146,6 +147,9 @@ class InstallTracker:
# Live detached runs of this process by app id, with the name to show. The
# enforce loop polls them; ctl connection threads read snapshots.
_TRACKERS: dict[int, tuple[str, InstallTracker]] = {}
# Failed runs by app id: (name, reason). The daemon has no job to print a
# failure to, so it stays listed until a new run or the game is installed.
_FAILURES: dict[int, tuple[str, str]] = {}
_LOCK = threading.Lock()
@ -153,13 +157,25 @@ def track(app_id: int, name: str, log_path: Path, *, offset: int, now: float) ->
"""Start following a run just spawned with its output at *offset* of the log."""
with _LOCK:
_TRACKERS[app_id] = (name, InstallTracker(log_path, offset=offset, started=now))
_FAILURES.pop(app_id, None)
def poll_trackers(live: set[int]) -> None:
"""Read new log lines of the *live* runs and forget every other one."""
def mark_failed(app_id: int, reason: str) -> None:
"""List *app_id*'s run as failed with *reason*, under its tracked name."""
with _LOCK:
entry = _TRACKERS.get(app_id)
_FAILURES[app_id] = (entry[0] if entry else f"AppID={app_id}", reason)
def poll_trackers(
live: set[int], installed: Callable[[int], bool] = lambda _app_id: False
) -> None:
"""Read the *live* runs' new lines; drop other runs and *installed* failures."""
with _LOCK:
for app_id in [aid for aid in _TRACKERS if aid not in live]:
del _TRACKERS[app_id]
for app_id in [aid for aid in _FAILURES if installed(aid)]:
del _FAILURES[app_id]
for _name, tracker in _TRACKERS.values():
tracker.poll()
@ -172,9 +188,15 @@ def progress_of(app_id: int, now: float) -> InstallProgress | None:
def fast_install_status(now: float) -> list[dict[str, object]]:
"""Every tracked run as the ctl ``status`` op reports it (no file I/O)."""
"""Every tracked and failed run as the ctl ``status`` op reports it (no I/O)."""
with _LOCK:
entries = [(aid, name, t.snapshot(now)) for aid, (name, t) in _TRACKERS.items()]
failed = [
{"app_id": aid, "name": name, "phase": "failed", "percent": None}
| {"elapsed_seconds": 0, "error": reason}
for aid, (name, reason) in _FAILURES.items()
if aid not in _TRACKERS
]
return [
{
"app_id": app_id,
@ -182,9 +204,10 @@ def fast_install_status(now: float) -> list[dict[str, object]]:
"phase": snap.phase,
"percent": None if snap.percent is None else round(snap.percent, 2),
"elapsed_seconds": snap.elapsed_seconds,
"error": None,
}
for app_id, name, snap in entries
]
] + failed
class WaitReporter:

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from functools import partial
import logging
import os
import shutil
@ -168,7 +169,10 @@ def install_game(
if ready is not None:
return ready
if fast_install(app_id, label):
# The installer only finishes a manifest it finds: queue a fresh install first.
if fast_install(
app_id, label, partial(_queue_download, app_id, game_name, steam_id)
):
return True
# Steam already has a download of its own going: let it carry on, rather
# than overwrite its manifest with the fabricated one below.
@ -181,6 +185,16 @@ def install_game(
return True
logger.debug("steam:// protocol failed; falling back to manifest")
if not _queue_download(app_id, game_name, steam_id):
return False
_ensure_steam_running() # so it picks the manifest up
return True
def _queue_download(app_id: int, game_name: str, steam_id: str) -> bool:
"""Make Steam download *app_id* with a minimal appmanifest; False if unwritable."""
if is_game_installed(app_id):
return True
# Build a minimal appmanifest. StateFlags 1026 = UpdateRequired (2) +
# UpdateStarted (1024), which tells Steam "this app needs downloading".
manifest_content = (
@ -219,7 +233,6 @@ def install_game(
)
manifest_path = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
try:
with manifest_path.open("w", encoding="utf-8") as fh:
fh.write(manifest_content)
@ -230,13 +243,8 @@ def install_game(
if os.geteuid() == 0 and real_user and real_user != "root":
uid, gid = _get_uid_gid_for_user(real_user)
os.chown(manifest_path, uid, gid)
logger.info("Created appmanifest for %s — Steam will auto-download", label)
logger.info("Created appmanifest for %s — Steam will auto-download", game_name)
except OSError:
logger.exception("Failed to create appmanifest for %s", label)
logger.exception("Failed to create appmanifest for %s", game_name)
return False
# Make sure Steam is running so it picks up the manifest.
_ensure_steam_running()
return True

View File

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from steam_backlog_enforcer._fast_install import fast_install_failure
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._prompter import (
PromptOption,
@ -115,10 +116,13 @@ def cmd_install(config: Config, state: State) -> None:
# install_game only *starts* the download (fast installer or steam://);
# there is no download-progress loop to report on yet.
current_progress().phase(f"Starting install of {name}")
if install_game(app_id, name, config.steam_id, use_steam_protocol=True):
if not install_game(app_id, name, config.steam_id, use_steam_protocol=True):
_echo("Failed to start the install.")
elif fast_install_failure(app_id) is None:
_echo("Done!")
else:
_echo("Failed to start the install.")
# The fast install already printed its "Failed: ..." line.
_echo("Steam's own download was started instead.")
def cmd_hide(config: Config, state: State) -> None:

View File

@ -18,7 +18,11 @@ from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _fast_install, _fast_install_progress
from steam_backlog_enforcer import (
_fast_install,
_fast_install_cmd,
_fast_install_progress,
)
if TYPE_CHECKING:
from collections.abc import Iterator
@ -40,11 +44,14 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
_fast_install._RUNS.clear()
_fast_install._FAILED.clear()
_fast_install_progress._TRACKERS.clear()
_fast_install_progress._FAILURES.clear()
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
patches += [
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
patch.object(_fast_install, "_INSTALLER", tmp_path / "no-installer" / "run.sh"),
patch.object(_fast_install, "STEAMAPPS_PATH", fake_steamapps),
patch.object(
_fast_install_cmd, "_INSTALLER", tmp_path / "no-installer" / "run.sh"
),
patch.object(_fast_install_cmd, "STEAMAPPS_PATH", fake_steamapps),
]
for p in patches:
p.start()
@ -56,3 +63,4 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
_fast_install._RUNS.clear()
_fast_install._FAILED.clear()
_fast_install_progress._TRACKERS.clear()
_fast_install_progress._FAILURES.clear()

View File

@ -145,6 +145,14 @@ class TestTypedWrappers:
FastInstallInfo(7, "G", "downloading depot 1/8", 46.9, 60),
)
def test_status_failed_fast_install(self) -> None:
run = {"app_id": 7, "name": "G", "phase": "failed", "percent": None}
run |= {"elapsed_seconds": 0, "error": "exit 1"}
data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]}
with self._call(data):
info = _ctl_client.status()
assert info.fast_installs[0].error == "exit 1"
def test_journal_tail(self) -> None:
with self._call({"lines": ["a", 2]}) as call:
assert _ctl_client.journal_tail(9) == ["a", "2"]

View File

@ -0,0 +1,66 @@
"""Tests for a failed daemon-started fast install staying visible.
The root daemon has no job to print to: before, a failed run's tracker was
dropped and the Dashboard card simply vanished, as if nothing had happened.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import _fast_install
from steam_backlog_enforcer import _fast_install_progress as fip
if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer._fast_install"
def _failed_run(tmp_path: Path, app_id: int = 7) -> None:
"""A tracked daemon run of *app_id* that has just exited 1."""
log = tmp_path / "log"
log.write_text("Restarting the Steam client...\n")
fip.track(app_id, "Baldur's Gate 3", log, offset=0, now=0.0)
proc = MagicMock(pid=4321)
proc.poll.return_value = 1
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log))
class TestFailedRunsStayListed:
def test_status_carries_the_reason(self, tmp_path: Path) -> None:
_failed_run(tmp_path)
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
_fast_install.poll_fast_installs()
assert fip.fast_install_status(now=9.0) == [
{
"app_id": 7,
"name": "Baldur's Gate 3",
"phase": "failed",
"percent": None,
"elapsed_seconds": 0,
"error": "exit 1: Restarting the Steam client...",
}
]
def test_live_runs_have_no_error(self, tmp_path: Path) -> None:
fip.track(8, "G", tmp_path / "log", offset=0, now=0.0)
fip.poll_trackers({8})
assert fip.fast_install_status(now=1.0)[0]["error"] is None
def test_gone_once_the_game_is_installed(self, tmp_path: Path) -> None:
"""Steam's own download finished: the failure is history."""
_failed_run(tmp_path)
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
_fast_install.poll_fast_installs()
with patch(f"{PKG}.is_game_fully_installed", return_value=True):
_fast_install.poll_fast_installs()
assert fip.fast_install_status(now=9.0) == []
def test_a_new_run_replaces_the_failure(self, tmp_path: Path) -> None:
_failed_run(tmp_path)
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
_fast_install.poll_fast_installs()
fip.track(7, "Baldur's Gate 3", tmp_path / "log", offset=0, now=0.0)
assert [s["phase"] for s in fip.fast_install_status(now=1.0)] == [fip.STARTING]

View File

@ -0,0 +1,54 @@
"""Tests for _fast_install_failure: a failed run explained in the installer's words.
The text is the BG3 run of 2026-10-23, which downloaded 160 GB and then
refused; all the job showed was a finished bar and "Succeeded".
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from steam_backlog_enforcer._fast_install_failure import failure_reason
if TYPE_CHECKING:
from pathlib import Path
_REFUSAL = (
"2026-10-23 22:33:13,889 ERROR steam_installer: Refusing to mark "
"Baldur's Gate 3 as installed:\n"
"2026-10-23 22:33:13,889 ERROR steam_installer: - depot(s) 1419654 "
"have no cached manifest -- the download did not finish\n"
"2026-10-23 22:33:13,889 INFO steam_installer: Restarting the Steam client...\n"
)
def _console(tmp_path: Path, run_log: Path) -> Path:
console = tmp_path / "fast_install-1000.log"
console.write_text(
f"Run log: {tmp_path / 'old.log'}\n"
" 99.99% /x/Gustav.pak (403.89 Mbit/s)\n"
"Refusing to mark Baldur's Gate 3 as installed:\n"
f"Run log (send this): {run_log}\n"
"Restarting the Steam client...\n"
)
return console
def test_reason_is_the_run_logs_error_lines(tmp_path: Path) -> None:
run_log = tmp_path / "run-1086940.log"
hello = "2026-10-23 22:13:21,605 INFO steam_installer: hi\n"
run_log.write_text(hello + _REFUSAL)
assert failure_reason(_console(tmp_path, run_log), 1) == (
"Refusing to mark Baldur's Gate 3 as installed: - depot(s) 1419654 have "
"no cached manifest -- the download did not finish"
)
def test_without_a_run_log_the_last_console_line_is_shown(tmp_path: Path) -> None:
console = tmp_path / "fast_install-0.log"
console.write_text("Traceback (most recent call last):\nOSError: disk full\n")
assert failure_reason(console, 1) == "exit 1: OSError: disk full"
def test_an_unreadable_console_log_still_names_the_exit(tmp_path: Path) -> None:
assert failure_reason(tmp_path / "missing.log", 3) == "exit 3"

View File

@ -1,4 +1,4 @@
"""Tests for _fast_install's pure helpers: paths, account, refusal, argv.
"""Tests for the fast install's pure helpers: paths, account, refusal, argv.
The run lifecycle (spawn, poll, fast_install) lives in test_fast_install_runs.py
to keep both files under the 250-line cap.
@ -10,9 +10,10 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import _fast_install, _steam_state
from steam_backlog_enforcer import _fast_install, _fast_install_cmd, _steam_state
PKG = "steam_backlog_enforcer._fast_install"
CMD = "steam_backlog_enforcer._fast_install_cmd"
class TestInstallerPath:
@ -26,44 +27,44 @@ class TestInstallerPath:
# real relative one back so the home join itself is what is tested.
relative = Path("src", "steam-game-installer", "run.sh")
with (
patch.object(_fast_install, "_INSTALLER", relative),
patch(f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)),
patch.object(_fast_install_cmd, "_INSTALLER", relative),
patch(f"{CMD}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)),
):
assert _fast_install._installer_path("kuhy") == installer
assert _fast_install_cmd.installer_path("kuhy") == installer
def test_missing_file_is_none(self, tmp_path: Path) -> None:
with patch(
f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)
f"{CMD}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)
):
assert _fast_install._installer_path("kuhy") is None
assert _fast_install_cmd.installer_path("kuhy") is None
def test_unknown_user_is_none(self) -> None:
with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("nobody")):
assert _fast_install._installer_path("nobody") is None
with patch(f"{CMD}.pwd.getpwnam", side_effect=KeyError("nobody")):
assert _fast_install_cmd.installer_path("nobody") is None
def test_no_user_uses_own_home(self, tmp_path: Path) -> None:
with patch(f"{PKG}.Path.home", return_value=tmp_path):
assert _fast_install._installer_path(None) is None
with patch(f"{CMD}.Path.home", return_value=tmp_path):
assert _fast_install_cmd.installer_path(None) is None
class TestSteamAccountName:
"""The username comes from Steam's own loginusers.vdf."""
def _write(self, content: str) -> None:
config = _fast_install.STEAMAPPS_PATH.parent / "config"
config = _fast_install_cmd.STEAMAPPS_PATH.parent / "config"
config.mkdir(parents=True, exist_ok=True)
(config / "loginusers.vdf").write_text(content)
def test_reads_account_name(self) -> None:
self._write('"users"\n{\n\t"7656"\n\t{\n\t\t"AccountName"\t\t"krzys"\n')
assert _fast_install._steam_account_name() == "krzys"
assert _fast_install_cmd.steam_account_name() == "krzys"
def test_no_account_field(self) -> None:
self._write('"users"\n{\n}\n')
assert _fast_install._steam_account_name() is None
assert _fast_install_cmd.steam_account_name() is None
def test_missing_file(self) -> None:
assert _fast_install._steam_account_name() is None
assert _fast_install_cmd.steam_account_name() is None
class TestRefusal:
@ -90,10 +91,10 @@ class TestBuildCmd:
def test_with_account(self) -> None:
with (
patch(f"{PKG}._steam_account_name", return_value="krzys"),
patch(f"{PKG}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd),
patch(f"{CMD}.steam_account_name", return_value="krzys"),
patch(f"{CMD}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd),
):
cmd = _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy")
cmd = _fast_install_cmd.build_cmd(Path("/i/run.sh"), 42, "kuhy")
assert cmd == [
"/i/run.sh",
"--no-deps",
@ -109,10 +110,10 @@ class TestBuildCmd:
def test_without_account_and_drops_privileges(self) -> None:
with (
patch(f"{PKG}._steam_account_name", return_value=None),
patch(f"{PKG}.desktop_user_cmd", return_value=["wrapped"]) as wrap,
patch(f"{CMD}.steam_account_name", return_value=None),
patch(f"{CMD}.desktop_user_cmd", return_value=["wrapped"]) as wrap,
):
assert _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy") == [
assert _fast_install_cmd.build_cmd(Path("/i/run.sh"), 42, "kuhy") == [
"wrapped"
]
argv, user = wrap.call_args.args

View File

@ -51,13 +51,13 @@ class TestJobWaitsForInstaller:
def _install(self, tmp_path: Path, spawn: MagicMock, euid: int) -> bool:
with (
patch(f"{PKG}._installer_path", return_value=_ready(tmp_path)),
patch(f"{PKG}.installer_path", return_value=_ready(tmp_path)),
patch(f"{PKG}._refusal", return_value=None),
patch(f"{PKG}.sys.stdin.isatty", return_value=False),
patch(f"{PKG}.os.geteuid", return_value=euid),
patch(f"{PKG}._spawn_detached", spawn),
patch(f"{PKG}.time.sleep"),
patch(f"{PKG}._echo"),
patch(f"{PKG}._echo") as self.echo,
):
return _fast_install.fast_install(7, "G")
@ -67,7 +67,18 @@ class TestJobWaitsForInstaller:
def test_failure_falls_back(self, tmp_path: Path) -> None:
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
assert {7} == _fast_install._FAILED
assert {7} == set(_fast_install._FAILED)
def test_failure_is_an_error_line_with_the_reason(self, tmp_path: Path) -> None:
"""A job turns a line starting "Failed" into its failed verdict."""
log = b"Refusing to mark G as installed:\nRestarting the Steam client...\n"
with patch(f"{PKG}._fip.current_progress"):
self._install(tmp_path, _fake_spawn(None, 1, log_text=log), 1000)
echoed = [c.args[0] for c in self.echo.call_args_list]
assert any(
line.startswith("Failed: fast install of G") and "exit 1" in line
for line in echoed
), echoed
def test_progress_is_reported_while_waiting(self, tmp_path: Path) -> None:
text = b"Shutting down the Steam client...\r\nDownloading via depot\r\n"

View File

@ -154,6 +154,7 @@ class TestRegistry:
"phase": "downloading depot 1/8",
"percent": 59.16,
"elapsed_seconds": 60,
"error": None,
}
]

View File

@ -34,13 +34,13 @@ class TestPollFastInstalls:
_run(1, 0, tmp_path / "log")
_fast_install.poll_fast_installs()
assert _fast_install._RUNS == []
assert set() == _fast_install._FAILED
assert not _fast_install._FAILED
def test_failure_is_remembered(self, tmp_path: Path) -> None:
_run(2, 3, tmp_path / "log")
_fast_install.poll_fast_installs()
assert _fast_install._RUNS == []
assert {2} == _fast_install._FAILED
assert {2} == set(_fast_install._FAILED)
def test_live_and_chatty_is_left_alone(self, tmp_path: Path) -> None:
log = tmp_path / "log"
@ -123,8 +123,8 @@ class TestFastInstall:
return installer
def test_previously_failed_goes_straight_to_fallback(self) -> None:
_fast_install._FAILED.add(7)
with patch(f"{PKG}._installer_path") as find:
_fast_install._FAILED[7] = "exit 1"
with patch(f"{PKG}.installer_path") as find:
assert _fast_install.fast_install(7, "G") is False
find.assert_not_called()
@ -134,7 +134,7 @@ class TestFastInstall:
def test_refused(self, tmp_path: Path) -> None:
with (
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}._refusal", return_value="a game is running"),
patch(f"{PKG}._echo") as echo,
patch(f"{PKG}.subprocess.run") as run,
@ -145,7 +145,7 @@ class TestFastInstall:
def test_no_terminal_spawns_detached(self, tmp_path: Path) -> None:
with (
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}._refusal", return_value=None),
patch(f"{PKG}.sys.stdin.isatty", return_value=False),
patch(f"{PKG}._spawn_detached") as spawn,
@ -157,23 +157,23 @@ class TestFastInstall:
def test_terminal_runs_in_foreground(self, tmp_path: Path) -> None:
with (
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}._refusal", return_value=None),
patch(f"{PKG}.sys.stdin.isatty", return_value=True),
patch(f"{PKG}._echo"),
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)),
):
assert _fast_install.fast_install(7, "G") is True
assert set() == _fast_install._FAILED
assert not _fast_install._FAILED
def test_foreground_failure_falls_back(self, tmp_path: Path) -> None:
with (
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
patch(f"{PKG}._refusal", return_value=None),
patch(f"{PKG}.sys.stdin.isatty", return_value=True),
patch(f"{PKG}._echo") as echo,
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=2)),
):
assert _fast_install.fast_install(7, "G") is False
assert {7} == _fast_install._FAILED
assert {7} == set(_fast_install._FAILED)
assert "exit 2" in echo.call_args.args[0]

View File

@ -0,0 +1,50 @@
"""Tests for queueing an install with Steam before the fast installer runs.
steam-game-installer only finishes a manifest it finds. A fresh install used
to reach it with none (Oct 22, BG3: "nothing to finish"), so the enforcer now
writes Steam's own "needs downloading" manifest first.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from steam_backlog_enforcer.game_install import _queue_download, install_game
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
PKG = "steam_backlog_enforcer.game_install"
class TestQueueBeforeFastInstall:
def test_fast_install_gets_a_manifest_to_finish(self, tmp_path: Path) -> None:
"""steam-game-installer only finishes a manifest it finds, so a fresh
install is queued with Steam before the installer runs, not after."""
seen: list[bool] = []
def fast(_app_id: int, _label: str, prepare: Callable[[], object]) -> bool:
prepare()
seen.append((tmp_path / "appmanifest_440.acf").exists())
return True
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.fast_install", side_effect=fast),
patch(f"{PKG}.os.geteuid", return_value=1000),
):
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
assert seen == [True]
def test_a_manifest_steam_already_has_is_kept(self, tmp_path: Path) -> None:
"""Steam's own download in progress: its manifest is never overwritten."""
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"StateFlags"\t\t"1042"\n')
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path),
):
assert _queue_download(440, "TF2", "s1") is True
assert manifest.read_text() == '"StateFlags"\t\t"1042"\n'

View File

@ -112,6 +112,19 @@ class TestCmdInstall:
):
cmd_install(Config(steam_id="i"), state)
def test_failed_fast_install_never_says_done(self) -> None:
"""Steam's fallback download started, but the fast one failed: "Done!"
after the "Failed: ..." line was the success the BG3 job showed."""
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_fully_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True),
patch(f"{PKG}.fast_install_failure", return_value="exit 1"),
patch(f"{PKG}._echo") as echo,
):
cmd_install(Config(steam_id="i"), state)
echo.assert_called_with("Steam's own download was started instead.")
class TestCmdHide:
"""Tests for cmd_hide."""

View File

@ -91,6 +91,7 @@ class TestDaemonView:
"phase": "downloading depot 3/8",
"percent": 46.9,
"elapsed_seconds": 60,
"error": None,
}
]

View File

@ -173,6 +173,8 @@ export interface FastInstallRun {
/** The current depot's percent; null until depotdownloader reports one. */
percent: number | null
elapsed_seconds: number
/** Why the run failed (`phase` is then `failed`); null while it runs. */
error?: string | null
}
export interface DaemonStatus {

View File

@ -114,6 +114,14 @@ describe('Dashboard', () => {
expect(screen.getByRole('progressbar', { name: "Baldur's Gate 3 download" })).toHaveClass('bar-indeterminate')
})
it('keeps a failed daemon run on screen with its reason', async () => {
const failed = { ...bg3, phase: 'failed', percent: null, elapsed_seconds: 0, error: 'Refusing to mark it as installed' }
show({ 'GET /api/daemon': daemon([failed]) })
expect(await screen.findByText("Fast install of Baldur's Gate 3 failed: Refusing to mark it as installed")).toHaveClass('text-danger')
expect(screen.getByText("Steam's own download took over.")).toBeInTheDocument()
expect(screen.queryByRole('progressbar', { name: "Baldur's Gate 3 download" })).not.toBeInTheDocument()
})
it('stays out of the way when nothing is installing', async () => {
show()
expect(await screen.findByText('Hollow Knight')).toBeInTheDocument()

View File

@ -55,13 +55,21 @@ function FastInstalls() {
if (!runs.length) return null
return (
<Card title="Fast install">
{runs.map((r) => (
<div key={r.app_id}>
<p>{fastInstallLine(r)}</p>
<ProgressBar fraction={r.percent === null ? null : Math.floor(r.percent) / 100} label={`${r.name} download`} />
<p className="field-help">{fmtDuration(r.elapsed_seconds)} elapsed</p>
</div>
))}
{runs.map((r) =>
r.error ? (
// The daemon has no job to report to: a failure stays here until Steam finishes.
<div key={r.app_id}>
<p className="text-danger">{`Fast install of ${r.name} failed: ${r.error}`}</p>
<p className="field-help">Steam&apos;s own download took over.</p>
</div>
) : (
<div key={r.app_id}>
<p>{fastInstallLine(r)}</p>
<ProgressBar fraction={r.percent === null ? null : Math.floor(r.percent) / 100} label={`${r.name} download`} />
<p className="field-help">{fmtDuration(r.elapsed_seconds)} elapsed</p>
</div>
),
)}
</Card>
)
}