Files
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

318 lines
11 KiB
Python

"""Integration: one room's crash does not take the server down (phase 05,
task 05; PLAN §6 "crash isolation") — the task's `UNDERCOOKED_TEST_HOOK`
seam in action.
Two live rooms, both created through the supervisor and both driven by
manual `tick_once` (a `FakeClock`; no test sleeps). An exception injected
into room A's sim through the test-only `Room._test_step_override` seam
(set only here — a `Callable` with `step`'s signature the `PLAYING` tick
calls in the kernel's place; never read from the network):
* room A becomes `CLOSED` — the sim marked `ABORTED` through
`sim.round_state.abort`, `abort_reason` names the exception type, and
the log line carries the room's code and round **never a player
token**;
* the `err` event is delivered — one `tick_crash` in the buffer, the
round number in the payload, drained exactly once;
* room B keeps ticking correctly — its sim advances one substep per
0.05 s frame, still `PLAYING`, untouched by A's fault;
* the supervisor's `gc_pass()` reaps room A (dropped from the registry —
the code no longer resolves, its token purged) and leaves room B
registered;
* a room that is `CLOSED` ticks nothing;
* the server keeps serving `/healthz` (200, the live room count is one —
room B).
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.clock import FakeClock
from undercooked.server.room import Room, RoomState
from undercooked.server.rooms import RoomSupervisor
from undercooked.server.sessions import new_session
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import (
Event,
EventKind,
ItemForm,
ItemKind,
KitchenState,
RoundResult,
StationKind,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
from undercooked.sim.step import PlayerInput
from undercooked.sim.step import step as real_step
pytestmark = pytest.mark.integration
#: The test-isolated content dir (a recipe book, no levels) — the app
#: boots on it; the supervisor's bundle carries the two test levels.
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`).
PERIOD = 0.05
# ------------------------------------------------------------------ content
def make_book() -> RecipeBook:
return RecipeBook(
recipes={
"soup": Recipe(
id="soup",
name="Soup",
base_score=40,
ingredients=[
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=1)
],
steps=[
Step(kind="pick", station=StationKind.SPAWN_SHELF),
Step(kind="serve", station=StationKind.SERVE),
],
)
},
ingredients=[ItemKind.TOMATO],
)
def make_level(level_id: str, *, time_limit: float, target: int) -> Level:
"""A 7x4 kitchen (one shelf, one serve window) — the rooms only need
to reach `PLAYING`; room A never runs its round (the injected fault
fires on the first playing tick) and room B never needs a verdict
(the 60 s limit outlives the test)."""
data: dict[str, object] = {
"id": level_id,
"name": f"Isolation kitchen {level_id}",
"order": 1,
"time_limit": time_limit,
"target_score": target,
"grid": [
"#######",
"#S...V#",
"#.....#",
"#######",
],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
},
"spawns": [[2, 2]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [4, 6],
"deadline": 30,
},
}
return Level.build(LevelDef.model_validate(data))
def make_bundle() -> ContentBundle:
"""The two levels: `08_bad` (room A — the injected fault) and
`08_good` (room B — the one that must keep ticking)."""
return ContentBundle(
recipe_book=make_book(),
levels=(
make_level("08_bad", time_limit=60.0, target=1000),
make_level("08_good", time_limit=60.0, target=1000),
),
)
# --------------------------------------------------------------------- app
class IsolationApp:
"""An app whose supervisor runs on a `FakeClock` (the
`FakeClockSupervisorApp` pattern `test_rooms_api.py` pins) with the
test's two-level bundle. The `TestClient` is used without the
context manager: the rooms' tick tasks are never started — every
tick is the test's own `tick_once`, and `gc_pass` runs in a fresh
`asyncio.run` with nothing to cancel."""
def __init__(self, **settings_overrides: Any) -> None:
self.settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides)
self.app = create_app(self.settings)
self.clock = FakeClock()
self.app.state.supervisor = RoomSupervisor(
self.settings, self.clock, make_bundle()
)
@property
def supervisor(self) -> RoomSupervisor:
return self.app.state.supervisor
# ------------------------------------------------------------------ the test
def bring_both_to_playing(
room_a: Room, room_b: Room, clock: FakeClock
) -> None:
"""Both rooms through the 3 s countdown (0.05 s wall frames); both
end `PLAYING` with a built, un-decided round."""
for _ in range(90):
clock.advance(PERIOD)
room_a.tick_once()
room_b.tick_once()
if room_a.state is RoomState.PLAYING and room_b.state is RoomState.PLAYING:
break
assert room_a.state is RoomState.PLAYING
assert room_b.state is RoomState.PLAYING
assert room_a.sim_state is not None and room_b.sim_state is not None
assert room_a.sim_state.result is None
assert room_b.sim_state.result is None
def inject_the_fault(room_a: Room) -> None:
"""The UNDERCOOKED_TEST_HOOK: room A's sim raises on its first
playing tick; room B's level is left to the real kernel."""
def boom(
state: KitchenState,
dt: float,
inputs: Sequence[PlayerInput],
book: RecipeBook | None,
) -> tuple[KitchenState, tuple[Event, ...]]:
if state.level_id == "08_bad":
msg = "sim exploded in room A"
raise RuntimeError(msg)
return real_step(state, dt, inputs, book)
room_a._test_step_override = boom
def check_room_a_is_aborted(
room_a: Room,
caplog: pytest.LogCaptureFixture,
token_a: str,
token_b: str,
) -> None:
"""Room A: aborted and closed; the err event delivered; the log line
carries the code and the round, never a player token."""
assert room_a.state is RoomState.CLOSED
assert room_a.sim_state is not None
assert room_a.sim_state.result is RoundResult.ABORTED
assert room_a.abort_reason is not None
assert "RuntimeError" in room_a.abort_reason
events = room_a.take_events()
crash = [
e
for e in events
if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash"
]
assert len(crash) == 1
assert crash[0].payload["round"] == room_a.round_no
assert room_a.take_events() == ()
assert room_a.code in caplog.text
assert "round 1" in caplog.text
assert token_a not in caplog.text
assert token_b not in caplog.text
def check_room_b_keeps_ticking(room_b: Room, clock: FakeClock) -> None:
"""Room B: untouched — still playing, still ticking correctly (one
substep per 0.05 s frame: no slip); and a closed room ticks nothing."""
sim = room_b.sim_state
assert sim is not None
tick_before = sim.tick
for _ in range(20): # 1.0 s of B's round
clock.advance(PERIOD)
room_b.tick_once()
assert room_b.state is RoomState.PLAYING
assert room_b.sim_state is not None
assert room_b.sim_state.tick == tick_before + 20
assert room_b.sim_state.result is None
async def check_gc_reaps_a_leaves_b(
sup: RoomSupervisor,
room_a: Room,
room_b: Room,
token_a: str,
token_b: str,
) -> None:
"""The GC reaps A (already `CLOSED`: the pass drops it — the close
itself happened in the crash isolation, not here) and leaves B
registered."""
assert await sup.gc_pass() == 0
assert sup.get(room_a.code) is None # A is gone: the code no longer resolves
assert sup.get(room_b.code) is room_b
assert sup.by_token(token_a) is None # A's token is purged
assert sup.by_token(token_b) is room_b
assert sup.stats()["rooms"] == 1
# ------------------------------------------------------------------ the test
def test_one_room_fails_without_affecting_the_other(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.ERROR, logger="undercooked.server.room")
fixture = IsolationApp()
sup = fixture.supervisor
clock = fixture.clock
async def scenario() -> None:
# Two live rooms, both created through the supervisor (the
# creator is seat 0, host; the rooms get the store as their
# `store`).
ada = new_session("Ada", clock)
bob = new_session("Bob", clock)
room_a = sup.create_room(ada, "Ada")
room_b = sup.create_room(bob, "Bob")
room_b.select_level("08_good") # A plays the campaign's first
assert room_a.level_id == "08_bad"
# Both are solo hosts: the start is legal as-is.
room_a.start(ada.token)
room_b.start(bob.token)
bring_both_to_playing(room_a, room_b, clock)
inject_the_fault(room_a)
# The crash tick — isolated: it must not propagate. The task-03
# substep guard makes a dt-0 tick skip `step` entirely (a
# zero-substep tick would drop queued inputs without applying
# them), so the fault needs a substep-advancing tick: advance
# the wall clock by one period. B's `_last_at` is re-anchored
# to the same wall time so B's cadence below stays exactly one
# 0.05 s frame per substep.
clock.advance(PERIOD)
room_a.tick_once()
room_b._last_at = clock.now()
check_room_a_is_aborted(room_a, caplog, ada.token, bob.token)
check_room_b_keeps_ticking(room_b, clock)
# A closed room ticks nothing.
clock.advance(PERIOD)
room_a.tick_once()
assert room_a.state is RoomState.CLOSED
await check_gc_reaps_a_leaves_b(sup, room_a, room_b, ada.token, bob.token)
asyncio.run(scenario())
# The server keeps serving /healthz — room B is the live room.
client = TestClient(fixture.app)
body = client.get("/healthz").json()
assert body["status"] == "ok"
assert body["rooms"] == 1