mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:27:03 +01:00
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
152 lines
5.0 KiB
Python
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)
|