diff --git a/docs/superpowers/specs/2026-05-12-server-live-state-display-design.md b/docs/superpowers/specs/2026-05-12-server-live-state-display-design.md index e52f8a0..c7e26bc 100644 --- a/docs/superpowers/specs/2026-05-12-server-live-state-display-design.md +++ b/docs/superpowers/specs/2026-05-12-server-live-state-display-design.md @@ -191,7 +191,7 @@ Implementation notes: - Single TCP connection per query (loopback, ~10-20ms total round-trip — pooling not worth it at this scale). - Header regex on `map :` and `players :` lines (the `(hibernating|not hibernating)` token is in `players :`). - Roster regex: split lines starting with `#`, skip the column-header line, robustly extract the quoted name + the `STEAM_X:Y:Z` token + `MM:SS` or `HH:MM:SS` connected duration + ping. Tolerate the two-numeric-prefix L4D2 variant (`# 2 1 "Crone" STEAM_1:0:...`). -- Steam ID conversion: `STEAM_X:Y:Z` → `76561197960265728 + (Y * 2) + Z` (returned as string). +- Steam ID conversion: `STEAM_X:Y:Z` → `76561197960265728 + (Z * 2) + Y` (Y is the low bit; returned as string). ### `l4d2web/services/steam_users.py` (new) diff --git a/l4d2web/services/rcon.py b/l4d2web/services/rcon.py index 0bee2cc..ea5e74f 100644 --- a/l4d2web/services/rcon.py +++ b/l4d2web/services/rcon.py @@ -168,7 +168,10 @@ def parse_status(body: str) -> StatusResponse: def _parse_duration(text: str) -> int: """Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds.""" - parts = [int(p) for p in text.split(":")] + try: + parts = [int(p) for p in text.split(":")] + except ValueError as exc: + raise RconError(f"unparseable connected duration: {text!r}") from exc if len(parts) == 2: return parts[0] * 60 + parts[1] if len(parts) == 3: diff --git a/l4d2web/tests/test_rcon.py b/l4d2web/tests/test_rcon.py index 29ee0fe..d6c0d41 100644 --- a/l4d2web/tests/test_rcon.py +++ b/l4d2web/tests/test_rcon.py @@ -42,7 +42,11 @@ def _unpack_one(conn: socket.socket) -> tuple[int, int, str]: @contextmanager def fake_rcon_server(handler) -> Iterator[int]: - """Start a TCP server on an ephemeral port; handler(conn) runs in a thread.""" + """Start a TCP server on an ephemeral port; handler(conn) runs in a thread. + + Handler exceptions propagate at context exit so a buggy handler surfaces + as a real test failure instead of degrading into a client-side timeout. + """ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("127.0.0.1", 0)) @@ -50,6 +54,8 @@ def fake_rcon_server(handler) -> Iterator[int]: port = server.getsockname()[1] server.settimeout(3.0) + handler_error: list[BaseException] = [] + def serve() -> None: try: conn, _ = server.accept() @@ -57,8 +63,8 @@ def fake_rcon_server(handler) -> Iterator[int]: handler(conn) finally: conn.close() - except Exception: - pass + except BaseException as exc: + handler_error.append(exc) t = threading.Thread(target=serve, daemon=True) t.start() @@ -67,6 +73,8 @@ def fake_rcon_server(handler) -> Iterator[int]: finally: server.close() t.join(timeout=1.0) + if handler_error: + raise AssertionError("fake_rcon_server handler raised") from handler_error[0] def test_auth_success_then_status(monkeypatch: pytest.MonkeyPatch) -> None: @@ -129,3 +137,19 @@ def test_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: with fake_rcon_server(handler) as port: with pytest.raises(RconError): query_status("127.0.0.1", port, "x", timeout=0.3) + + +def test_parse_duration_handles_hours() -> None: + from l4d2web.services.rcon import _parse_duration + + assert _parse_duration("00:21") == 21 + assert _parse_duration("01:23:45") == 5025 + assert _parse_duration("12:00") == 720 + + +def test_parse_duration_rejects_malformed_as_rcon_error() -> None: + from l4d2web.services.rcon import _parse_duration + + for bad in ["", ":", "abc", "1:", ":5", "1:2:3:4"]: + with pytest.raises(RconError): + _parse_duration(bad)