steam-backlog-enforcer/steam_backlog_enforcer/_fast_install_progress.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

250 lines
9.9 KiB
Python

"""Live progress of a detached steam-game-installer run, read from its log.
The installer runs depotdownloader on a pty and flushes every chunk, so the
per-user ``fast_install-<euid>.log`` grows while the download runs (CRLF line
endings, from the pty). Its markers, verbatim from a real run:
* ``Shutting down the Steam client...``
* ``Steam already staged 71.53 GB for this app ...`` (adopting a partial download)
* ``Downloading via depotdownloader (fast path)...`` (connecting, logging in)
* ``Processing depot 1086941`` once per depot, then ``Downloading depot 1086941``
as each one starts
* `` 46.90% /path/of/finished/file (657.13 Mbit/s)`` per finished file
* ``Checking for owned DLC ...``, then the manifest is finished
* ``Restarting the Steam client...``
The percentage is depotdownloader's progress through the *current depot*: it
restarts at 0 for every depot. Depots are wildly uneven (one Baldur's Gate 3
depot is 128 of its 132 GB), so averaging over depot count would lie; the
phase names the depot instead (``downloading depot 3/8``) and the percent
belongs to it.
The log is opened in append mode and keeps every earlier run, so a tracker
reads only bytes past the offset recorded at spawn, and only the new ones on
each poll: the log of a large game runs to tens of megabytes.
Who reads it: a waiting web job (:class:`WaitReporter`, through the Progress
API and a line every :data:`ECHO_SECONDS`), and the root daemon's ctl
``status`` op (:func:`fast_install_status`), which never touches the file -
the daemon's own enforce loop polls the trackers.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
import threading
from typing import TYPE_CHECKING, Final
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"
_DOWNLOADING: Final = "downloading"
# Line prefix -> the phase it starts.
_MARKERS: Final = (
("Shutting down the Steam client", "shutting Steam down"),
("Steam already staged", "adopting Steam's partial download"),
("Downloading via depotdownloader", "connecting to Steam"),
("Checking for owned DLC", "finishing manifest"),
("Reconciling with steamcmd", "finishing manifest"),
("Restarting the Steam client", "restarting Steam"),
)
_PLANNED_DEPOT: Final = re.compile(r"Processing depot \d+$")
_STARTED_DEPOT: Final = re.compile(r"Downloading depot \d+$")
_PERCENT: Final = re.compile(r"\s*(\d{1,3}\.\d+)% ")
# A waiting job prints a status line this often; the bar moves every poll.
ECHO_SECONDS: Final = 30
_READ_CHUNK: Final = 1 << 20
def _clock(seconds: int) -> str:
"""``42s`` or ``5m 12s``."""
minutes, secs = divmod(seconds, 60)
return f"{minutes}m {secs:02d}s" if minutes else f"{secs}s"
@dataclass(frozen=True)
class InstallProgress:
"""Where a run is: its phase, the current depot's percent, time spent."""
phase: str
percent: float | None
elapsed_seconds: int
@property
def downloading(self) -> bool:
"""Whether the phase is a depot download (the one with a percent)."""
return self.phase.startswith(_DOWNLOADING)
def describe(self) -> str:
"""``46% (downloading depot 3/8), 5m 12s elapsed``; no % before one is known."""
elapsed = f"{_clock(self.elapsed_seconds)} elapsed"
if self.percent is None:
return f"{self.phase}, {elapsed}"
return f"{int(self.percent)}% ({self.phase}), {elapsed}"
class InstallTracker:
"""Follows one run's log from its spawn offset, a complete line at a time."""
def __init__(self, log_path: Path, *, offset: int, started: float) -> None:
"""Track *log_path* from byte *offset*; *started* is a monotonic time."""
self._path = log_path
self._offset = offset
self._started = started
self._partial = b""
self._phase = STARTING
self._percent: float | None = None
self._planned = 0
self._depot = 0
def poll(self) -> None:
"""Parse whatever the run has written since the last poll.
A log that is missing or unreadable changes nothing: the run itself is
judged by its exit status, never by its log.
"""
try:
with self._path.open("rb") as log:
log.seek(self._offset)
while chunk := log.read(_READ_CHUNK):
self._offset += len(chunk)
*lines, self._partial = (self._partial + chunk).split(b"\n")
for line in lines:
self._feed(line.decode("utf-8", "replace").rstrip("\r"))
except OSError:
return
def _feed(self, line: str) -> None:
"""Apply one log line to the phase / percent state."""
if match := _PERCENT.match(line):
if self._phase.startswith(_DOWNLOADING):
# Max within a depot: a depot's progress only ever grows.
self._percent = max(self._percent or 0.0, float(match.group(1)))
elif _PLANNED_DEPOT.match(line):
self._planned += 1
elif _STARTED_DEPOT.match(line):
self._depot += 1
# DLC depots come on top of the planned ones: no total to show then.
of = f"/{self._planned}" if self._depot <= self._planned else ""
self._phase, self._percent = f"{_DOWNLOADING} depot {self._depot}{of}", None
else:
for prefix, phase in _MARKERS:
if line.startswith(prefix):
self._phase, self._percent = phase, None
def snapshot(self, now: float) -> InstallProgress:
"""The run's progress as of monotonic time *now*."""
elapsed = max(0, int(now - self._started))
return InstallProgress(self._phase, self._percent, elapsed)
# 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()
def track(app_id: int, name: str, log_path: Path, *, offset: int, now: float) -> None:
"""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 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()
def progress_of(app_id: int, now: float) -> InstallProgress | None:
"""The tracked run's progress for *app_id*, or None if it is not tracked."""
with _LOCK:
entry = _TRACKERS.get(app_id)
return entry[1].snapshot(now) if entry else None
def fast_install_status(now: float) -> list[dict[str, object]]:
"""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,
"name": name,
"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:
"""Shows a waiting caller's run: the job progress bar plus a periodic line.
The installer prints a line per finished file (thousands), so nothing here
runs per line: one report per poll, one echo per :data:`ECHO_SECONDS`.
"""
def __init__(self, app_id: int, label: str, *, started: float) -> None:
"""Announce the wait for *app_id*'s run; *started* is when it began."""
self._app_id = app_id
self._label = label
self._phase: str | None = None
self._last_echo = started
_echo(
f" Fast-installing {label} with steam-game-installer (Steam will "
f"close); progress every {ECHO_SECONDS}s..."
)
def report(self, now: float) -> None:
"""Push the run's current progress; a no-op once it is not tracked."""
snap = progress_of(self._app_id, now)
if snap is None:
return
progress = current_progress()
if snap.phase != self._phase:
# Only on a change: phase() restarts the job's ETA clock.
self._phase = snap.phase
total = 100 if snap.downloading else None
progress.phase(f"Fast-installing {self._label}: {snap.phase}", total)
if snap.downloading:
# Floor, so 99.99 never reads as done before the depot is.
progress.advance(self._label, step=int(snap.percent or 0))
else:
progress.advance(f"{_clock(snap.elapsed_seconds)} elapsed", step=0)
if now - self._last_echo >= ECHO_SECONDS:
self._last_echo = now
_echo(f" Fast-installing {self._label}: {snap.describe()}")