phase: 06_realtime_websocket_protocol
All green. Final verification complete — writing the report.
**Final pass report — phase 06 `realtime_websocket_protocol`**
- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched
Next pending phase: `07_canvas_client_and_hud`.
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
"""Fixtures for the live-socket integration tests (phase 06).
|
||||
|
||||
`ws_server` is a **session-scoped factory**: one call spins up a real
|
||||
`uvicorn.Server` on an ephemeral `127.0.0.1` port (never a routable
|
||||
interface, the `tests/conftest.py` rule) with a `Settings` built from
|
||||
explicit overrides, and the returned context manager tears the server down
|
||||
when the test ends (a second safety close runs at session end). The app
|
||||
built here is yielded too, so a test can reach
|
||||
`app.state.supervisor` / `app.state.ws_registry` to assert on room state
|
||||
(the seat's `connected` flag, the disconnect count, the socket census)
|
||||
without a second client.
|
||||
|
||||
`live_server` is the same machine with one difference (phase 06 task 06):
|
||||
`UNDERCOOKED_LEVELS_DIR` defaults to the **shipped** `levels/` campaign
|
||||
(so a live server carries the real content the content gate validated),
|
||||
and any `Settings` field is overridable (`tick_hz`, `snapshot_hz`, …)
|
||||
the same way. A test that needs a playable, tunable round passes
|
||||
`levels_dir=` explicitly (the task-03 `levels_playable` precedent, and
|
||||
the task-06 `levels_round` campaign for the two-client round).
|
||||
|
||||
`ws_client` is a factory for the **scripted client** the round/abuse/
|
||||
budget suites drive: `ws_client(url, code, token=None, name=None)` returns
|
||||
a `WsClient` — a connected `websockets` socket with the `hello` already
|
||||
sent and a background reader that keeps a merged snapshot view
|
||||
(`client.view`: full frames reset, delta frames patch, the frame's `e`
|
||||
events accumulated) and a frame queue the client's three verbs consume:
|
||||
|
||||
* `send_model(model)` — queue a JSON frame out (fire-and-forget; the
|
||||
`input` choreography cadence never blocks on the socket),
|
||||
* `expect(type, wait_s)` — the next frame of `type`, the ones before it
|
||||
buffered and re-queued for a later verb,
|
||||
* `collect(type, n, wait_s)` — `n` frames of `type`, the same buffering.
|
||||
|
||||
Settings are built the same way `tests/conftest.py` `make_settings` does
|
||||
(the authoritative `config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` /
|
||||
`DEBUGPY*` stripped), with one deliberate difference: `UNDERCOOKED_LEVELS_DIR`
|
||||
is *kept*, pointed at the campaign the factory names (the `ws_server`
|
||||
default is the fixture-isolated campaign with a recipe book — `create_app`'s
|
||||
content gate passes on it; the `live_server` default is the shipped
|
||||
`levels/`), so a live server boots without the ambient environment leaking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
|
||||
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
|
||||
#: The shipped campaign (phase 06 task 06: `live_server` boots on it).
|
||||
SHIPPED_LEVELS_DIR = Path(__file__).resolve().parents[2] / "levels"
|
||||
|
||||
config = importlib.import_module("undercooked.config")
|
||||
app_module = importlib.import_module("undercooked.server.app")
|
||||
uvicorn = importlib.import_module("uvicorn")
|
||||
|
||||
|
||||
def _as_env_value(value: object) -> str:
|
||||
"""Render a Python override value the way `load_settings` reads it."""
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (str, os.PathLike)):
|
||||
return str(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return ",".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _wait_for_health(url: str, thread: threading.Thread) -> None:
|
||||
"""Poll `/healthz` until it answers 200, failing fast if the server died."""
|
||||
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
|
||||
deadline = time.monotonic() + timeout
|
||||
probe = httpx.Client(timeout=1.0, trust_env=False)
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if probe.get(f"{url}/healthz").status_code == 200:
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
thread.join(timeout=0.05)
|
||||
if not thread.is_alive():
|
||||
raise RuntimeError(f"live server on {url} died during startup")
|
||||
finally:
|
||||
probe.close()
|
||||
raise RuntimeError(f"live server on {url} never became healthy")
|
||||
|
||||
|
||||
def _start_live_server(
|
||||
overrides: dict[str, object], default_levels_dir: Path
|
||||
) -> tuple[str, Any, Any, threading.Thread]:
|
||||
"""One live uvicorn server on an ephemeral loopback port.
|
||||
|
||||
Returns `(url, application, server, thread)`; the caller wraps them in
|
||||
a `LiveServer`. `overrides` are `Settings` field names (validated
|
||||
against `config.ENV_KEYS`) — `levels_dir` falls back to
|
||||
`default_levels_dir` when the caller did not name it.
|
||||
"""
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
|
||||
}
|
||||
levels_dir = overrides.get("levels_dir", str(default_levels_dir))
|
||||
env["UNDERCOOKED_LEVELS_DIR"] = str(levels_dir)
|
||||
for name, value in overrides.items():
|
||||
env[config.ENV_KEYS[name]] = _as_env_value(value)
|
||||
settings = config.load_settings(env)
|
||||
application = app_module.create_app(settings)
|
||||
uv_config = uvicorn.Config(
|
||||
application,
|
||||
host=LIVE_HOST,
|
||||
port=0,
|
||||
log_level="warning",
|
||||
lifespan="on",
|
||||
)
|
||||
sock = uv_config.bind_socket()
|
||||
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
|
||||
server = uvicorn.Server(uv_config)
|
||||
thread = threading.Thread(
|
||||
target=server.run, args=([sock],), name="undercooked-live-ws", daemon=True
|
||||
)
|
||||
thread.start()
|
||||
_wait_for_health(url, thread)
|
||||
return url, application, server, thread
|
||||
|
||||
|
||||
class LiveServer:
|
||||
"""One live uvicorn server + its app (a context manager)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
app: Any,
|
||||
server: Any,
|
||||
thread: threading.Thread,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.app = app
|
||||
self._server = server
|
||||
self._thread = thread
|
||||
self._closed = False
|
||||
|
||||
def __enter__(self) -> tuple[str, Any]:
|
||||
return self.url, self.app
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._server.should_exit = True
|
||||
self._thread.join(timeout=15)
|
||||
if self._thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
|
||||
pytest.fail("live server did not shut down within 15 s")
|
||||
|
||||
|
||||
def _make_live_server_factory(default_levels_dir: Path) -> Callable[..., LiveServer]:
|
||||
started: list[LiveServer] = []
|
||||
|
||||
def _make(**overrides: object) -> LiveServer:
|
||||
if any(name not in config.ENV_KEYS for name in overrides):
|
||||
unknown = sorted(name for name in overrides if name not in config.ENV_KEYS)
|
||||
pytest.fail(f"unknown settings override(s): {unknown}")
|
||||
url, application, server, thread = _start_live_server(
|
||||
dict(overrides), default_levels_dir
|
||||
)
|
||||
live = LiveServer(url, application, server, thread)
|
||||
started.append(live)
|
||||
return live
|
||||
|
||||
# The session-end safety close (the context managers already closed
|
||||
# each server the test used; a leaked one is closed here).
|
||||
def _close_all() -> None:
|
||||
for live in started:
|
||||
live.close()
|
||||
|
||||
_make.close_all = _close_all # type: ignore[attr-defined]
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ws_server() -> Iterator[Any]:
|
||||
"""Factory: `with ws_server(max_players=2) as (url, app): …`
|
||||
|
||||
Each call starts a fresh `uvicorn.Server` on an ephemeral loopback
|
||||
port (bound before the serving thread starts, so the port is known
|
||||
exactly — no log scraping, no collisions between concurrent runs)
|
||||
with a `Settings` resolved from `**overrides` (field name, the same
|
||||
names `make_settings` takes) over the fixture-isolated content.
|
||||
Shutdown is requested through `should_exit` so the lifespan stops
|
||||
cleanly.
|
||||
"""
|
||||
factory = _make_live_server_factory(FIXTURE_LEVELS_DIR)
|
||||
yield factory
|
||||
factory.close_all() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def live_server() -> Iterator[Any]:
|
||||
"""Factory: `with live_server(tick_hz=20, snapshot_hz=20) as (url, app): …`
|
||||
|
||||
The task-06 live server: identical to `ws_server` except that
|
||||
`UNDERCOOKED_LEVELS_DIR` points at the **shipped** `levels/` campaign
|
||||
by default (override with `levels_dir=…`), so the server boots on the
|
||||
real, gate-validated content; `tick_hz` / `snapshot_hz` (and every
|
||||
other `Settings` field) are overridable the same way.
|
||||
"""
|
||||
factory = _make_live_server_factory(SHIPPED_LEVELS_DIR)
|
||||
yield factory
|
||||
factory.close_all() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _uri(url: str, code: str) -> str:
|
||||
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
|
||||
|
||||
|
||||
class View:
|
||||
"""The merged snapshot state a scripted client reasons about.
|
||||
|
||||
Full frames (`full: true`, the `FULL_SNAPSHOT_EVERY_TICKS` cadence and
|
||||
the `needs_full` resync) reset the entity maps; delta frames patch
|
||||
them (a `{"gone": true}` tombstone removes). The frame's `e` events
|
||||
accumulate in order (a boundary's events ride that one frame — the
|
||||
encoder's contract), and the scalars (`t` / `s` / `tgt` / `tl`) are
|
||||
the latest frame's."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.players: dict[str, dict[str, Any]] = {}
|
||||
self.stations: dict[tuple[Any, Any, Any], dict[str, Any]] = {}
|
||||
self.orders: dict[Any, dict[str, Any]] = {}
|
||||
self.events: list[dict[str, Any]] = []
|
||||
self.tick = 0
|
||||
self.score = 0
|
||||
self.target = 0
|
||||
self.time_left = 0.0
|
||||
|
||||
def apply(self, frame: dict[str, Any]) -> None:
|
||||
# The wire's `snap` message nests the encoder's frame under
|
||||
# `raw` (`SnapMsg.raw` — the message's own `p` is the protocol
|
||||
# version, the frame's own `p` is its players array).
|
||||
raw = frame.get("raw")
|
||||
if not isinstance(raw, dict):
|
||||
raw = frame
|
||||
if raw.get("full"):
|
||||
self.players = {
|
||||
entry["i"]: entry for entry in raw.get("p", []) if not entry.get("gone")
|
||||
}
|
||||
self.stations = {
|
||||
(entry["k"], entry["x"], entry["y"]): entry
|
||||
for entry in raw.get("st", [])
|
||||
if not entry.get("gone")
|
||||
}
|
||||
self.orders = {
|
||||
entry["id"]: entry for entry in raw.get("o", []) if not entry.get("gone")
|
||||
}
|
||||
else:
|
||||
delta = raw.get("d", {})
|
||||
for entry in delta.get("p", []):
|
||||
if entry.get("gone"):
|
||||
self.players.pop(entry["i"], None)
|
||||
else:
|
||||
self.players[entry["i"]] = entry
|
||||
for entry in delta.get("st", []):
|
||||
key = (entry["k"], entry["x"], entry["y"])
|
||||
if entry.get("gone"):
|
||||
self.stations.pop(key, None)
|
||||
else:
|
||||
self.stations[key] = entry
|
||||
for entry in delta.get("o", []):
|
||||
if entry.get("gone"):
|
||||
self.orders.pop(entry["id"], None)
|
||||
else:
|
||||
self.orders[entry["id"]] = entry
|
||||
self.events.extend(raw.get("e", []))
|
||||
self.tick = raw.get("t", self.tick)
|
||||
self.score = raw.get("s", self.score)
|
||||
self.target = raw.get("tgt", self.target)
|
||||
self.time_left = raw.get("tl", self.time_left)
|
||||
|
||||
def player(self, index: str) -> dict[str, Any] | None:
|
||||
return self.players.get(index)
|
||||
|
||||
def station(self, kind: str) -> dict[str, Any] | None:
|
||||
"""The first station of `kind` (the wire's identity is `k` +
|
||||
coordinates; a level's stations of a kind sit on distinct cells)."""
|
||||
for (station_kind, _, _), entry in self.stations.items():
|
||||
if station_kind == kind:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def station_at(self, kind: str, x: float, y: float) -> dict[str, Any] | None:
|
||||
return self.stations.get((kind, x, y))
|
||||
|
||||
def event(self, kind: str) -> dict[str, Any] | None:
|
||||
for entry in self.events:
|
||||
if entry.get("k") == kind:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
class WsClient:
|
||||
"""The scripted client (phase 06 task 06): a connected socket with the
|
||||
`hello` already sent, a background reader (the merged `view` + the
|
||||
frame queue), and the three verbs the suites name.
|
||||
|
||||
`send_model` is fire-and-forget by design: the choreography feeds
|
||||
inputs on a cadence (~10 Hz) faster than the socket's round trip, and
|
||||
the room's per-tick queue is what serialises the intent — a blocking
|
||||
send would add latency the sim's queue discipline is meant to absorb.
|
||||
`raw` is the malformed-frame half (the abuse suite's `not json`).
|
||||
"""
|
||||
|
||||
def __init__(self, ws: Any) -> None:
|
||||
self.ws = ws
|
||||
self.view = View()
|
||||
self._frames: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
self._reader: asyncio.Task[None] | None = None
|
||||
self._closed = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._reader = asyncio.get_running_loop().create_task(self._read_loop())
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
try:
|
||||
while not self._closed:
|
||||
raw = await self.ws.recv()
|
||||
frame = json.loads(raw)
|
||||
if frame.get("type") == "snap":
|
||||
self.view.apply(frame)
|
||||
self._frames.put_nowait(frame)
|
||||
except (ConnectionClosed, asyncio.CancelledError):
|
||||
pass
|
||||
|
||||
def send_model(self, model: dict[str, Any]) -> None:
|
||||
"""Queue one JSON frame out (the task's `send_model`)."""
|
||||
asyncio.get_running_loop().create_task(self.ws.send(json.dumps(model)))
|
||||
|
||||
async def send(self, model: dict[str, Any]) -> None:
|
||||
await self.ws.send(json.dumps(model))
|
||||
|
||||
def raw(self, text: str) -> None:
|
||||
"""One raw frame out (the malformed half: `not json`, …)."""
|
||||
asyncio.get_running_loop().create_task(self.ws.send(text))
|
||||
|
||||
async def expect(self, type: str, wait_s: float = 10.0) -> dict[str, Any]:
|
||||
"""The next frame of `type`; the frames before it are buffered and
|
||||
re-queued (a later verb sees them, in order)."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + wait_s
|
||||
held: list[dict[str, Any]] = []
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
msg = f"timed out waiting for a {type!r} frame"
|
||||
raise TimeoutError(msg)
|
||||
frame = await asyncio.wait_for(self._frames.get(), remaining)
|
||||
if frame.get("type") == type:
|
||||
for buffered in held:
|
||||
self._frames.put_nowait(buffered)
|
||||
return frame
|
||||
held.append(frame)
|
||||
|
||||
async def collect(
|
||||
self, type: str, n: int, wait_s: float = 30.0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""`n` frames of `type` (in arrival order); the frames in between
|
||||
are buffered and re-queued."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + wait_s
|
||||
held: list[dict[str, Any]] = []
|
||||
out: list[dict[str, Any]] = []
|
||||
while len(out) < n:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
msg = f"timed out after {len(out)}/{n} {type!r} frames"
|
||||
raise TimeoutError(msg)
|
||||
frame = await asyncio.wait_for(self._frames.get(), remaining)
|
||||
if frame.get("type") == type:
|
||||
out.append(frame)
|
||||
else:
|
||||
held.append(frame)
|
||||
for buffered in held:
|
||||
self._frames.put_nowait(buffered)
|
||||
return out
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if self._reader is not None:
|
||||
self._reader.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._reader
|
||||
with contextlib.suppress(Exception):
|
||||
await self.ws.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ws_client() -> Callable[..., Any]:
|
||||
"""Factory: `client = await ws_client(url, code, token, name=…)`.
|
||||
|
||||
Connects to `/ws/{code}` on the named server, sends the `hello`
|
||||
(the `token` joins or rejoins; absent, the server mints one and the
|
||||
`welcome` echoes it) and returns the scripted `WsClient` (its reader
|
||||
already running, its `view` merging snapshots).
|
||||
"""
|
||||
|
||||
async def _connect(
|
||||
url: str,
|
||||
code: str,
|
||||
token: str | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
p: int = 1,
|
||||
) -> WsClient:
|
||||
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
||||
hello: dict[str, Any] = {"type": "hello", "p": p}
|
||||
if token is not None:
|
||||
hello["token"] = token
|
||||
if name is not None:
|
||||
hello["name"] = name
|
||||
await ws.send(json.dumps(hello))
|
||||
client = WsClient(ws)
|
||||
client.start()
|
||||
return client
|
||||
|
||||
return _connect
|
||||
Reference in New Issue
Block a user