"""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