fix: fast install ignores other downloads; install offers any allowed game

- Fast install no longer refuses while Steam downloads something else:
  the installer leaves via `steam -shutdown`, which Steam resumes from.
  Only a running game still refuses.
- Non-daemon detached runs (web jobs) wait for the installer with the
  same stall reaper the daemon uses, and log to a per-uid file: the
  shared root-owned log made every job spawn raise.
- `install` offers every allowed game that is not fully installed
  (assignment + manual picks), so a skipped fast install can be started
  later; it takes over Steam's slow download.
- Uninstall progress names only removed games; protected runtimes are
  logged as kept instead of appearing as the last "uninstalled" item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHzMuPSHq68S2AcPqGe3Q
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:21:00 +02:00
parent 2735c00716
commit c31ae2170f
11 changed files with 274 additions and 68 deletions

View File

@ -18,7 +18,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
"check": "Check assigned game for a new achievement",
"status": "Show current status",
"list": "List games from snapshot",
"install": "Install the assigned game",
"install": "Install an allowed game (fast installer first)",
"hide": "Hide all non-assigned games in library",
"unhide": "Unhide all games in library",
"buy-dlc": "Unblock the store for 15 min to buy a game/DLC",

View File

@ -10,14 +10,18 @@ Two modes, picked by whether anyone can answer a prompt:
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run to
completion with the terminal attached, so depotdownloader's progress shows
and a password / Steam Guard prompt can be answered.
* **Detached** (the daemon): spawn in its own session and return. Every later
pass reaps it through :func:`poll_fast_installs`, which records a failure (so
the caller falls back to ``steam://install``) and interrupts a run whose
output has stalled - most likely a login prompt nobody will ever answer,
sitting there with Steam shut down.
* **Detached** (no terminal): spawn in its own session. The root daemon
returns at once and reaps it on every later pass through
:func:`poll_fast_installs`, which records a failure (so the caller falls
back to ``steam://install``) and interrupts a run whose output has stalled -
most likely a login prompt nobody will ever answer, sitting there with Steam
shut down. Anything else (a web job) exits once its command returns, so it
runs that same reaper in a loop until the installer is done.
The installer closes Steam to do its work, so it is refused outright while a
game is running or another app is mid-update (the AoE2 corruption case).
game is running. Another app mid-download is no reason to wait: the installer
asks Steam to leave with ``steam -shutdown``, and Steam resumes the transfer
when it comes back.
"""
from __future__ import annotations
@ -38,10 +42,7 @@ 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._echo import _echo
from steam_backlog_enforcer._steam_restart_guard import game_is_running
from steam_backlog_enforcer._steam_state import (
STEAMAPPS_PATH,
steam_update_in_progress,
)
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
logger = logging.getLogger(__name__)
@ -53,6 +54,8 @@ _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
# How often a waiting (non-daemon) caller reaps its run.
_WAIT_POLL_SECONDS = 5
@dataclass
@ -100,14 +103,10 @@ def _steam_account_name() -> str | None:
return match.group(1) if match else None
def _refusal(app_id: int) -> str | None:
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"
if steam_update_in_progress(exclude_app_id=app_id):
return (
"another Steam download is in progress and closing Steam could corrupt it"
)
return None
@ -181,7 +180,9 @@ def poll_fast_installs() -> None:
def _spawn_detached(cmd: list[str], app_id: int) -> None:
"""Start the installer in its own session, output to a log file."""
log_path = _config.CONFIG_DIR / "fast_install.log"
# 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
@ -201,6 +202,18 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None:
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."""
_echo(
f" Fast-installing {label} with steam-game-installer (Steam will close); "
"waiting for it to finish..."
)
while is_fast_installing(app_id):
time.sleep(_WAIT_POLL_SECONDS)
poll_fast_installs()
return app_id not in _FAILED
def fast_install(app_id: int, label: str) -> bool:
"""Install *app_id* with steam-game-installer.
@ -215,7 +228,7 @@ def fast_install(app_id: int, label: str) -> bool:
if installer is None:
logger.warning("steam-game-installer not found; using Steam's download.")
return False
reason = _refusal(app_id)
reason = _refusal()
if reason is not None:
_echo(f" Fast install of {label} skipped: {reason}.")
return False
@ -223,7 +236,7 @@ def fast_install(app_id: int, label: str) -> bool:
cmd = _build_cmd(installer, app_id, user)
if not sys.stdin.isatty():
_spawn_detached(cmd, app_id)
return True
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

View File

@ -79,7 +79,7 @@ def _manifest_transfer_active(manifest: Path) -> bool:
) > _field("BytesStaged")
def steam_update_in_progress(exclude_app_id: int | None = None) -> bool:
def steam_update_in_progress() -> bool:
"""Return True if any installed Steam app is mid-update.
Steam records live transfer progress in each ``appmanifest_*.acf``: a game
@ -90,19 +90,14 @@ def steam_update_in_progress(exclude_app_id: int | None = None) -> bool:
failure that corrupted Age of Empires II DE into a deterministic launch
crash).
Args:
exclude_app_id: An app whose own transfer does not count - the one a
fast install is about to take over.
Returns:
True if at least one appmanifest shows an unfinished download/stage.
"""
skip = f"appmanifest_{exclude_app_id}.acf"
try:
manifests = list(STEAMAPPS_PATH.glob("appmanifest_*.acf"))
except OSError:
return False
return any(_manifest_transfer_active(m) for m in manifests if m.name != skip)
return any(_manifest_transfer_active(m) for m in manifests)
def is_game_fully_installed(app_id: int) -> bool:

View File

@ -146,17 +146,22 @@ def uninstall_other_games(allowed_app_ids: set[int]) -> int:
Returns: number of games uninstalled.
"""
installed = get_installed_games()
count = 0
for app_id, name in tracked(installed, "Uninstalling games", lambda g: g[1]):
to_remove: list[tuple[int, str]] = []
kept_protected: list[str] = []
for app_id, name in get_installed_games():
if app_id in allowed_app_ids:
logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id)
continue
if is_protected_app(app_id):
logger.debug("Skipping protected: %s (AppID=%d)", name, app_id)
continue
elif is_protected_app(app_id):
kept_protected.append(name)
else:
to_remove.append((app_id, name))
if kept_protected:
logger.info("KEEPING protected: %s", ", ".join(kept_protected))
# Only removals go through the progress bar: it names the item it just
# finished, and naming a kept runtime there reads as "removed it".
count = 0
for app_id, name in tracked(to_remove, "Uninstalling games", lambda g: g[1]):
logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id)
if uninstall_game(app_id, name):
count += 1

