steam-backlog-enforcer/steam_backlog_enforcer/_serve_startup.py
Krzysztof kuhy Rudnicki 47f77f5f1e
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 (push) Waiting to run
Tests / test (push) Waiting to run
Make serve re-runnable, and cover it to 100%
The serve rework was sitting untracked: a /proc-based port guard, the
start-or-defer decision, and a stale-bundle rebuild. It had no tests, and it
changed cmd_serve(_config, state) to cmd_serve(argv) without updating the one
test that called it, so the suite was red and coverage was 251 lines short.

Tests, all against fakes:
- _fake_proc builds a synthetic /proc. _NET_TCP is derived from _PROC at
  import time, so patch_proc redirects both; patching _PROC alone would have
  had these tests parse the host's real process table.
- _serve_guard is split across two test files to stay under the 250-line cap.
  os is replaced wholesale in every termination test, so no signal can reach a
  real process even if the guard's self-protection were removed.
- _web_build mocks subprocess entirely and redirects every path constant into
  tmp_path. _no_subprocess now blocks _web_build.subprocess.run too: an
  unguarded test would fire a real `npm run build`, rewriting web/dist and
  perturbing frontend_is_stale() for every later test in the run.

Source changes, both required rather than incidental:
- _serve_guard's wildcard match was a `# noqa: S104` on a "0.0.0.0" literal,
  which the no-noqa hook rejects outright. It is now the all-zero hex column
  /proc actually prints, so the dotted-quad literal never appears.
- main/__init__.py's serve dispatch arm was itself uncovered; it now has a
  test asserting the raw argv passes through unparsed.

Also drops the document.hidden guard from useBudgetPoll: it skipped the very
first load, so the budget panel sat on "Reading budget state…" forever in a
background tab, which is every tab under browser automation.

Verified: 1712 python tests, 100% branch coverage; 128 vitest, 100%. The
guard's /proc parser was checked read-only against a real listener on :8000
and resolved the same pid as `ss`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:52:11 +02:00

152 lines
5.0 KiB
Python

"""Argument parsing and the start-or-defer decision for ``serve``.
:mod:`steam_backlog_enforcer._serve_guard` answers *who owns the port*; this
module decides *what to do about it*, so ``cmd_serve`` stays a few lines.
"""
from __future__ import annotations
import ipaddress
import sys
from typing import NoReturn
from steam_backlog_enforcer._serve_guard import (
find_port_owner,
is_our_server,
newest_py_since,
port_is_free,
process_started_at,
read_cmdline,
terminate,
)
from steam_backlog_enforcer._web_server import DEFAULT_HOST, DEFAULT_PORT
from steam_backlog_enforcer.game_install import _echo
# The dataset API is unauthenticated, so a bind must never leave this machine.
# IPv4 only: ThreadingHTTPServer's address_family is AF_INET and nothing
# overrides it, so accepting "::1" here would ship a flag that always crashes.
_LOOPBACK_ALIAS = "localhost"
_IPV4 = 4
_MIN_PORT = 1024
_MAX_PORT = 65535
_SERVE_USAGE = (
"Usage: serve [--host HOST] [--port PORT]\n"
f" --host : IPv4 loopback only (127.0.0.1 or {_LOOPBACK_ALIAS})\n"
f" --port : {_MIN_PORT}-{_MAX_PORT}, default {DEFAULT_PORT}\n\n"
"Re-running serve is safe: it rebuilds the frontend when stale, reports an\n"
"already-running server, and replaces one running outdated code."
)
def _fail_usage(message: str) -> NoReturn:
"""Print *message* followed by the usage block, then exit 1."""
_echo(message)
_echo(_SERVE_USAGE)
sys.exit(1)
def _parse_port(raw: str) -> int:
"""Parse and range-check a ``--port`` value."""
try:
port = int(raw)
except ValueError:
_fail_usage(f"Not a number: {raw}")
if not _MIN_PORT <= port <= _MAX_PORT:
_fail_usage(f"Port out of range: {port}")
return port
def _parse_host(raw: str) -> str:
"""Validate a ``--host`` value and canonicalise it to a literal address."""
if raw == _LOOPBACK_ALIAS:
return DEFAULT_HOST
try:
addr = ipaddress.ip_address(raw)
except ValueError:
_fail_usage(f"Not an IP address: {raw}")
if not addr.is_loopback or addr.version != _IPV4:
_fail_usage(f"Refusing to bind non-IPv4-loopback host: {raw}")
return raw
def parse_serve_args(argv: list[str]) -> tuple[str, int]:
"""Parse ``serve``'s flags into a (host, port) pair.
Args:
argv: CLI arguments after the command name.
Returns:
The host and port to bind.
"""
host, port = DEFAULT_HOST, DEFAULT_PORT
rest = list(argv)
while rest:
flag = rest.pop(0)
if flag not in {"--host", "--port"}:
_fail_usage(f"Unknown argument: {flag}")
if not rest:
_fail_usage(f"{flag} needs a value.")
value = rest.pop(0)
if flag == "--port":
port = _parse_port(value)
else:
host = _parse_host(value)
return host, port
def _describe(pid: int) -> str:
"""Render a short, human-readable identification of *pid*."""
argv = read_cmdline(pid)
return f"pid {pid} ({' '.join(argv)})" if argv else f"pid {pid}"
def _exit_foreign(port: int, owner: str) -> NoReturn:
"""Report a port held by something that is not ours, then exit 1."""
_echo(f"Port {port} is held by {owner}.", flush=True)
_echo(f"Free it, or choose another port: serve --port {port + 1}")
sys.exit(1)
def ensure_port_available(host: str, port: int) -> None:
"""Make *host*:*port* bindable, or exit with a clear explanation.
Returns normally when the port is free or was freed by stopping a stale
server. Exits 0 when an up-to-date server of ours already holds it - that
is success, not failure: the UI the caller asked for is already up. Exits
1 when the port belongs to something we must not touch.
"""
if port_is_free(host, port):
return
pid = find_port_owner(host, port)
if pid is None:
_exit_foreign(port, "a process this user cannot inspect")
elif not is_our_server(pid):
_exit_foreign(port, f"{_describe(pid)}, which is not our web UI")
else:
_replace_or_defer(host, port, pid)
def _replace_or_defer(host: str, port: int, pid: int) -> None:
"""Exit 0 if the server at *pid* is current, else terminate it."""
started_at = process_started_at(pid)
stale = None if started_at is None else newest_py_since(started_at)
if started_at is not None and stale is None:
_echo(
"Steam Backlog Enforcer web UI already running: "
f"http://{host}:{port} (pid {pid})",
flush=True,
)
sys.exit(0)
reason = "start time unreadable" if started_at is None else f"{stale} is newer"
_echo(f"Restarting stale web UI (pid {pid}): {reason}.", flush=True)
if not is_our_server(pid):
# The PID was recycled between the scan and here; never signal blind.
_exit_foreign(port, f"{_describe(pid)}, which is not our web UI")
if not terminate(pid, host, port):
_echo(f"Could not free port {port} from pid {pid}.", flush=True)
sys.exit(1)