steam-backlog-enforcer/steam_backlog_enforcer/_fast_install_failure.py
Krzysztof kuhy Rudnicki dc22bd557c 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
2026-10-24 08:36:57 +02:00

56 lines
2.1 KiB
Python

"""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}"