View File

@ -7,7 +7,11 @@ 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._progress import current_progress
from steam_backlog_enforcer._prompter import confirm_phrase
from steam_backlog_enforcer._prompter import (
PromptOption,
confirm_phrase,
current_prompter,
)
from steam_backlog_enforcer._steam_errors import SteamUnavailableError
from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.game_install import (
@ -73,29 +77,48 @@ def cmd_uninstall(_config: Config, state: State) -> None:
_echo(f"\nUninstalled {count} games.")
def _pick_install_target(state: State) -> tuple[int, str] | None:
"""The allowed game to install: the only unfinished one, or the user's pick.
Every allowed game counts, not just the assignment: a manual pick whose
fast install was skipped (a game was running at pick time) needs a way to
start it later too.
"""
pending = [
(aid, n) for aid, n in allowed_games(state) if not is_game_fully_installed(aid)
]
if not pending:
_echo("All allowed games are already installed.")
return None
if len(pending) == 1:
return pending[0]
options = [PromptOption(str(aid), name or f"AppID={aid}") for aid, name in pending]
picked = int(current_prompter().choice("Install which game", options))
return next(game for game in pending if game[0] == picked)
def cmd_install(config: Config, state: State) -> None:
"""Manually trigger install of the assigned game."""
if state.current_app_id is None:
"""Install an allowed game, through the fast installer when it can be used.
A download Steam already started is taken over, so this is also how a
fast install that was skipped earlier gets started.
"""
if not allowed_games(state):
_echo("No game currently assigned. Run 'scan' first.")
return
if is_game_fully_installed(state.current_app_id):
_echo(f"{state.current_game_name} is already installed.")
target = _pick_install_target(state)
if target is None:
return
app_id, name = target
_echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...")
_echo(f"Installing {name} (AppID={app_id})...")
# 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 {state.current_game_name}")
if install_game(
state.current_app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
):
current_progress().phase(f"Starting install of {name}")
if install_game(app_id, name, config.steam_id, use_steam_protocol=True):
_echo("Done!")
else:
_echo("Failed to create install manifest.")
_echo("Failed to start the install.")
def cmd_hide(config: Config, state: State) -> None:

View File

@ -111,10 +111,9 @@ class TestFullyInstalled:
path.write_text('"AppState"\n{\n}\n')
assert _steam_state.is_game_fully_installed(1) is False
def test_update_check_can_exclude_the_target(self) -> None:
def test_update_check_sees_a_transfer(self) -> None:
_manifest(7, 1026, to_download=10)
assert _steam_state.steam_update_in_progress() is True
assert _steam_state.steam_update_in_progress(exclude_app_id=7) is False
class TestInstallGameFastPath:

View File

@ -10,7 +10,7 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import _fast_install
from steam_backlog_enforcer import _fast_install, _steam_state
PKG = "steam_backlog_enforcer._fast_install"
@ -71,22 +71,18 @@ class TestRefusal:
def test_game_running(self) -> None:
with patch(f"{PKG}.game_is_running", return_value=True):
assert "game is running" in (_fast_install._refusal(1) or "")
assert "game is running" in (_fast_install._refusal() or "")
def test_other_update_in_progress(self) -> None:
with (
patch(f"{PKG}.game_is_running", return_value=False),
patch(f"{PKG}.steam_update_in_progress", return_value=True) as update,
):
assert "download is in progress" in (_fast_install._refusal(7) or "")
update.assert_called_once_with(exclude_app_id=7)
def test_safe(self) -> None:
with (
patch(f"{PKG}.game_is_running", return_value=False),
patch(f"{PKG}.steam_update_in_progress", return_value=False),
):
assert _fast_install._refusal(7) is None
def test_other_download_does_not_refuse(self) -> None:
# The installer closes Steam with ``steam -shutdown``, which Steam
# survives mid-download; another transfer is no reason to skip.
(_steam_state.STEAMAPPS_PATH / "appmanifest_9.acf").write_text(
'"AppState"\n{\n\t"BytesToDownload"\t\t"10"\n'
'\t"BytesDownloaded"\t\t"0"\n}\n'
)
assert _steam_state.steam_update_in_progress() is True
with patch(f"{PKG}.game_is_running", return_value=False):
assert _fast_install._refusal() is None
class TestBuildCmd:

View File

@ -0,0 +1,84 @@
"""Tests for a fast install started outside the daemon (a web job, a pipe).
Only the root daemon reaps detached runs on its later passes. Any other
process exits once the command returns, so it must wait for the installer
itself - otherwise a stalled login prompt would hold Steam closed with
nobody left to interrupt it. Its log must also be its own: the daemon's
log is root-owned, and opening it as the desktop user used to raise.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import _fast_install, config
if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer._fast_install"
def _ready(tmp_path: Path) -> Path:
installer = tmp_path / "run.sh"
installer.write_text("#!/bin/bash\n")
return installer
def _fake_spawn(*returncodes: int | None) -> MagicMock:
"""A ``_spawn_detached`` that registers a run answering *returncodes*."""
def spawn(_cmd: list[str], app_id: int) -> None:
proc = MagicMock(pid=4321)
proc.poll.side_effect = list(returncodes)
_fast_install._RUNS.append(
_fast_install._DetachedRun(app_id, proc, config.CONFIG_DIR / "x.log")
)
return MagicMock(side_effect=spawn)
class TestJobWaitsForInstaller:
"""A non-root, non-terminal caller blocks until the run is reaped."""
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}._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"),
):
return _fast_install.fast_install(7, "G")
def test_success_after_waiting(self, tmp_path: Path) -> None:
assert self._install(tmp_path, _fake_spawn(None, None, 0), 1000) is True
assert _fast_install._RUNS == []
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
def test_daemon_returns_at_once(self, tmp_path: Path) -> None:
assert self._install(tmp_path, _fake_spawn(), 0) is True
assert _fast_install.is_fast_installing(7)
class TestLogIsPerUser:
"""A root-owned daemon log never blocks the desktop user's spawn."""
def test_unwritable_daemon_log_is_not_used(self) -> None:
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
daemon_log = config.CONFIG_DIR / "fast_install.log"
daemon_log.write_text("root's\n")
daemon_log.chmod(0o400)
with (
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}.subprocess.Popen") as popen,
):
_fast_install._spawn_detached(["run.sh"], 9)
popen.assert_called_once()
assert _fast_install._RUNS[0].log_path != daemon_log

