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`.
318 lines
11 KiB
Python
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
|