mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:07:05 +01:00
A server started as `python -c "from ..._web_server import serve; serve( port=8000)"` ran for five hours after the code beneath it changed, reporting an 8h gaming budget while the daemon enforced 6h and had already cut gaming off. Two separate holes let that happen. The staleness check only ran from cmd_serve, so a server started any other way never met it. And is_our_server matched argv containing "steam_backlog_enforcer.main", which a `python -c` process does not have -- so that server could not even be identified as ours: `run.sh serve` would have refused the port and named it as a stranger rather than replacing it. _serve_stale puts the check inside the server, where no launch path can skip it. Every route, static included, returns 503 once any package .py is newer than this process, because a fresh page fetching stale numbers is the same lie with extra steps. The answer is memoised for five seconds so it does not stat the package per request; that bounds the staleness window instead of leaving it open forever. Refusing is only half a fix -- a process that 503s forever is honest and useless -- so a refused request also stands the server down. The new steam-backlog-enforcer-web.service supplies the other half with Restart=always: it dies on stale code and returns on current code with nobody having to remember anything. A user unit, not a system one; it serves read-only views and needs no privilege. install.sh installs and enables it rather than printing instructions. is_our_server now substring-matches the joined argv, so run.sh serve, python -m ...main serve and a bare python -c are all recognised. newest_py_since moves into _serve_stale and _serve_guard delegates to it. Both needed the same scan, and two copies would have been free to disagree about whether a server is current -- which is the entire failure being fixed. Verified end to end: healthy 200, touch _budget_view.py, next request 503, systemd replaces the process, back to 200 reporting 6.0h and blocked=True. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
170 lines
6.9 KiB
Python
170 lines
6.9 KiB
Python
"""Minimal read-only localhost HTTP server for the interactive web UI.
|
|
|
|
Serves the projected dataset at ``GET /api/dataset`` and the built React
|
|
bundle (``web/dist``) as static files. Binds to localhost only and never
|
|
exposes secrets: the payload comes from :func:`build_web_dataset`, which reads
|
|
the data caches but never ``config.json``.
|
|
|
|
In development the Vite dev server proxies ``/api`` here; in production the
|
|
``serve`` command serves the built bundle and the API from one process.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
from urllib.parse import urlsplit
|
|
|
|
from steam_backlog_enforcer._budget_view import build_budget_snapshot
|
|
from steam_backlog_enforcer._serve_stale import outdated_source
|
|
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload
|
|
from steam_backlog_enforcer.config import State
|
|
from steam_backlog_enforcer.game_install import _echo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Built frontend lives at <repo>/web/dist (sibling of the package directory).
|
|
WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve()
|
|
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_PORT = 8000
|
|
_API_DATASET = "/api/dataset"
|
|
_API_BUDGET = "/api/budget"
|
|
|
|
# Content types that are text but not under the ``text/`` prefix.
|
|
_EXTRA_TEXT_TYPES = frozenset(
|
|
{"application/javascript", "application/json", "image/svg+xml"}
|
|
)
|
|
_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build"
|
|
|
|
# Epoch seconds this process began, captured at import. Anything in the
|
|
# package newer than this is code we did not load.
|
|
_STARTED_AT = time.time()
|
|
|
|
# Set once a stale server has begun standing down, so concurrent requests
|
|
# do not each spawn a shutdown thread.
|
|
_RETIRING = threading.Event()
|
|
|
|
_STALE_MSG = (
|
|
b"This server is running outdated code and has stopped answering rather "
|
|
b"than report numbers the enforcer is not applying. Restart it: "
|
|
b"./run.sh serve"
|
|
)
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
"""Serve the dataset JSON and the static frontend bundle (read-only)."""
|
|
|
|
def log_message(self, fmt: str, *args: object) -> None:
|
|
"""Route the default request log to ``logging`` at debug level."""
|
|
logger.debug("%s - %s", self.address_string(), fmt % args)
|
|
|
|
def do_GET(self) -> None:
|
|
"""Dispatch a GET to one of the APIs or to a static file."""
|
|
stale = outdated_source(_STARTED_AT)
|
|
if stale is not None:
|
|
# Fail closed on every route, static included: a page served from a
|
|
# fresh bundle that then fetches numbers from stale code is the
|
|
# same lie with extra steps.
|
|
self._send(HTTPStatus.SERVICE_UNAVAILABLE, _STALE_MSG, "text/plain")
|
|
self._retire()
|
|
return
|
|
split = urlsplit(self.path)
|
|
if split.path == _API_DATASET:
|
|
self._serve_dataset()
|
|
elif split.path == _API_BUDGET:
|
|
# ``?demo=1`` reads the demo run's state and log, which is how the
|
|
# 60-second demo can be watched hitting its cutoff in the browser
|
|
# without spending a real day's budget to see it.
|
|
self._serve_budget(demo="demo=1" in split.query)
|
|
else:
|
|
self._serve_static(split.path)
|
|
|
|
def _retire(self) -> None:
|
|
"""Answer this request, then stop the server so it can be replaced.
|
|
|
|
Refusing to serve is only half a fix: a process that 503s forever is
|
|
honest and useless. Exiting hands the problem to the supervisor, and
|
|
``steam-backlog-enforcer-web.service`` has ``Restart=always``, so the
|
|
server comes back on current code with nobody having to remember to
|
|
restart it. Started outside systemd it simply exits, which is still
|
|
better than serving numbers the enforcer is not applying.
|
|
|
|
``shutdown`` blocks until ``serve_forever`` returns and cannot be
|
|
called from the thread handling a request, so it goes to its own.
|
|
"""
|
|
if _RETIRING.is_set():
|
|
return
|
|
_RETIRING.set()
|
|
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
|
|
def _serve_budget(self, *, demo: bool) -> None:
|
|
"""Build and send the gaming-budget snapshot as JSON."""
|
|
try:
|
|
body = json.dumps(build_budget_snapshot(demo=demo)).encode("utf-8")
|
|
except OSError, ValueError, KeyError:
|
|
logger.exception("Failed to build budget snapshot")
|
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"budget error", "text/plain")
|
|
return
|
|
self._send(HTTPStatus.OK, body, "application/json")
|
|
|
|
def _serve_dataset(self) -> None:
|
|
"""Build and send the projected dataset as JSON."""
|
|
try:
|
|
payload = dataset_to_payload(build_web_dataset(State.load()))
|
|
body = json.dumps(payload).encode("utf-8")
|
|
except OSError, ValueError, KeyError:
|
|
logger.exception("Failed to build web dataset")
|
|
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"dataset error", "text/plain")
|
|
return
|
|
self._send(HTTPStatus.OK, body, "application/json")
|
|
|
|
def _serve_static(self, path: str) -> None:
|
|
"""Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard."""
|
|
rel = path.lstrip("/") or "index.html"
|
|
candidate = (WEB_DIST / rel).resolve()
|
|
# Reject path traversal, then fall back to index.html for SPA routes.
|
|
if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file():
|
|
candidate = WEB_DIST / "index.html"
|
|
if not candidate.is_file():
|
|
self._send(HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
|
|
return
|
|
ctype, _ = mimetypes.guess_type(candidate.name)
|
|
self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain")
|
|
|
|
def _send(self, status: HTTPStatus, body: bytes, ctype: str) -> None:
|
|
"""Write a complete response with the given status, body, and type."""
|
|
if ctype.startswith("text/") or ctype in _EXTRA_TEXT_TYPES:
|
|
ctype = f"{ctype}; charset=utf-8"
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
def create_server(
|
|
host: str = DEFAULT_HOST, port: int = DEFAULT_PORT
|
|
) -> ThreadingHTTPServer:
|
|
"""Create (but do not start) the threading HTTP server."""
|
|
return ThreadingHTTPServer((host, port), _Handler)
|
|
|
|
|
|
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
|
"""Run the web server until interrupted with Ctrl-C."""
|
|
server = create_server(host, port)
|
|
_echo(f"Steam Backlog Enforcer web UI: http://{host}:{port}")
|
|
_echo("Press Ctrl-C to stop.")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
_echo("\nShutting down.")
|
|
finally:
|
|
server.server_close()
|