View File

@ -86,7 +86,7 @@ class TestSpawnDetached:
assert kwargs["start_new_session"] is True
run = _fast_install._RUNS[0]
assert run.app_id == 9
assert run.log_path == config.CONFIG_DIR / "fast_install.log"
assert run.log_path == config.CONFIG_DIR / "fast_install-1000.log"
assert run.log_path.exists()
def test_as_root_escapes_the_service_cgroup(self) -> None:

View File

@ -12,6 +12,7 @@ from steam_backlog_enforcer.game_uninstall import (
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from pathlib import Path
PKG = "steam_backlog_enforcer.game_install"
@ -165,3 +166,31 @@ class TestUninstallOtherGames:
count = uninstall_other_games({440})
assert count == 0
mock_uninstall.assert_not_called()
class TestUninstallProgressNames:
"""The progress bar names only games that are actually removed."""
def test_protected_and_allowed_never_shown(self) -> None:
shown: list[str] = []
def fake_tracked(
items: list[tuple[int, str]],
_label: str,
name: Callable[[tuple[int, str]], str],
) -> Iterator[tuple[int, str]]:
for item in items:
yield item
shown.append(name(item))
with (
patch(
f"{GAME_UNINSTALL_PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS"), (228980, "Redist")],
),
patch(f"{GAME_UNINSTALL_PKG}.tracked", side_effect=fake_tracked),
patch(f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=True),
):
count = uninstall_other_games({440})
assert count == 1
assert shown == ["CS"]

