Files
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

509 lines
21 KiB
Python

"""Integration: the WebSocket endpoint, handshake, and connection guardrails
(phase 06 task 02; US-01/US-02) — every path driven by **real sockets**
against a live `uvicorn.Server` on an ephemeral `127.0.0.1` port (the
`ws_server` factory in `tests/integration/conftest.py`), with the app's
own supervisor reached for state assertions (the seat's `connected` flag,
the `mark_disconnected` call count, the socket census).
* **the happy handshake** — `POST /api/rooms` (a known token) →
`/ws/{code}` → `hello` → `welcome` (the joiner's seat index / host flag
correct) + the current `lobby` (the host's seat, `can_start`); a
`ping` inside the handshake window gets a `pong` before the `hello`,
* **the refusal closes** — unknown code → `err room_gone` + close `4401`;
a full room (a *new* token) → `err room_full` + close `4402`; no
`hello` within the (shortened) handshake window → close `4400`; a `p`
outside the supported range → `err protocol_mismatch` + close `4400` on
the first bad frame (before any seat is taken);
* **the origin check** — an explicit `allowed_origins` list that the
request's `Origin` misses is refused at the HTTP level (the client
sees a 403, never a close frame); the same-host rule (the empty
setting) admits the matching `Origin`;
* **one socket per token** — a second `hello` for a token evicts the
first (`err evicted` + close `4403`), the first stops receiving, and
the *evicted* socket's teardown does not mark the seat disconnected
(the new socket owns it — the crown stays put);
* **the size/rate guardrails** — an oversized frame is `too_big` and
three consecutive refusals close `4404`; a message flood is
`rate_limited` and three consecutive refusals close `4404`; five
shape violations in the handshake window close `4404`; a
`wrong_state` / `unknown_type` frame is refused with the named code
and the connection stays open;
* **the disconnect** — an abrupt transport close (the client's socket
vanishes without a close handshake) reaches the room as exactly one
`mark_disconnected` (the seat is kept for the rejoin path), releases
the registry slot, and leaves no error in the server log.
Note on the "abrupt close" mechanics: the installed `websockets` (17.2)
client has no `close(transport=True)` keyword, so the test drives the
abrupt path through `ws.transport.close()` — the same primitive. A bare
FIN is enough: the server's transport sees the EOF, uvicorn closes the
connection, and the endpoint's `finally` reports the disconnect.
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Callable
from typing import Any
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed, InvalidHandshake
pytestmark = pytest.mark.integration
HELLO_TIMEOUT_S = 0.5 # the shortened window, set via settings (task 02)
# --------------------------------------------------------------------- helpers
def _uri(url: str, code: str) -> str:
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
async def _connect(
url: str,
code: str,
token: str | None = None,
*,
p: int = 1,
name: str | None = None,
origin: str | None = None,
) -> Any:
"""A connected client with a `hello` already sent (the handshake is
the point under test, so the hello goes out immediately)."""
kwargs: dict[str, Any] = {"open_timeout": 10.0}
if origin is not None:
kwargs["origin"] = origin
ws = await websockets.connect(_uri(url, code), **kwargs)
if token is not None or name is not None or p != 1:
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))
return ws
async def _next_json(ws: Any) -> dict[str, Any]:
return json.loads(await ws.recv())
async def _close_code(ws: Any) -> int:
with pytest.raises(ConnectionClosed) as excinfo:
await ws.recv()
return int(excinfo.value.code)
async def _drain_until_close(
ws: Any, max_frames: int, wait_s: float = 5.0
) -> tuple[list[dict[str, Any]], int | None]:
"""Read frames until the connection closes.
A WebSocket client may hold *buffered* frames — the `too_big` /
`rate_limited` errors the server queued before the abuse close
arrive ahead of the close frame — so the close must be expected
after draining them, not on the very next read. Returns the frames
(in arrival order) and the close code (`None` if the window ran out
first, which the caller asserts against).
"""
frames: list[dict[str, Any]] = []
close_code: int | None = None
for _ in range(max_frames):
try:
raw = await asyncio.wait_for(ws.recv(), wait_s)
except ConnectionClosed as exc:
close_code = int(exc.code)
break
frames.append(json.loads(raw))
return frames, close_code
async def _poll(predicate: Callable[[], bool], what: str, wait_s: float = 5.0) -> None:
"""Poll across threads (the server's loop does the work; this thread
only observes it)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
if loop.time() > deadline:
pytest.fail(f"timed out waiting for {what}")
await asyncio.sleep(0.01)
def _create_room(url: str, token: str | None = None) -> tuple[str, str | None]:
"""`POST /api/rooms` over the live server; returns (code, you.token)."""
body: dict[str, Any] = {}
if token is not None:
body["token"] = token
response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0)
assert response.status_code == 201, response.text
data = response.json()
return data["room_code"], (data["you"] or {}).get("token")
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
)
assert response.status_code == 200, response.text
# ---------------------------------------------------------------- the happy path
async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, you_echo = _create_room(url, "tok-creator")
assert you_echo == "tok-creator"
async with await _connect(url, code, "tok-joiner", name="Joiner") as ws:
welcome = await _next_json(ws)
assert welcome["type"] == "welcome"
assert welcome["p"] == 1
assert welcome["room"] == code
assert welcome["state"] == "lobby"
assert welcome["you"] == {
"token": "tok-joiner",
"seat": 1,
"host": False,
}
seats = welcome["seats"]
assert len(seats) == 2
assert seats[0]["index"] == 0
assert seats[0]["host"] is True
assert seats[1]["index"] == 1
assert seats[1]["name"] == "Joiner"
assert seats[1]["host"] is False
lobby = await _next_json(ws)
assert lobby["type"] == "lobby"
assert lobby["code"] == code
assert lobby["state"] == "lobby"
assert lobby["host_seat"] == 0
# The fixture campaign is level-free, so `can_start` is False
# (the start preconditions — level selected, ready players — are
# covered by the phase-05 lifecycle suite over real levels).
assert lobby["can_start"] is False
# The ready-up: a toggle that re-broadcasts the lobby.
await ws.send(json.dumps({"type": "ready"}))
lobby2 = await _next_json(ws)
assert lobby2["can_start"] is False # still: no level selected
assert lobby2["seats"][1]["ready"] is True
assert len(lobby2["seats"]) == 2
async def test_a_new_seat_rebroadcasts_the_lobby_to_existing_members(
ws_server: Any,
) -> None:
"""PLAN §7: the lobby is re-broadcast on lobby-state changes, not
only to the joiner — a member who is already in the room must see
the new seat appear (and a ghost rejoin revives it), so the lobby
screen in an open tab updates without a refresh."""
with ws_server() as (url, _app):
code, _ = _create_room(url, "tok-host")
async with await _connect(url, code, "tok-host", name="Host") as host:
welcome = await _next_json(host)
assert welcome["type"] == "welcome"
await _next_json(host) # the initial lobby (one seat)
# The joiner arrives on their own socket: welcome + lobby for
# them, and a *re-broadcast* for the host.
async with await _connect(
url, code, "tok-joiner", name="Joiner"
) as joiner:
j_welcome = await _next_json(joiner)
assert j_welcome["type"] == "welcome"
assert j_welcome["you"]["seat"] == 1
j_lobby = await _next_json(joiner)
assert j_lobby["type"] == "lobby"
host_lobby = await _next_json(host)
assert host_lobby["type"] == "lobby"
assert host_lobby["code"] == code
assert len(host_lobby["seats"]) == 2
assert host_lobby["seats"][1]["name"] == "Joiner"
assert host_lobby["seats"][1]["ready"] is False
async def test_ping_before_hello_gets_pong(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
await ws.send(json.dumps({"type": "ping", "t": 7}))
pong = await _next_json(ws)
assert pong["type"] == "pong"
assert pong["t"] == 7
await ws.send(json.dumps({"type": "hello", "p": 1}))
welcome = await _next_json(ws)
assert welcome["type"] == "welcome"
await ws.close()
# ---------------------------------------------------------------- refusal closes
async def test_unknown_code_closes_4401(ws_server: Any) -> None:
with ws_server() as (url, _app):
ws = await websockets.connect(_uri(url, "ZZZZZ"), open_timeout=10.0)
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "room_gone"
assert await _close_code(ws) == 4401
async def test_full_room_new_token_closes_4402(ws_server: Any) -> None:
with ws_server(max_players=2) as (url, _app):
code, _ = _create_room(url, "tok-a")
_join(url, code, "tok-b") # the second (and last) seat
async with await _connect(url, code, "tok-c") as ws:
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "room_full"
assert await _close_code(ws) == 4402
async def test_no_hello_within_timeout_closes_4400(ws_server: Any) -> None:
with ws_server(hello_timeout_s=HELLO_TIMEOUT_S) as (url, _app):
code, _ = _create_room(url)
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
# No hello at all: the shortened window closes the socket.
assert await _close_code(ws) == 4400
async def test_wrong_protocol_version_closes_4400(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-x", p=99) as ws:
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "protocol_mismatch"
assert await _close_code(ws) == 4400
# No seat was taken on the refused version.
room = _app.state.supervisor.get(code)
assert len(room.seats) == 1
# ---------------------------------------------------------------- the origin check
async def test_disallowed_origin_refused_at_http_level(ws_server: Any) -> None:
with ws_server(allowed_origins=("https://elsewhere.example",)) as (url, _app):
code, _ = _create_room(url)
# The client's Origin (http://127.0.0.1:<port>) is not in the
# explicit list: the upgrade is refused before the accept — the
# client sees a 403 handshake failure, never a close frame.
with pytest.raises(InvalidHandshake):
await websockets.connect(_uri(url, code), open_timeout=10.0)
async def test_same_host_origin_allowed(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
origin = "http://" + url.removeprefix("http://")
async with await _connect(url, code, "tok-orig", origin=origin) as ws:
welcome = await _next_json(ws)
assert welcome["type"] == "welcome"
# ------------------------------------------------- one socket per token (eviction)
async def test_second_socket_same_token_evicts_first(ws_server: Any) -> None:
with ws_server() as (url, app):
code, _ = _create_room(url)
first = await _connect(url, code, "tok-shared")
welcome = await _next_json(first)
assert welcome["type"] == "welcome"
await _next_json(first) # the lobby
second = await _connect(url, code, "tok-shared")
welcome2 = await _next_json(second)
assert welcome2["type"] == "welcome"
assert welcome2["you"]["seat"] == 1 # the creator holds seat 0
# The evicted socket: the named refusal, then the close.
err = await _next_json(first)
assert err["type"] == "err"
assert err["code"] == "evicted"
assert await _close_code(first) == 4403
# The evicted socket's teardown must not mark the seat
# disconnected (the new socket owns it now), and the crown stays
# with the creator (an eviction is not a host leave — no
# `transfer_host`). Checked before `second` closes: closing it
# is a genuine disconnect for the token.
room = app.state.supervisor.get(code)
seat = room.seat_for_token("tok-shared")
assert seat is not None
assert seat.connected is True
assert room.seats[0].host is True
await second.close()
async def test_evicted_socket_stops_receiving(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
first = await _connect(url, code, "tok-shared")
await _next_json(first) # welcome
await _next_json(first) # lobby
second = await _connect(url, code, "tok-shared")
await _next_json(second) # welcome
await _next_json(second) # lobby
await _next_json(first) # the evicted err
await _close_code(first) # 4403 (asserted in the other test; the
# point here is what happens *after* the close)
# The evicted socket is out of the broadcast: a ready-up from the
# second socket re-broadcasts the lobby to the second only.
await second.send(json.dumps({"type": "ready"}))
lobby = await _next_json(second)
assert lobby["type"] == "lobby"
assert any(seat["ready"] for seat in lobby["seats"])
await second.close()
# ----------------------------------------------------------- size / rate guardrails
async def test_oversized_frame_refused_then_4404(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-big") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
oversized = json.dumps(
{"type": "ping", "t": 1, "pad": "x" * 5000} # > max_message_bytes
)
for _ in range(3): # three consecutive refusals -> the abuse close
await ws.send(oversized)
frames, close_code = await _drain_until_close(ws, 8)
assert frames, "the oversized frames must be refused"
assert all(
frame["type"] == "err" and frame["code"] == "too_big"
for frame in frames
), f"unexpected frames before the close: {frames}"
assert close_code == 4404
async def test_flood_rate_limited_then_4404(ws_server: Any) -> None:
with ws_server(max_inputs_per_s=5) as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-flood") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
for index in range(8): # burst 5: the 6th..8th exhaust the bucket
await ws.send(json.dumps({"type": "ping", "t": index}))
frames, close_code = await _drain_until_close(ws, 16)
assert any(
frame.get("type") == "err" and frame["code"] == "rate_limited"
for frame in frames
), f"the flood never tripped the token bucket: {frames}"
assert close_code == 4404
async def test_shape_violations_in_handshake_window_close_4404(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
for _ in range(5): # five shape violations inside the window
await ws.send("{not json")
assert await _close_code(ws) == 4404
async def test_wrong_state_refused_and_connection_stays(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-state") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
await ws.send(
json.dumps(
{
"type": "input", # legal in PLAYING, not in LOBBY
"seq": 1,
"move": {"x": 1.0, "y": 0.0},
"act": False,
"face": None,
}
)
)
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "wrong_state"
# One shape violation: the connection survives.
await ws.send(json.dumps({"type": "ping", "t": 3}))
pong = await _next_json(ws)
assert pong["type"] == "pong"
async def test_unknown_type_refused_and_connection_stays(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-bogus") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
await ws.send(json.dumps({"type": "teleport", "x": 1}))
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "unknown_type"
await ws.send(json.dumps({"type": "ping", "t": 4}))
pong = await _next_json(ws)
assert pong["type"] == "pong"
# ------------------------------------------------------------------- the disconnect
async def test_abrupt_close_notifies_room_once_and_releases(
ws_server: Any,
) -> None:
with ws_server() as (url, app):
code, _ = _create_room(url, "tok-abrupt")
supervisor = app.state.supervisor
calls: list[tuple[str, str]] = []
original = supervisor.mark_disconnected
def counting(raw_code: str, token: str) -> None:
calls.append((raw_code, token))
original(raw_code, token)
supervisor.mark_disconnected = counting
records: list[logging.LogRecord] = []
class _Collector(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
collector = _Collector(level=logging.ERROR)
logging.getLogger().addHandler(collector)
try:
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-abrupt"}))
assert json.loads(await ws.recv())["type"] == "welcome"
room = supervisor.get(code)
assert room.seat_for_token("tok-abrupt").connected is True
ws.transport.close() # abrupt: the transport vanishes, no close handshake
# Both halves of the teardown (the `mark_disconnected` and the
# registry release) must be visible before the assertions — they
# run in the server's loop, this one in the test's.
await _poll(
lambda: room.seat_for_token("tok-abrupt").connected is False
and app.state.ws_registry.socket_count == 0,
"the seat to be marked disconnected and the socket released",
)
finally:
supervisor.mark_disconnected = original
logging.getLogger().removeHandler(collector)
# Exactly one on_disconnect — and for the right token.
assert calls == [(code, "tok-abrupt")]
# No exception in the server log: the drop is a clean no-op.
errors = [record for record in records if record.levelno >= logging.ERROR]
exceptions = [
record for record in errors if record.exc_info is not None
]
assert not exceptions, (
"the server log carries an exception for the abrupt close:\n"
+ "\n".join(repr(record.exc_info) for record in exceptions)
)