mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Raise the pylint hook to --fail-under=10.0 and make it lint the whole
package on every run (pass_filenames: false): over the staged subset a
9.99 tree passed at commit time.
Ports de2aea3 (9b9342ba-1) onto main, minus its web/ and requirements.txt
bumps and the iptables dedupe f1822f1 already landed; _hltb_confidence and
_hltb_search keep main's 429 handling. _search_batch splits out
_acquire_auth / _search_headers, and _stats.py is back to 250 lines.
Drops the min-similarity-lines = 8 floor (0 duplicate-code hits at the
default) and sets max-attributes = 15 for the flat persisted records, as
kuhy decided. Tests stay outside pylint, as at every site already at 10.
Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
8.7 KiB
Python
232 lines
8.7 KiB
Python
"""Installing games through steam-game-installer, for download speed.
|
|
|
|
depotdownloader saturates the link far better than the Steam client, and
|
|
steam-game-installer (``~/src/steam-game-installer``) wraps it so Steam accepts
|
|
the result as a real install. It also adopts whatever Steam already staged, so
|
|
taking over a slow client download loses nothing.
|
|
|
|
Two modes, picked by whether anyone can answer a prompt:
|
|
|
|
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run with
|
|
the terminal attached, so depotdownloader's progress shows and a password /
|
|
Steam Guard prompt can be answered.
|
|
* **Detached** (no terminal): spawn in its own session. The root daemon returns
|
|
at once; :func:`poll_fast_installs` reaps the run on later passes, records a
|
|
failure (the caller falls back to ``steam://install``) and interrupts a run
|
|
whose output stalled - likely a login prompt nobody will answer, with Steam
|
|
shut down. A web job loops that reaper until the run ends. Either way the
|
|
log is followed for progress (:mod:`_fast_install_progress`).
|
|
|
|
The installer closes Steam, so it is refused while a game is running. Another
|
|
app mid-download is no reason to wait: asked to leave with ``steam -shutdown``,
|
|
Steam resumes the transfer when it comes back.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import os
|
|
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 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 is_game_fully_installed
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import BinaryIO
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_INSTALLER_LOG = "~/.local/state/steam-game-installer/latest.log"
|
|
# 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
|
|
# How often a waiting (non-daemon) caller reaps its run.
|
|
_WAIT_POLL_SECONDS = 5
|
|
|
|
|
|
@dataclass
|
|
class _DetachedRun:
|
|
"""A daemon-spawned installer run, kept so it can be reaped."""
|
|
|
|
app_id: int
|
|
proc: subprocess.Popen[bytes]
|
|
log_path: Path
|
|
|
|
|
|
_RUNS: list[_DetachedRun] = []
|
|
# 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:
|
|
"""Why closing Steam right now would do damage, or None if it is safe."""
|
|
if game_is_running():
|
|
return "a game is running and closing Steam would kill it"
|
|
return None
|
|
|
|
|
|
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:
|
|
return time.time() - run.log_path.stat().st_mtime > STALL_SECONDS
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def poll_fast_installs() -> None:
|
|
"""Reap finished detached runs and interrupt stalled ones.
|
|
|
|
SIGINT, not SIGTERM: the installer turns it into KeyboardInterrupt, and its
|
|
``finally`` restarts the Steam client it shut down. SIGTERM would leave
|
|
Steam closed.
|
|
"""
|
|
for run in list(_RUNS):
|
|
returncode = run.proc.poll()
|
|
if returncode is None:
|
|
if _stalled(run):
|
|
logger.warning(
|
|
"steam-game-installer for AppID=%d silent for %ds — "
|
|
"interrupting (see %s).",
|
|
run.app_id,
|
|
STALL_SECONDS,
|
|
run.log_path,
|
|
)
|
|
os.killpg(run.proc.pid, signal.SIGINT)
|
|
continue
|
|
_RUNS.remove(run)
|
|
if returncode == 0:
|
|
logger.info("steam-game-installer finished AppID=%d.", run.app_id)
|
|
continue
|
|
_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: %s. Falling back to "
|
|
"Steam's own download. Log: %s",
|
|
run.app_id,
|
|
_FAILED[run.app_id],
|
|
run.log_path,
|
|
)
|
|
_fip.poll_trackers({run.app_id for run in _RUNS}, is_game_fully_installed)
|
|
|
|
|
|
def _start_session(cmd: list[str], log: BinaryIO) -> subprocess.Popen[bytes]:
|
|
"""Start *cmd* in its own session writing to *log*; the caller owns it.
|
|
|
|
No ``with``: the run outlives this call and is reaped by
|
|
:func:`poll_fast_installs`.
|
|
"""
|
|
return subprocess.Popen(
|
|
cmd,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=log,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
|
|
|
|
def _spawn_detached(cmd: list[str], app_id: int, label: str = "") -> None:
|
|
"""Start the installer in its own session, output to a log file."""
|
|
# Per user: the daemon's log is root-owned, and a web job runs as the
|
|
# desktop user - sharing one file made every job spawn raise.
|
|
log_path = _config.CONFIG_DIR / f"fast_install-{os.geteuid()}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# The unit is KillMode=control-group: restarting the daemon would SIGTERM
|
|
# an installer left in its cgroup, skipping the `finally` that restarts
|
|
# Steam. A transient scope moves it out; --scope execs in place, so the
|
|
# Popen pid below is still the session leader killpg needs.
|
|
if os.geteuid() == 0 and shutil.which("systemd-run"):
|
|
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
|
|
with log_path.open("ab") as log:
|
|
# Append mode starts at the end: earlier runs' lines are never parsed.
|
|
_fip.track(app_id, label, log_path, offset=log.tell(), now=time.monotonic())
|
|
proc = _start_session(cmd, log)
|
|
_RUNS.append(_DetachedRun(app_id, proc, log_path))
|
|
logger.info("Started steam-game-installer for AppID=%d (log: %s)", app_id, log_path)
|
|
|
|
|
|
def _wait_detached(app_id: int, label: str) -> bool:
|
|
"""Reap this process's run for *app_id* until it ends; True on success."""
|
|
reporter = _fip.WaitReporter(app_id, label, started=time.monotonic())
|
|
while is_fast_installing(app_id):
|
|
time.sleep(_WAIT_POLL_SECONDS)
|
|
poll_fast_installs()
|
|
reporter.report(time.monotonic())
|
|
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, 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.
|
|
"""
|
|
if app_id in _FAILED:
|
|
return False
|
|
user = resolve_desktop_user()
|
|
installer = installer_path(user)
|
|
if installer is None:
|
|
logger.warning("steam-game-installer not found; using Steam's download.")
|
|
return False
|
|
reason = _refusal()
|
|
if reason is not None:
|
|
_echo(f" Fast install of {label} skipped: {reason}.")
|
|
return False
|
|
|
|
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)
|
|
|
|
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")
|
|
returncode = subprocess.run(cmd, check=False).returncode
|
|
if returncode == 0:
|
|
return True
|
|
_FAILED[app_id] = f"exit {returncode}"
|
|
_echo(
|
|
f"Failed: fast install of {label} (exit {returncode}, reasons above). "
|
|
f"Steam's own download takes over (log: {_INSTALLER_LOG})."
|
|
)
|
|
return False
|