Files
undercooked/tests/integration/test_ws_abuse.py
T
ducoterra f26849c1f3 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`.
2026-10-09 11:13:25 -04:00

451 lines
18 KiB
Python

"""The abuse suite (phase 06 task 06): the wire's guardrails, driven by
real sockets against a real `uvicorn.Server`.
The contract under test is PLAN §7 / `docs/PROTOCOL.md` "Inbound
guards": a malformed frame is an `err` + a counted shape violation (5
inside 10 s → close `4404`); a frame over `max_message_bytes` is
`too_big`, a frame over the token bucket `rate_limited` (3 consecutive
→ `4404`); the per-state whitelist is enforced before the semantics;
the host-only verbs are host-only no matter how fast they are
repeated; the emote throttle (2 per 1.0 s per connection) is a drop,
not an error. A flood of any of these must leave the **other**
connections — and the server itself — exactly as they were: the guard
is per-connection, the room and the process are not the blast radius.
Every test here bounds its own window: a live room's `snap` stream
never goes quiet while the round is `PLAYING`, so a drain waits for a
*budget* of frames or a *deadline*, never for silence that a healthy
server does not produce (the pre-task-06 drain design hung on a fixed
server and only ever passed on the pre-fix one that aborted).
Measured on this suite's machine (the `docs/PROTOCOL.md` budget table
carries the same numbers): 200 concurrent handshakes wall 0.18 s,
p95 164 ms, RSS delta 23.4 MB, `send_queue_max` 2.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import time
from typing import Any
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed
from undercooked.server.ws import (
ABUSE_WINDOW_S,
MAX_SEND_QUEUE,
SHAPE_VIOLATIONS_TO_CLOSE,
)
pytestmark = pytest.mark.integration
#: The 4 KB boundary (PLAN §7's `max_message_bytes` default) in bytes.
MAX_MESSAGE_BYTES = 4096
#: The frame the cap refuses (64 KB — sixteen times the cap).
OVERSIZED_FRAME_BYTES = 64 * 1024
#: The `send_queue_max` bound for the burst test: the cap bounds
#: droppable traffic; non-droppable frames (the `pong`s a burst buys)
#: may push a queue past `MAX_SEND_QUEUE` until the pump drains it —
#: 4x the cap is the bound a 500-frame burst must stay inside
#: (measured peak: 2).
BURST_QUEUE_BOUND = 4 * MAX_SEND_QUEUE
#: The 200-connection memory bound (RSS delta, MB): the measured
#: footprint is 23.4 MB — the bound is 5x that, still 15x under a
#: per-connection "unbounded" regression would be visible at.
HANDSHAKE_MEMORY_BOUND_MB = 128.0
#: The p95 handshake bound (the task's: under 1 s). Measured 164 ms.
HANDSHAKE_P95_BOUND_S = 1.0
# ------------------------------------------------------------- the room
def _create_room(url: str, token: str) -> str:
"""One room, its creator at seat 0 as host."""
response = httpx.post(f"{url}/api/rooms", json={"token": token}, timeout=10.0)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
"""Seat `token` in `code`."""
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=10.0
)
assert 200 <= response.status_code < 300, response.text
async def _connect(
url: str, code: str, token: str, name: str | None = None
) -> websockets.ClientConnection:
"""A connected socket: the upgrade, the `hello`, the `welcome`."""
ws = await websockets.connect(
url.replace("http://", "ws://", 1) + f"/ws/{code}", open_timeout=10.0
)
hello: dict[str, Any] = {"type": "hello", "p": 1, "token": token}
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
welcome = json.loads(await ws.recv())
assert welcome["type"] == "welcome", welcome
return ws
async def _read_until_close(ws: Any) -> list[dict[str, Any]]:
"""Everything `ws` has left to say, until the close frame."""
frames: list[dict[str, Any]] = []
while True:
try:
frames.append(json.loads(await asyncio.wait_for(ws.recv(), 5.0)))
except (
TimeoutError,
ConnectionClosed,
):
break
return frames
async def _alive(ws: Any, tag: str) -> None:
"""A keep-alive round trip: `ping` → `pong` (the other connection
is still being served)."""
await ws.send(json.dumps({"type": "ping", "t": 1}))
deadline = asyncio.get_running_loop().time() + 5.0
while True:
frame = json.loads(
await asyncio.wait_for(
ws.recv(), max(0.1, deadline - asyncio.get_running_loop().time())
)
)
if frame["type"] == "pong":
return
assert (
asyncio.get_running_loop().time() < deadline
), f"{tag} lost its pong within 5 s: {frame}"
# ------------------------------------------------- 1: the shape storm
async def test_two_thousand_malformed_frames_cannot_take_the_server_down(
ws_server: Any,
) -> None:
"""2000 malformed frames on one socket: the socket closes at the
violation limit (`4404`), the *other* connection keeps being served,
and the server is healthy afterwards."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
_join(url, code, "tok-attacker")
host = await _connect(url, code, "tok-host", "Host")
attacker = await _connect(url, code, "tok-attacker", "Attacker")
room = app.state.supervisor.get(code)
assert room is not None
# The storm: 2000 shape violations (valid JSON, no `type` —
# `parse_client_message` answers `bad_field` for each). The
# close falls on the 5th inside the 10 s window; the rest of
# the flood meets a closed socket (suppressed — the guard did
# its job).
for _ in range(2000):
with contextlib.suppress(Exception):
await attacker.send(json.dumps({"nope": 1}))
# The attacker's socket is gone, by the abuse close.
await _read_until_close(attacker)
closed = attacker.close_code
assert closed is not None, "the attacker's socket was not closed"
assert closed == 4404, f"expected the 4404 abuse close, got {closed}"
assert (
SHAPE_VIOLATIONS_TO_CLOSE == 5
) # the bound the close fell on (the doc's "disconnect after 5")
assert ABUSE_WINDOW_S == 10.0
# The other connection is unaffected: a round trip on it, and
# the room exactly as it was (LOBBY, two seats — the attacker's
# seat is kept for the grace, not ejected by the flood).
await _alive(host, "the host")
assert room.state.value == "lobby"
assert len(room.seats) == 2
probe = await asyncio.to_thread(
httpx.get, f"{url}/healthz", timeout=5.0
)
assert probe.status_code == 200
# ------------------------------------------------- 2: the rate burst
async def test_a_500_message_burst_hits_the_rate_limit_and_the_queue_bound(
ws_server: Any,
) -> None:
"""500 pings on one socket: the bucket (40/40) empties, the
overage is `rate_limited` (3 consecutive → `4404`), the send
queue's high-water mark stays within its bound, and the server
survives."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
host = await _connect(url, code, "tok-host", "Host")
room = app.state.supervisor.get(code)
assert room is not None
# The burst: 500 pings, as fast as the socket lets us (the
# first 40 take the bucket's burst; the overage is refused).
for index in range(500):
with contextlib.suppress(Exception):
await host.send(json.dumps({"type": "ping", "t": index}))
frames = await _read_until_close(host)
codes = [
frame.get("code")
for frame in frames
if frame["type"] == "err"
]
assert "rate_limited" in codes, (
"no rate_limited err frame arrived in the burst"
)
closed = host.close_code
assert closed is not None, "the burst socket was not closed"
assert closed == 4404, f"expected the 4404 abuse close, got {closed}"
# The queue's high-water mark: the `pong`s the burst buys are
# non-droppable, so a queue may lawfully run past the cap —
# the bound is what must hold (measured peak: 2).
stats = app.state.ws_registry.stats()
assert stats["send_queue_max"] <= BURST_QUEUE_BOUND, stats
# And the server is healthy.
probe = await asyncio.to_thread(
httpx.get, f"{url}/healthz", timeout=5.0
)
assert probe.status_code == 200
assert room.state.value == "lobby"
# ------------------------------------------------- 3: the size cap
async def test_the_size_cap_refuses_64_kb_and_admits_the_4kb_boundary(
ws_server: Any,
) -> None:
"""A 64 KB frame is `too_big` (refused before parsing, a rate
violation); a frame of exactly `max_message_bytes` (4096 B — the
cap's boundary) is admitted: the `ready` in it lands (the lobby
re-broadcast) without a single `too_big`."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
host = await _connect(url, code, "tok-host", "Host")
room = app.state.supervisor.get(code)
assert room is not None
seat = room.seat_for_token("tok-host")
assert seat is not None and seat.ready is False
# The 64 KB *text* frame (the wire's contract): any bytes do
# (the cap is checked before the parse) — a `ready` padded
# past the cap with JSON whitespace.
oversized = b'{"type": "ready"}' + b" " * (
OVERSIZED_FRAME_BYTES - len(b'{"type": "ready"}')
)
assert len(oversized) == OVERSIZED_FRAME_BYTES
await host.send(oversized.decode("utf-8"))
# The 64 KB *binary* frame: the same bytes, but the client
# library encodes `bytes` as a binary frame — the regression
# the `KeyError`'s fix covers (the cap answers, not the
# stack's `receive_text()`).
await host.send(oversized)
# The 4 KB boundary: a `ready` padded with JSON whitespace to
# exactly `max_message_bytes` (not over — the cap is `>`).
payload = b'{"type": "ready"}'
boundary = payload + b" " * (MAX_MESSAGE_BYTES - len(payload))
assert len(boundary) == MAX_MESSAGE_BYTES
await host.send(boundary.decode("utf-8"))
# Read what the server said: two `too_big`s (the two 64 KB
# frames), the `ready`'s lobby re-broadcast, nothing else.
saw_too_big = 0
saw_lobby = False
deadline = asyncio.get_running_loop().time() + 5.0
while asyncio.get_running_loop().time() < deadline:
try:
frame = json.loads(await asyncio.wait_for(host.recv(), 0.5))
except TimeoutError:
break
if frame["type"] == "err":
if frame["code"] == "too_big":
saw_too_big += 1
else:
pytest.fail(f"an unexpected err frame: {frame}")
elif frame["type"] == "lobby":
saw_lobby = True
assert saw_too_big == 2, (
f"each 64 KB frame (text and binary) must be refused "
f"exactly once (too_big); saw {saw_too_big}"
)
assert saw_lobby, "the 4 KB boundary frame did not land (no ready-up)"
assert seat.ready is True, "the boundary frame's ready-up did not apply"
# ------------------------------------------------- 4: the lobby flood
async def test_a_lobby_flood_cannot_bypass_the_host_check_or_the_emote_throttle(
ws_server: Any,
) -> None:
"""A non-host floods the host-only verbs (`start` x 50,
`set_level` x 50) and the emote (x 20): the `not_host` refusals
are per-frame errors, not shape violations (the connection is not
closed by them), no transition happens, the level is untouched,
and the emote throttle (2 per 1.0 s) drops the overage silently
(no `err`, fewer `ev`s than the flood). The flood is paced to
the token bucket (40/40 — waves of ≤ 40 frames a second apart):
the `not_host` refusals must be tested *through* the rate guard,
not by tripping it first."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
_join(url, code, "tok-b")
host = await _connect(url, code, "tok-host", "Host")
b = await _connect(url, code, "tok-b", "B")
room = app.state.supervisor.get(code)
assert room is not None
original_level = room.level
# The flood, paced to the token bucket (40/40): waves of ≤ 40
# frames, a second apart (the refill outpaces the wave — the
# `not_host` refusals are what the flood is meant to test).
async def _wave(frames: list[dict[str, Any]]) -> None:
for frame in frames:
with contextlib.suppress(Exception):
await b.send(json.dumps(frame))
await asyncio.sleep(1.1)
await _wave([{"type": "start"} for _ in range(40)])
await _wave(
[{"type": "start"} for _ in range(10)]
+ [{"type": "set_level", "level": "not-a-level"} for _ in range(30)]
)
await _wave(
[{"type": "set_level", "level": "not-a-level"} for _ in range(20)]
+ [{"type": "emote", "id": "wave"} for _ in range(10)]
)
await _wave(
[{"type": "emote", "id": "wave"} for _ in range(10)]
)
# B's view: `not_host` for every start/set_level (the semantic
# refusals — 100 of them), no close by the flood.
not_host = 0
emote_events_b = 0
deadline = asyncio.get_running_loop().time() + 5.0
while asyncio.get_running_loop().time() < deadline:
try:
frame = json.loads(await asyncio.wait_for(b.recv(), 0.5))
except TimeoutError:
break
if frame["type"] == "err" and frame["code"] == "not_host":
not_host += 1
elif frame["type"] == "ev":
for event in frame["events"]:
if event.get("k") == "emote":
emote_events_b += 1
assert not_host >= 100, (
f"every start/set_level needs its not_host refusal "
f"(got {not_host})"
)
assert (
b.close_code is None
), "the not_host refusals are semantic errors, not a 4404"
# The room is exactly as it was: no transition, no level.
assert room.state.value == "lobby", (
"the start flood must not have left the lobby"
)
assert room.level is original_level, (
"the set_level flood must not have changed the level"
)
# The emote overage is a silent drop: fewer events than the
# flood (2 per 1.0 s window — the whole burst is one window on
# a healthy loop; a slow one may roll into a second).
assert emote_events_b < 20, (
f"the emote throttle must drop the overage (got "
f"{emote_events_b} events from a 20-emote burst)"
)
# And the host is unaffected.
await _alive(host, "the host")
# ------------------------------------------------- 5: 200 handshakes
async def test_200_concurrent_handshakes_stay_within_the_memory_bound(
ws_server: Any,
) -> None:
"""200 concurrent handshakes (50 rooms x 4 seats, every seat
taken by a real socket): all 200 `welcome`s land, the p95
handshake is under 1 s, the socket census sees them, and the
process's RSS growth stays within the bound (measured: 23.4 MB
→ the bound is 5x that)."""
with ws_server() as (url, app):
code_of: dict[str, str] = {}
for room_index in range(50):
creator = f"tok-{room_index}-0"
code = _create_room(url, creator)
for seat_index in (1, 2, 3):
_join(url, code, f"tok-{room_index}-{seat_index}")
for seat_index in (0, 1, 2, 3):
code_of[f"tok-{room_index}-{seat_index}"] = code
assert len(code_of) == 200
baseline_mb = app.state.ws_registry.memory_mb()
async def handshake(token: str) -> tuple[str, float]:
started = time.monotonic()
ws = await websockets.connect(
url.replace("http://", "ws://", 1) + f"/ws/{code_of[token]}",
open_timeout=10.0,
)
await ws.send(
json.dumps({"type": "hello", "p": 1, "token": token})
)
welcome = json.loads(await ws.recv())
assert welcome["type"] == "welcome", welcome
return token, time.monotonic() - started
results = await asyncio.gather(
*(handshake(token) for token in code_of)
)
assert len(results) == 200
durations = sorted(duration for _, duration in results)
p95 = durations[int(0.95 * len(durations)) - 1]
assert p95 < HANDSHAKE_P95_BOUND_S, (
f"the p95 handshake is {p95 * 1000:.0f} ms "
f"(bound {HANDSHAKE_P95_BOUND_S * 1000:.0f} ms)"
)
# The census sees all 200 sockets (one per token — no
# eviction: every token is distinct).
stats = app.state.ws_registry.stats()
assert stats["sockets"] == 200, stats
# And the process's growth is bounded (the 200 connections'
# footprint: sockets, queues, the room/seat bookkeeping).
delta_mb = app.state.ws_registry.memory_mb() - baseline_mb
assert delta_mb <= HANDSHAKE_MEMORY_BOUND_MB, (
f"200 handshakes grew the process by {delta_mb:.1f} MB "
f"(bound {HANDSHAKE_MEMORY_BOUND_MB:.0f} MB)"
)
# The server is healthy under the load.
probe = await asyncio.to_thread(
httpx.get, f"{url}/healthz", timeout=5.0
)
assert probe.status_code == 200