View File

@ -0,0 +1,62 @@
"""Tests for ``install`` choosing which allowed game to (fast-)install.
A fast install that was skipped at pick time must be startable later, for the
assignment and for every manual pick alike.
"""
from __future__ import annotations
from unittest.mock import patch
from steam_backlog_enforcer._prompter import use_prompter
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.main import cmd_install
from steam_backlog_enforcer.tests._fake_prompter import FakePrompter
PKG = "steam_backlog_enforcer.main.install"
def _state() -> State:
return State(
current_app_id=1,
current_game_name="Assigned",
manual_picks=[{"app_id": 2, "game_name": "Picked"}],
)
class TestInstallPicksGame:
"""Every allowed game that is not fully installed is offered."""
def test_only_unfinished_game_installed_without_asking(self) -> None:
prompter = FakePrompter()
with (
use_prompter(prompter),
patch(f"{PKG}.is_game_fully_installed", side_effect=lambda a: a == 1),
patch(f"{PKG}.install_game", return_value=True) as install,
patch(f"{PKG}._echo"),
):
cmd_install(Config(steam_id="i"), _state())
assert prompter.asked == []
assert install.call_args.args[:2] == (2, "Picked")
def test_several_unfinished_games_prompt(self) -> None:
prompter = FakePrompter(choices=["2"])
with (
use_prompter(prompter),
patch(f"{PKG}.is_game_fully_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True) as install,
patch(f"{PKG}._echo"),
):
cmd_install(Config(steam_id="i"), _state())
assert prompter.asked[0][1] == ["1", "2"]
assert install.call_args.args[:2] == (2, "Picked")
def test_all_installed(self) -> None:
with (
patch(f"{PKG}.is_game_fully_installed", return_value=True),
patch(f"{PKG}.install_game") as install,
patch(f"{PKG}._echo") as echo,
):
cmd_install(Config(steam_id="i"), _state())
install.assert_not_called()
assert any("already installed" in str(c) for c in echo.call_args_list)