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:
2026-10-09 11:13:25 -04:00
parent f51ba919af
commit f26849c1f3
44 changed files with 11525 additions and 72 deletions
+344
View File
@@ -0,0 +1,344 @@
"""Unit: the send-queue policy and the per-connection guardrail arithmetic
(phase 06 task 02) — the `Connection`'s queue is the only buffer between
the game loop and a slow reader, so it must be bounded and honest:
* **snapshot droppability under a slow reader** — once the queue is at
`MAX_SEND_QUEUE` a new frame may only enter after the *oldest snapshot*
is dropped; `lobby` / `round` / `err` frames are never dropped and keep
their order,
* **the one-`rate_limited`-err bound** — a flood queues at most one
`rate_limited` `err` (the client is told once; the queue cannot grow
with the flood),
* **no unbounded growth** — the queue caps at `MAX_SEND_QUEUE` for the
dominant (snapshot) traffic; only the never-dropped frames can push it
past that, and only by their own count (game-bounded: one
`lobby` / `round` per state change, one coalesced `rate_limited`),
* **the token bucket** — `rate` tokens per second, a burst of `burst`,
refilled strictly off the injected `Clock` (never a wall clock),
* **the abuse tracker** — 3 *consecutive* rate/oversize violations inside
the 10 s window close `4404` (a clean frame resets the run); 5 shape
violations inside the window close it (a violation older than the
window does not count).
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import Any
import pytest
from undercooked.server.clock import FakeClock
from undercooked.server.protocol import (
ErrMsg,
ErrorCode,
LobbyMsg,
RoundMsg,
SnapMsg,
)
from undercooked.server.ws import (
ABUSE_WINDOW_S,
MAX_SEND_QUEUE,
AbuseTracker,
Connection,
TokenBucket,
pump_out,
)
pytestmark = pytest.mark.unit
class _FakeWs:
"""A `WsLike` that records what the pump wrote (no socket)."""
def __init__(self) -> None:
self.sent: list[dict[str, Any]] = []
async def send_text(self, data: str) -> None:
self.sent.append(json.loads(data))
def _conn(clock: FakeClock) -> tuple[Connection, _FakeWs]:
ws = _FakeWs()
return (
Connection(
ws=ws,
token="tok-unit", # noqa: S106 - a test identity, not a credential
player_id="tok-unit",
seat_index=0,
role="player",
rate=TokenBucket(10.0, 10, clock),
),
ws,
)
def _snap(index: int) -> SnapMsg:
return SnapMsg(type="snap", raw={"t": index, "p": []})
def _lobby(index: int) -> LobbyMsg:
return LobbyMsg(
type="lobby",
code=f"C{index}",
state="lobby",
can_start=False,
seats=[],
spectators=[],
)
def _round() -> RoundMsg:
return RoundMsg(
type="round",
result="won",
score=1,
target=1,
stars=0,
time_left=0.0,
counters={},
longest_streak=0,
players=[],
level_id="x",
seed=1,
ticks=0,
)
# ------------------------------------------------------------------ drop policy
async def test_slow_reader_drops_oldest_snapshots() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
# A slow reader: nothing is pumped yet, so the queue fills.
for index in range(3):
conn.enqueue(_lobby(index)) # non-droppable
for index in range(20):
conn.enqueue(_snap(index)) # droppable
assert conn.send_queue.qsize() <= MAX_SEND_QUEUE
# Drain and check: the lobbies are all there, in order; the survivors
# are the NEWEST snapshots.
got: list[Any] = []
while not conn.send_queue.empty():
got.append(conn.send_queue.get_nowait())
types = [item.type for item in got]
assert types == ["lobby"] * 3 + ["snap"] * (MAX_SEND_QUEUE - 3)
survivors = [item.raw["t"] for item in got if item.type == "snap"]
assert survivors == list(
range(20 - (MAX_SEND_QUEUE - 3), 20)
), "the oldest snapshots must be the ones dropped"
async def test_non_droppable_frames_preserve_order() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
frames = [
_lobby(0),
_snap(0),
_round(),
_snap(1),
_lobby(1),
_snap(2),
_round(),
_snap(3),
_lobby(2),
_snap(4),
_snap(5),
_snap(6),
_snap(7),
_snap(8),
]
for frame in frames:
conn.enqueue(frame)
got: list[Any] = []
while not conn.send_queue.empty():
got.append(conn.send_queue.get_nowait())
# Nothing non-droppable was dropped; the snaps that survived are the
# newest, and every frame keeps its relative order.
non_droppable = [item for item in got if item.type in ("lobby", "round")]
assert list(non_droppable) == [
_lobby(0),
_round(),
_lobby(1),
_round(),
_lobby(2),
]
snaps = [item for item in got if item.type == "snap"]
assert [s.raw["t"] for s in snaps] == sorted(s.raw["t"] for s in snaps)
async def test_queue_cannot_grow_with_a_rate_limited_flood() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(50): # a flood of refusals (e.g. 50 rate_limited errs)
conn.enqueue(
ErrMsg(
type="err",
code=ErrorCode.RATE_LIMITED,
msg=f"refusal {index}",
)
)
# At most ONE rate_limited err is queued (coalesced); the queue is
# bounded regardless of the flood's size.
held: list[Any] = []
while True:
try:
held.append(conn.send_queue.get_nowait())
except asyncio.QueueEmpty:
break
rate_limited = [item for item in held if item.code == ErrorCode.RATE_LIMITED]
assert len(rate_limited) == 1
assert len(held) <= MAX_SEND_QUEUE + 1
async def test_queue_caps_at_max_under_snapshot_flood() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(100): # the dominant traffic, a slow reader: the
conn.enqueue(_snap(index)) # queue holds the newest, caps at the max
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
held: list[Any] = []
while True:
try:
held.append(conn.send_queue.get_nowait())
except asyncio.QueueEmpty:
break
assert [item.raw["t"] for item in held] == list(range(92, 100))
async def test_all_non_droppable_frames_are_delivered_never_dropped() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(30):
conn.enqueue(_lobby(index))
# Nothing is ever dropped: once the queue holds no snapshot there is
# nothing to drop, and the queue grows with the non-droppable frames
# the server actually sent (bounded by the game's own event count —
# a lobby/round/err per state change, a coalesced `rate_limited`).
assert conn.send_queue.qsize() == 30
async def test_pump_delivers_frames_in_order_to_a_slow_reader() -> None:
clock = FakeClock()
conn, ws = _conn(clock)
for index in range(15):
conn.enqueue(_snap(index))
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
pump = asyncio.create_task(pump_out(ws, conn))
await asyncio.sleep(0) # let the pump run its passes
conn.alive = False
pump.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump
sent_snaps = [frame["raw"]["t"] for frame in ws.sent if frame["type"] == "snap"]
# Whatever the reader saw is in order and a suffix of the batch (only
# the oldest may have been dropped).
assert sent_snaps == sorted(sent_snaps)
assert sent_snaps == list(range(15 - len(sent_snaps), 15))
# ----------------------------------------------------------------- token bucket
def test_bucket_serves_its_burst_then_empty() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
assert all(bucket.take() for _ in range(5))
assert not bucket.take() # the sixth, with no time elapsed: empty
def test_bucket_refills_off_the_injected_clock() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
for _ in range(5):
bucket.take()
clock.advance(0.2) # one token (5/s * 0.2 s)
assert bucket.take()
assert not bucket.take()
clock.advance(1.0) # a full second: back to the burst cap
assert all(bucket.take() for _ in range(5))
assert not bucket.take()
def test_bucket_never_refills_past_the_burst_cap() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=3, clock=clock)
clock.advance(100.0) # a very long idle
assert bucket.tokens() == 3.0
assert all(bucket.take() for _ in range(3))
assert not bucket.take()
def test_bucket_rejects_impossible_parameters() -> None:
clock = FakeClock()
with pytest.raises(ValueError, match=r"rate must be > 0"):
TokenBucket(rate=0.0, burst=1, clock=clock)
with pytest.raises(ValueError, match=r"burst must be >= 1"):
TokenBucket(rate=1.0, burst=0, clock=clock)
def test_bucket_tokens_reports_the_refilled_count() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=2.0, burst=10, clock=clock)
for _ in range(5):
bucket.take()
assert bucket.tokens() == 5.0
clock.advance(0.5) # +1 (2/s * 0.5 s)
assert bucket.tokens() == 6.0
# ------------------------------------------------------------- the abuse tracker
def test_rate_close_after_three_consecutive_violations() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation()
assert not tracker.note_rate_violation()
assert tracker.note_rate_violation() # the third, inside the window
def test_clean_frame_resets_the_consecutive_run() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation()
tracker.note_clean()
assert not tracker.note_rate_violation()
assert not tracker.note_rate_violation() # the run only has two now
clock.advance(1.0)
assert tracker.note_rate_violation() # three consecutive again
def test_rate_violations_outside_the_window_do_not_close() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation() # t=0
clock.advance(ABUSE_WINDOW_S + 0.001) # the first stamp falls out
assert not tracker.note_rate_violation() # t=10.001: the run is two, one in-window
assert not tracker.note_rate_violation() # t=10.001: the run is three, two in-window
clock.advance(0.1)
assert tracker.note_rate_violation() # now three fit in the window
def test_shape_close_after_five_in_the_window() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
for _ in range(4):
assert not tracker.note_shape_violation()
assert tracker.note_shape_violation() # the fifth
def test_shape_violations_outside_the_window_do_not_close() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
for _ in range(4):
assert not tracker.note_shape_violation() # four at t=0
assert tracker.note_shape_violation() # the fifth closes (all in-window)
# Age the whole batch past the window, then it must not count again.
clock.advance(ABUSE_WINDOW_S + 0.001)
for _ in range(4):
assert not tracker.note_shape_violation() # one..four in-window
assert tracker.note_shape_violation() # the fifth in-window