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:
@@ -0,0 +1,517 @@
|
||||
"""Integration: input ingestion over **real sockets** (phase 06, task 03;
|
||||
US-04/US-06) — the `seq` discipline, the hostile-clamp invariants, the
|
||||
seat rules and the per-tick queue, driven against a live `uvicorn.Server`
|
||||
(the `ws_server` factory in `tests/integration/conftest.py`) with a
|
||||
*playable* level (the shipped, already-validated `01_first_kitchen` in
|
||||
`tests/fixtures/levels_playable/`, so a room can actually reach `PLAYING`
|
||||
— the fixture campaign in `tests/fixtures/levels/` has no levels and a
|
||||
room built from it cannot start a round).
|
||||
|
||||
* **the `seq` discipline, end to end** — monotonic seqs are accepted
|
||||
(`inputs_received` / `last_seq` advance); an equal seq is an idempotent
|
||||
resend, silently dropped; a backwards seq is `err bad_field` *with the
|
||||
frame's `seq`* + the room's `resync` event (`stale_seqs` counted,
|
||||
`last_seq` never lowered); a jump of ≤ 3 ahead is tolerated silently;
|
||||
a larger jump is accepted and counted in `input_gaps`;
|
||||
* **the hostile clamp** — a `move` of `{x: 1000, y: 1000}` is a
|
||||
`bad_field` at the wire (the `Vec2Msg`'s early, explicit half), and a
|
||||
cook that *is* moving (fed a legitimate intent) never exceeds
|
||||
`MAX_SPEED x 1.01` — per-tick distance `vel x 0.05` within
|
||||
`MAX_SPEED x 0.05 x 1.01`; a `pos` field (the teleport attempt) is a
|
||||
`bad_field` by `extra="forbid"`; a non-bool `act` is rejected upstream;
|
||||
* **the seat rules** — a spectator's input is `err not_seated`; a
|
||||
`DISCONNECTED` seat's input is silently ignored (no err, no stat);
|
||||
* **the per-tick queue** — a 20-frame burst on a one-seat room never
|
||||
leaves more than `4 x 1` frames queued (the capacity), every frame is
|
||||
accounted (`inputs_received + inputs_dropped == 20` — the drops
|
||||
themselves are proven deterministic in
|
||||
`tests/unit/test_ws_input_path.py`), and the sim keeps stepping
|
||||
through it.
|
||||
|
||||
The sim-side half of the hostile-speed proof — `(1000, 1000)` driven
|
||||
through `to_player_input` + `Room.queue_input` + `tick_once` for 20
|
||||
ticks — lives in `tests/unit/test_ws_input_path.py`, where it can be
|
||||
measured tick-exact; here the live socket proves the wire layer's half.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
|
||||
from undercooked.server.room import Room, RoomState
|
||||
from undercooked.sim.movement import MAX_SPEED
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: The playable campaign (the shipped level 1, validated by the content
|
||||
#: gate at server boot) — a room built from it can start a round.
|
||||
PLAYABLE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _uri(url: str, code: str) -> str:
|
||||
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
|
||||
|
||||
|
||||
async def _connect(
|
||||
url: str,
|
||||
code: str,
|
||||
token: str | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> Any:
|
||||
"""A connected client with the `hello` already sent."""
|
||||
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
||||
if token is not None or name is not None:
|
||||
hello: dict[str, Any] = {"type": "hello", "p": 1}
|
||||
if token is not None:
|
||||
hello["token"] = token
|
||||
if name is not None:
|
||||
hello["name"] = name
|
||||
await ws.send(json.dumps(hello))
|
||||
return ws
|
||||
|
||||
|
||||
async def _next_json(ws: Any, wait_s: float = 10.0) -> dict[str, Any]:
|
||||
return json.loads(await asyncio.wait_for(ws.recv(), wait_s))
|
||||
|
||||
|
||||
async def _next_non_countdown(ws: Any, wait_s: float = 10.0) -> dict[str, Any]:
|
||||
"""The next frame that is not part of the continuous `PLAYING`
|
||||
stream (task 04: the 3 s countdown's `n=3,2,1` frames queue before
|
||||
the first `PLAYING` frame, and the `snap` stream then flows
|
||||
continuously — a `PLAYING` probe skips both)."""
|
||||
while True:
|
||||
frame = await _next_json(ws, wait_s)
|
||||
if frame["type"] not in ("countdown", "snap"):
|
||||
return frame
|
||||
|
||||
|
||||
async def _wait_for(
|
||||
predicate: Callable[[], bool], what: str, wait_s: float = 15.0
|
||||
) -> None:
|
||||
"""Poll across threads (the server's loop does the work; this thread
|
||||
only observes it)."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + wait_s
|
||||
while not predicate():
|
||||
if loop.time() > deadline:
|
||||
pytest.fail(f"timed out waiting for {what}")
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
def _create_room(url: str, token: str | None = None) -> str:
|
||||
"""`POST /api/rooms` over the live server; returns the code."""
|
||||
body: dict[str, Any] = {}
|
||||
if token is not None:
|
||||
body["token"] = token
|
||||
response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["room_code"]
|
||||
|
||||
|
||||
def _join(url: str, code: str, token: str) -> None:
|
||||
response = httpx.post(
|
||||
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def _conn_for_token(app: Any, code: str, token: str) -> Any:
|
||||
for conn in app.state.ws_registry.for_room(code):
|
||||
if conn.token == token:
|
||||
return conn
|
||||
msg = f"no live connection for token {token!r} in room {code}"
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
async def _playing_room(
|
||||
url: str, app: Any, host_token: str
|
||||
) -> tuple[str, Room, Any]:
|
||||
"""Create the room, connect the host, start (solo host), and wait for
|
||||
`PLAYING` (the 3 s real-time countdown on the 20 Hz tick task)."""
|
||||
code = _create_room(url, host_token)
|
||||
host = await _connect(url, code, host_token, name="Host")
|
||||
await _next_json(host) # welcome
|
||||
await _next_json(host) # lobby
|
||||
await host.send(json.dumps({"type": "start"})) # solo host
|
||||
room = app.state.supervisor.get(code)
|
||||
assert room is not None
|
||||
await _wait_for(
|
||||
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
|
||||
f"room {code} to reach PLAYING",
|
||||
wait_s=20.0,
|
||||
)
|
||||
return code, room, host
|
||||
|
||||
|
||||
async def _send_input(ws: Any, seq: int, **extra: Any) -> None:
|
||||
frame: dict[str, Any] = {
|
||||
"type": "input",
|
||||
"seq": seq,
|
||||
"move": {"x": 1.0, "y": 0.0},
|
||||
"act": False,
|
||||
"face": None,
|
||||
}
|
||||
frame.update(extra)
|
||||
await ws.send(json.dumps(frame))
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the seq discipline
|
||||
|
||||
|
||||
async def test_seq_discipline_end_to_end(ws_server: Any) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
code, room, host = await _playing_room(url, app, "tok-seq")
|
||||
stats = room.stats
|
||||
|
||||
# Monotonic seqs are accepted.
|
||||
for seq in (1, 2, 3):
|
||||
await _send_input(host, seq)
|
||||
await _wait_for(
|
||||
lambda: stats()["inputs_received"] == 3,
|
||||
"the three accepted inputs to be counted",
|
||||
)
|
||||
conn = _conn_for_token(app, code, "tok-seq")
|
||||
assert conn.last_seq == 3
|
||||
|
||||
# An equal seq is an idempotent resend: silently dropped.
|
||||
await _send_input(host, 3)
|
||||
await asyncio.sleep(0.3)
|
||||
assert stats()["inputs_received"] == 3
|
||||
assert conn.last_seq == 3
|
||||
|
||||
# A backwards seq: `err bad_field` with the frame's `seq` + the
|
||||
# room's `resync` event; `last_seq` is never lowered.
|
||||
await _send_input(host, 1)
|
||||
err = await _next_non_countdown(host)
|
||||
assert err["type"] == "err"
|
||||
assert err["code"] == "bad_field"
|
||||
assert err["seq"] == 1
|
||||
await _wait_for(
|
||||
lambda: stats()["stale_seqs"] == 1,
|
||||
"the stale-seq refusal to be counted",
|
||||
)
|
||||
assert conn.last_seq == 3
|
||||
await _wait_for(
|
||||
lambda: any(event.kind == "resync" for event in room.events_since_snapshot),
|
||||
"the resync event to be buffered",
|
||||
)
|
||||
resync = next(
|
||||
event
|
||||
for event in room.events_since_snapshot
|
||||
if event.kind == "resync"
|
||||
)
|
||||
assert resync.payload == {"player_id": "tok-seq", "seq": 1}
|
||||
|
||||
# A jump of ≤ 3 ahead is tolerated silently.
|
||||
await _send_input(host, 6)
|
||||
await _wait_for(
|
||||
lambda: conn.last_seq == 6 and stats()["inputs_received"] == 4,
|
||||
"the tolerated gap to be accepted",
|
||||
)
|
||||
assert stats()["input_gaps"] == 0
|
||||
|
||||
# A larger jump is accepted and counted.
|
||||
await _send_input(host, 60)
|
||||
await _wait_for(
|
||||
lambda: conn.last_seq == 60 and stats()["inputs_received"] == 5,
|
||||
"the large gap to be accepted",
|
||||
)
|
||||
assert stats()["input_gaps"] == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the hostile clamp
|
||||
|
||||
|
||||
async def test_a_hostile_move_is_rejected_and_the_cook_stays_within_max_speed(
|
||||
ws_server: Any,
|
||||
) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
_, room, host = await _playing_room(url, app, "tok-hostile")
|
||||
|
||||
# The wire's early, explicit half: magnitude > 1 is a `bad_field`,
|
||||
# whatever the `seq` is (the parse fails before the seq check).
|
||||
await _send_input(host, 999, move={"x": 1000.0, "y": 1000.0}, act=True)
|
||||
err = await _next_non_countdown(host)
|
||||
assert err["type"] == "err"
|
||||
assert err["code"] == "bad_field"
|
||||
|
||||
# The sim-side bound: feed a legitimate intent on a ~10 Hz feeder
|
||||
# (the cook accelerates to the cap and stays there) while the
|
||||
# hostile frame's effect — none — is what the bound must hold.
|
||||
base = 1
|
||||
|
||||
# Feed slightly faster than the 50 ms tick cadence: a frame in
|
||||
# every tick window (the room's substep guard carries a queued
|
||||
# frame to the next substep-advancing tick, so at this cadence
|
||||
# the cook has an intent on every tick and accelerates to the
|
||||
# cap — a slower feed would let it brake between frames).
|
||||
async def feed() -> None:
|
||||
nonlocal base
|
||||
for offset in range(24):
|
||||
await _send_input(host, base + offset)
|
||||
base += 1
|
||||
await asyncio.sleep(0.04)
|
||||
|
||||
feeder = asyncio.create_task(feed())
|
||||
await asyncio.sleep(0.7) # acceleration window (30 tiles/s^2)
|
||||
samples: list[float] = []
|
||||
for _ in range(5):
|
||||
assert room.sim_state is not None
|
||||
(player,) = room.sim_state.players
|
||||
samples.append(player.vel.length)
|
||||
await asyncio.sleep(0.1)
|
||||
feeder.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await feeder
|
||||
|
||||
bound = MAX_SPEED * 1.01
|
||||
assert samples, "no velocity samples were taken"
|
||||
assert max(samples) <= bound, (
|
||||
f"the cook reached {max(samples):.3f} tiles/s; the bound is {bound:.3f}"
|
||||
)
|
||||
# The bound was non-trivial: the cook was genuinely moving (within
|
||||
# the cap) when it was measured — a stationary cook would prove
|
||||
# nothing about a hostile `move`.
|
||||
assert max(samples) >= 0.5 * MAX_SPEED, (
|
||||
f"the cook only reached {max(samples):.3f} tiles/s — the bound "
|
||||
f"was not exercised (expected near {MAX_SPEED:.3f})"
|
||||
)
|
||||
# Per-tick distance = vel x STEP_DT: the sweep can only apply
|
||||
# *less* motion than the velocity would, so the distance bound
|
||||
# follows from the velocity bound.
|
||||
assert max(samples) * 0.05 <= MAX_SPEED * 0.05 * 1.01
|
||||
|
||||
|
||||
async def test_a_position_field_is_rejected_by_the_schema(ws_server: Any) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
code, room, host = await _playing_room(url, app, "tok-pos")
|
||||
# The teleport attempt: a `pos` field in an `input` (there is no
|
||||
# position field in the protocol; `extra="forbid"` rejects one).
|
||||
await host.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input",
|
||||
"seq": 1,
|
||||
"move": {"x": 1.0, "y": 0.0},
|
||||
"act": False,
|
||||
"face": None,
|
||||
"pos": {"x": 3.5, "y": 1.5},
|
||||
}
|
||||
)
|
||||
)
|
||||
err = await _next_non_countdown(host)
|
||||
assert err["type"] == "err"
|
||||
assert err["code"] == "bad_field"
|
||||
assert "pos" in err["msg"] # the extra field is named
|
||||
# Nothing was accepted (and `last_seq` never moved on a refusal).
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
assert _conn_for_token(app, code, "tok-pos").last_seq == 0
|
||||
|
||||
|
||||
async def test_act_non_bool_is_rejected_upstream(ws_server: Any) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
_, room, host = await _playing_room(url, app, "tok-act")
|
||||
await host.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input",
|
||||
"seq": 1,
|
||||
"move": {"x": 1.0, "y": 0.0},
|
||||
"act": 1, # a JSON int, not a bool: the strict model refuses
|
||||
"face": None,
|
||||
}
|
||||
)
|
||||
)
|
||||
err = await _next_non_countdown(host)
|
||||
assert err["type"] == "err"
|
||||
assert err["code"] == "bad_field"
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
|
||||
|
||||
async def test_face_is_derived_from_move_in_the_sim(ws_server: Any) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
_, room, host = await _playing_room(url, app, "tok-face")
|
||||
# `face: null` + `move (0, -1)`: the facing is derived (up).
|
||||
await _send_input(host, 1, move={"x": 0.0, "y": -1.0})
|
||||
await _wait_for(
|
||||
lambda: room.sim_state is not None
|
||||
and room.sim_state.players[0].facing == "up",
|
||||
"the derived facing to reach the sim",
|
||||
wait_s=10.0,
|
||||
)
|
||||
# And the other way down, for symmetry.
|
||||
await _send_input(host, 2, move={"x": 0.0, "y": 1.0})
|
||||
await _wait_for(
|
||||
lambda: room.sim_state is not None
|
||||
and room.sim_state.players[0].facing == "down",
|
||||
"the derived facing to flip",
|
||||
wait_s=10.0,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the seat rules
|
||||
|
||||
|
||||
async def test_a_spectators_input_is_refused_not_seated(ws_server: Any) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
code, _, _ = await _playing_room(url, app, "tok-spect")
|
||||
room = app.state.supervisor.get(code)
|
||||
assert room is not None
|
||||
# A late join (the room is past `LOBBY`): `tok-watch` becomes a
|
||||
# spectator — its input is the documented `not_seated`.
|
||||
watcher = await _connect(url, code, "tok-watch", name="Watcher")
|
||||
await _next_json(watcher) # welcome
|
||||
await _next_json(watcher) # lobby
|
||||
await _send_input(watcher, 1)
|
||||
err = await _next_json(watcher)
|
||||
assert err["type"] == "err"
|
||||
assert err["code"] == "not_seated"
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
|
||||
|
||||
async def test_a_disconnected_seats_input_is_silently_ignored(ws_server: Any) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
# Two players in before the start: the host starts, the ready-up
|
||||
# is the non-host's.
|
||||
code = _create_room(url, "tok-p1")
|
||||
_join(url, code, "tok-p2")
|
||||
p1 = await _connect(url, code, "tok-p1", name="P1")
|
||||
await _next_json(p1) # welcome
|
||||
await _next_json(p1) # lobby
|
||||
p2 = await _connect(url, code, "tok-p2", name="P2")
|
||||
await _next_json(p2) # welcome
|
||||
await _next_json(p2) # lobby
|
||||
# The documented keep-alive (phase 06 task 05): both clients
|
||||
# ping periodically — under load the `PLAYING` wait below may
|
||||
# outlast the 12 s heartbeat dead window, and a silent socket
|
||||
# is a dead client (the server's close `1000` is correct
|
||||
# behaviour, not a bug).
|
||||
async def _keepalive() -> None:
|
||||
n = 0
|
||||
while True:
|
||||
await asyncio.sleep(2.0)
|
||||
n += 1
|
||||
for socket in (p1, p2):
|
||||
with contextlib.suppress(Exception):
|
||||
await socket.send(json.dumps({"type": "ping", "t": n}))
|
||||
|
||||
keepalive = asyncio.create_task(_keepalive())
|
||||
await p2.send(json.dumps({"type": "ready"}))
|
||||
await p1.send(json.dumps({"type": "start"}))
|
||||
room = app.state.supervisor.get(code)
|
||||
assert room is not None
|
||||
await _wait_for(
|
||||
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
|
||||
f"room {code} to reach PLAYING",
|
||||
wait_s=20.0,
|
||||
)
|
||||
# The seat's connection "drops" (the supervisor marks it — the
|
||||
# socket stays open: this is the mark the endpoint's `finally`
|
||||
# makes on a genuine drop, driven directly here).
|
||||
app.state.supervisor.mark_disconnected(code, "tok-p2")
|
||||
assert room.seat_for_token("tok-p2").connected is False
|
||||
# The (still-open) socket's input is silently ignored: no err, no
|
||||
# stat, `last_seq` untouched. (The `ready` re-broadcast is still
|
||||
# in the socket's queue — drain it first: it predates the drop.)
|
||||
# Bounded by design: a live room's `snap` stream never goes
|
||||
# quiet while the round is `PLAYING` (this level's 150 s time
|
||||
# limit outlives the test), so the pre-existing `ready`
|
||||
# re-broadcast, the three `countdown` frames and the first
|
||||
# snaps all land within a few seconds of the start — an 8 s
|
||||
# window covers them with twice the margin, and it is the
|
||||
# window the ignored input's (absent) `err` would have to
|
||||
# arrive in.
|
||||
await _send_input(p2, 1)
|
||||
deadline = asyncio.get_running_loop().time() + 8.0
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
try:
|
||||
pending = json.loads(
|
||||
await asyncio.wait_for(p2.recv(), 0.5)
|
||||
)
|
||||
except TimeoutError:
|
||||
continue # a quiet half second is fine; the window bounds
|
||||
assert pending["type"] in (
|
||||
"lobby",
|
||||
"countdown",
|
||||
"snap",
|
||||
"pong",
|
||||
), (
|
||||
f"only the pre-existing `lobby` re-broadcast, the "
|
||||
f"countdown frames, the keep-alive `pong`s and the "
|
||||
f"`snap` stream may still be in flight; got {pending}"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
assert room.stats()["stale_seqs"] == 0
|
||||
assert _conn_for_token(app, code, "tok-p2").last_seq == 0
|
||||
# And the silence held: no *refusal* frame arrived after the
|
||||
# drain. The live socket keeps receiving `snap` frames (the
|
||||
# dropped seat's cook is parked, not ejected — the stream
|
||||
# continues for any live connection in the player audience);
|
||||
# what must not come is the `err`.
|
||||
saw_refusal: dict[str, Any] | None = None
|
||||
deadline = asyncio.get_running_loop().time() + 3.0
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
try:
|
||||
frame = json.loads(await asyncio.wait_for(p2.recv(), 1.0))
|
||||
except TimeoutError:
|
||||
continue # the point: the refusal was *silent*
|
||||
if frame["type"] == "err":
|
||||
saw_refusal = frame
|
||||
break
|
||||
assert saw_refusal is None, (
|
||||
f"an unexpected err frame reached the ignored seat: {saw_refusal}"
|
||||
)
|
||||
keepalive.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await keepalive
|
||||
|
||||
|
||||
# ----------------------------------------------------------- the per-tick queue
|
||||
|
||||
|
||||
async def test_a_burst_never_exceeds_the_capacity_and_the_sim_keeps_stepping(
|
||||
ws_server: Any,
|
||||
) -> None:
|
||||
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
|
||||
_, room, host = await _playing_room(url, app, "tok-burst")
|
||||
# One seat: the queue's capacity is `4 x 1`. A 20-frame burst (all
|
||||
# within the token bucket's burst of 40) — with a single seat every
|
||||
# queued entry is the sender's own, so an overflow evicts the
|
||||
# sender's oldest (never a rejection) and *every* frame lands:
|
||||
# `inputs_received` reaches 20 no matter how the 20 Hz tick task
|
||||
# interleaves with the burst (the drop *mechanism* is proven
|
||||
# deterministic in tests/unit/test_ws_input_path.py).
|
||||
start_tick = room.sim_state.tick if room.sim_state is not None else 0
|
||||
for seq in range(1, 21):
|
||||
await _send_input(host, seq)
|
||||
for _ in range(6):
|
||||
assert len(room._queued_inputs) <= 4, (
|
||||
f"the per-tick queue grew past its capacity "
|
||||
f"(4 x 1 seat): {len(room._queued_inputs)} frames"
|
||||
)
|
||||
await asyncio.sleep(0.03)
|
||||
await _wait_for(
|
||||
lambda: room.stats()["inputs_received"] == 20,
|
||||
"every burst frame to be queued (one seat: evict, never reject)",
|
||||
)
|
||||
# The sim kept stepping through the whole burst.
|
||||
await _wait_for(
|
||||
lambda: room.sim_state is not None
|
||||
and room.sim_state.tick >= start_tick + 10,
|
||||
"the sim to keep stepping (10+ ticks)",
|
||||
wait_s=15.0,
|
||||
)
|
||||
Reference in New Issue
Block a user