**Final report — phase 07 final verification pass** - **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found. - **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase). - **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔. - **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry. - **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`. - **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate. - **Next pending phase:** `08_client_prediction_and_touch`.
509 lines
21 KiB
Python
509 lines
21 KiB
Python
"""Integration: the WebSocket endpoint, handshake, and connection guardrails
|
|
(phase 06 task 02; US-01/US-02) — every path driven by **real sockets**
|
|
against a live `uvicorn.Server` on an ephemeral `127.0.0.1` port (the
|
|
`ws_server` factory in `tests/integration/conftest.py`), with the app's
|
|
own supervisor reached for state assertions (the seat's `connected` flag,
|
|
the `mark_disconnected` call count, the socket census).
|
|
|
|
* **the happy handshake** — `POST /api/rooms` (a known token) →
|
|
`/ws/{code}` → `hello` → `welcome` (the joiner's seat index / host flag
|
|
correct) + the current `lobby` (the host's seat, `can_start`); a
|
|
`ping` inside the handshake window gets a `pong` before the `hello`,
|
|
* **the refusal closes** — unknown code → `err room_gone` + close `4401`;
|
|
a full room (a *new* token) → `err room_full` + close `4402`; no
|
|
`hello` within the (shortened) handshake window → close `4400`; a `p`
|
|
outside the supported range → `err protocol_mismatch` + close `4400` on
|
|
the first bad frame (before any seat is taken);
|
|
* **the origin check** — an explicit `allowed_origins` list that the
|
|
request's `Origin` misses is refused at the HTTP level (the client
|
|
sees a 403, never a close frame); the same-host rule (the empty
|
|
setting) admits the matching `Origin`;
|
|
* **one socket per token** — a second `hello` for a token evicts the
|
|
first (`err evicted` + close `4403`), the first stops receiving, and
|
|
the *evicted* socket's teardown does not mark the seat disconnected
|
|
(the new socket owns it — the crown stays put);
|
|
* **the size/rate guardrails** — an oversized frame is `too_big` and
|
|
three consecutive refusals close `4404`; a message flood is
|
|
`rate_limited` and three consecutive refusals close `4404`; five
|
|
shape violations in the handshake window close `4404`; a
|
|
`wrong_state` / `unknown_type` frame is refused with the named code
|
|
and the connection stays open;
|
|
* **the disconnect** — an abrupt transport close (the client's socket
|
|
vanishes without a close handshake) reaches the room as exactly one
|
|
`mark_disconnected` (the seat is kept for the rejoin path), releases
|
|
the registry slot, and leaves no error in the server log.
|
|
|
|
Note on the "abrupt close" mechanics: the installed `websockets` (17.2)
|
|
client has no `close(transport=True)` keyword, so the test drives the
|
|
abrupt path through `ws.transport.close()` — the same primitive. A bare
|
|
FIN is enough: the server's transport sees the EOF, uvicorn closes the
|
|
connection, and the endpoint's `finally` reports the disconnect.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
import websockets
|
|
from websockets.exceptions import ConnectionClosed, InvalidHandshake
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
HELLO_TIMEOUT_S = 0.5 # the shortened window, set via settings (task 02)
|
|
|
|
|
|
# --------------------------------------------------------------------- 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,
|
|
*,
|
|
p: int = 1,
|
|
name: str | None = None,
|
|
origin: str | None = None,
|
|
) -> Any:
|
|
"""A connected client with a `hello` already sent (the handshake is
|
|
the point under test, so the hello goes out immediately)."""
|
|
kwargs: dict[str, Any] = {"open_timeout": 10.0}
|
|
if origin is not None:
|
|
kwargs["origin"] = origin
|
|
ws = await websockets.connect(_uri(url, code), **kwargs)
|
|
if token is not None or name is not None or p != 1:
|
|
hello: dict[str, Any] = {"type": "hello", "p": p}
|
|
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) -> dict[str, Any]:
|
|
return json.loads(await ws.recv())
|
|
|
|
|
|
async def _close_code(ws: Any) -> int:
|
|
with pytest.raises(ConnectionClosed) as excinfo:
|
|
await ws.recv()
|
|
return int(excinfo.value.code)
|
|
|
|
|
|
async def _drain_until_close(
|
|
ws: Any, max_frames: int, wait_s: float = 5.0
|
|
) -> tuple[list[dict[str, Any]], int | None]:
|
|
"""Read frames until the connection closes.
|
|
|
|
A WebSocket client may hold *buffered* frames — the `too_big` /
|
|
`rate_limited` errors the server queued before the abuse close
|
|
arrive ahead of the close frame — so the close must be expected
|
|
after draining them, not on the very next read. Returns the frames
|
|
(in arrival order) and the close code (`None` if the window ran out
|
|
first, which the caller asserts against).
|
|
"""
|
|
frames: list[dict[str, Any]] = []
|
|
close_code: int | None = None
|
|
for _ in range(max_frames):
|
|
try:
|
|
raw = await asyncio.wait_for(ws.recv(), wait_s)
|
|
except ConnectionClosed as exc:
|
|
close_code = int(exc.code)
|
|
break
|
|
frames.append(json.loads(raw))
|
|
return frames, close_code
|
|
|
|
|
|
async def _poll(predicate: Callable[[], bool], what: str, wait_s: float = 5.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) -> tuple[str, str | None]:
|
|
"""`POST /api/rooms` over the live server; returns (code, you.token)."""
|
|
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
|
|
data = response.json()
|
|
return data["room_code"], (data["you"] or {}).get("token")
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------- the happy path
|
|
|
|
|
|
async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, you_echo = _create_room(url, "tok-creator")
|
|
assert you_echo == "tok-creator"
|
|
async with await _connect(url, code, "tok-joiner", name="Joiner") as ws:
|
|
welcome = await _next_json(ws)
|
|
assert welcome["type"] == "welcome"
|
|
assert welcome["p"] == 1
|
|
assert welcome["room"] == code
|
|
assert welcome["state"] == "lobby"
|
|
assert welcome["you"] == {
|
|
"token": "tok-joiner",
|
|
"seat": 1,
|
|
"host": False,
|
|
}
|
|
seats = welcome["seats"]
|
|
assert len(seats) == 2
|
|
assert seats[0]["index"] == 0
|
|
assert seats[0]["host"] is True
|
|
assert seats[1]["index"] == 1
|
|
assert seats[1]["name"] == "Joiner"
|
|
assert seats[1]["host"] is False
|
|
lobby = await _next_json(ws)
|
|
assert lobby["type"] == "lobby"
|
|
assert lobby["code"] == code
|
|
assert lobby["state"] == "lobby"
|
|
assert lobby["host_seat"] == 0
|
|
# The fixture campaign is level-free, so `can_start` is False
|
|
# (the start preconditions — level selected, ready players — are
|
|
# covered by the phase-05 lifecycle suite over real levels).
|
|
assert lobby["can_start"] is False
|
|
# The ready-up: a toggle that re-broadcasts the lobby.
|
|
await ws.send(json.dumps({"type": "ready"}))
|
|
lobby2 = await _next_json(ws)
|
|
assert lobby2["can_start"] is False # still: no level selected
|
|
assert lobby2["seats"][1]["ready"] is True
|
|
assert len(lobby2["seats"]) == 2
|
|
|
|
|
|
async def test_a_new_seat_rebroadcasts_the_lobby_to_existing_members(
|
|
ws_server: Any,
|
|
) -> None:
|
|
"""PLAN §7: the lobby is re-broadcast on lobby-state changes, not
|
|
only to the joiner — a member who is already in the room must see
|
|
the new seat appear (and a ghost rejoin revives it), so the lobby
|
|
screen in an open tab updates without a refresh."""
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url, "tok-host")
|
|
async with await _connect(url, code, "tok-host", name="Host") as host:
|
|
welcome = await _next_json(host)
|
|
assert welcome["type"] == "welcome"
|
|
await _next_json(host) # the initial lobby (one seat)
|
|
# The joiner arrives on their own socket: welcome + lobby for
|
|
# them, and a *re-broadcast* for the host.
|
|
async with await _connect(
|
|
url, code, "tok-joiner", name="Joiner"
|
|
) as joiner:
|
|
j_welcome = await _next_json(joiner)
|
|
assert j_welcome["type"] == "welcome"
|
|
assert j_welcome["you"]["seat"] == 1
|
|
j_lobby = await _next_json(joiner)
|
|
assert j_lobby["type"] == "lobby"
|
|
host_lobby = await _next_json(host)
|
|
assert host_lobby["type"] == "lobby"
|
|
assert host_lobby["code"] == code
|
|
assert len(host_lobby["seats"]) == 2
|
|
assert host_lobby["seats"][1]["name"] == "Joiner"
|
|
assert host_lobby["seats"][1]["ready"] is False
|
|
|
|
|
|
async def test_ping_before_hello_gets_pong(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
|
await ws.send(json.dumps({"type": "ping", "t": 7}))
|
|
pong = await _next_json(ws)
|
|
assert pong["type"] == "pong"
|
|
assert pong["t"] == 7
|
|
await ws.send(json.dumps({"type": "hello", "p": 1}))
|
|
welcome = await _next_json(ws)
|
|
assert welcome["type"] == "welcome"
|
|
await ws.close()
|
|
|
|
|
|
# ---------------------------------------------------------------- refusal closes
|
|
|
|
|
|
async def test_unknown_code_closes_4401(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
ws = await websockets.connect(_uri(url, "ZZZZZ"), open_timeout=10.0)
|
|
err = await _next_json(ws)
|
|
assert err["type"] == "err"
|
|
assert err["code"] == "room_gone"
|
|
assert await _close_code(ws) == 4401
|
|
|
|
|
|
async def test_full_room_new_token_closes_4402(ws_server: Any) -> None:
|
|
with ws_server(max_players=2) as (url, _app):
|
|
code, _ = _create_room(url, "tok-a")
|
|
_join(url, code, "tok-b") # the second (and last) seat
|
|
async with await _connect(url, code, "tok-c") as ws:
|
|
err = await _next_json(ws)
|
|
assert err["type"] == "err"
|
|
assert err["code"] == "room_full"
|
|
assert await _close_code(ws) == 4402
|
|
|
|
|
|
async def test_no_hello_within_timeout_closes_4400(ws_server: Any) -> None:
|
|
with ws_server(hello_timeout_s=HELLO_TIMEOUT_S) as (url, _app):
|
|
code, _ = _create_room(url)
|
|
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
|
# No hello at all: the shortened window closes the socket.
|
|
assert await _close_code(ws) == 4400
|
|
|
|
|
|
async def test_wrong_protocol_version_closes_4400(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
async with await _connect(url, code, "tok-x", p=99) as ws:
|
|
err = await _next_json(ws)
|
|
assert err["type"] == "err"
|
|
assert err["code"] == "protocol_mismatch"
|
|
assert await _close_code(ws) == 4400
|
|
# No seat was taken on the refused version.
|
|
room = _app.state.supervisor.get(code)
|
|
assert len(room.seats) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------- the origin check
|
|
|
|
|
|
async def test_disallowed_origin_refused_at_http_level(ws_server: Any) -> None:
|
|
with ws_server(allowed_origins=("https://elsewhere.example",)) as (url, _app):
|
|
code, _ = _create_room(url)
|
|
# The client's Origin (http://127.0.0.1:<port>) is not in the
|
|
# explicit list: the upgrade is refused before the accept — the
|
|
# client sees a 403 handshake failure, never a close frame.
|
|
with pytest.raises(InvalidHandshake):
|
|
await websockets.connect(_uri(url, code), open_timeout=10.0)
|
|
|
|
|
|
async def test_same_host_origin_allowed(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
origin = "http://" + url.removeprefix("http://")
|
|
async with await _connect(url, code, "tok-orig", origin=origin) as ws:
|
|
welcome = await _next_json(ws)
|
|
assert welcome["type"] == "welcome"
|
|
|
|
|
|
# ------------------------------------------------- one socket per token (eviction)
|
|
|
|
|
|
async def test_second_socket_same_token_evicts_first(ws_server: Any) -> None:
|
|
with ws_server() as (url, app):
|
|
code, _ = _create_room(url)
|
|
first = await _connect(url, code, "tok-shared")
|
|
welcome = await _next_json(first)
|
|
assert welcome["type"] == "welcome"
|
|
await _next_json(first) # the lobby
|
|
|
|
second = await _connect(url, code, "tok-shared")
|
|
welcome2 = await _next_json(second)
|
|
assert welcome2["type"] == "welcome"
|
|
assert welcome2["you"]["seat"] == 1 # the creator holds seat 0
|
|
|
|
# The evicted socket: the named refusal, then the close.
|
|
err = await _next_json(first)
|
|
assert err["type"] == "err"
|
|
assert err["code"] == "evicted"
|
|
assert await _close_code(first) == 4403
|
|
|
|
# The evicted socket's teardown must not mark the seat
|
|
# disconnected (the new socket owns it now), and the crown stays
|
|
# with the creator (an eviction is not a host leave — no
|
|
# `transfer_host`). Checked before `second` closes: closing it
|
|
# is a genuine disconnect for the token.
|
|
room = app.state.supervisor.get(code)
|
|
seat = room.seat_for_token("tok-shared")
|
|
assert seat is not None
|
|
assert seat.connected is True
|
|
assert room.seats[0].host is True
|
|
await second.close()
|
|
|
|
|
|
async def test_evicted_socket_stops_receiving(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
first = await _connect(url, code, "tok-shared")
|
|
await _next_json(first) # welcome
|
|
await _next_json(first) # lobby
|
|
second = await _connect(url, code, "tok-shared")
|
|
await _next_json(second) # welcome
|
|
await _next_json(second) # lobby
|
|
await _next_json(first) # the evicted err
|
|
await _close_code(first) # 4403 (asserted in the other test; the
|
|
# point here is what happens *after* the close)
|
|
# The evicted socket is out of the broadcast: a ready-up from the
|
|
# second socket re-broadcasts the lobby to the second only.
|
|
await second.send(json.dumps({"type": "ready"}))
|
|
lobby = await _next_json(second)
|
|
assert lobby["type"] == "lobby"
|
|
assert any(seat["ready"] for seat in lobby["seats"])
|
|
await second.close()
|
|
|
|
|
|
# ----------------------------------------------------------- size / rate guardrails
|
|
|
|
|
|
async def test_oversized_frame_refused_then_4404(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
async with await _connect(url, code, "tok-big") as ws:
|
|
await _next_json(ws) # welcome
|
|
await _next_json(ws) # lobby
|
|
oversized = json.dumps(
|
|
{"type": "ping", "t": 1, "pad": "x" * 5000} # > max_message_bytes
|
|
)
|
|
for _ in range(3): # three consecutive refusals -> the abuse close
|
|
await ws.send(oversized)
|
|
frames, close_code = await _drain_until_close(ws, 8)
|
|
assert frames, "the oversized frames must be refused"
|
|
assert all(
|
|
frame["type"] == "err" and frame["code"] == "too_big"
|
|
for frame in frames
|
|
), f"unexpected frames before the close: {frames}"
|
|
assert close_code == 4404
|
|
|
|
|
|
async def test_flood_rate_limited_then_4404(ws_server: Any) -> None:
|
|
with ws_server(max_inputs_per_s=5) as (url, _app):
|
|
code, _ = _create_room(url)
|
|
async with await _connect(url, code, "tok-flood") as ws:
|
|
await _next_json(ws) # welcome
|
|
await _next_json(ws) # lobby
|
|
for index in range(8): # burst 5: the 6th..8th exhaust the bucket
|
|
await ws.send(json.dumps({"type": "ping", "t": index}))
|
|
frames, close_code = await _drain_until_close(ws, 16)
|
|
assert any(
|
|
frame.get("type") == "err" and frame["code"] == "rate_limited"
|
|
for frame in frames
|
|
), f"the flood never tripped the token bucket: {frames}"
|
|
assert close_code == 4404
|
|
|
|
|
|
async def test_shape_violations_in_handshake_window_close_4404(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
|
for _ in range(5): # five shape violations inside the window
|
|
await ws.send("{not json")
|
|
assert await _close_code(ws) == 4404
|
|
|
|
|
|
async def test_wrong_state_refused_and_connection_stays(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
async with await _connect(url, code, "tok-state") as ws:
|
|
await _next_json(ws) # welcome
|
|
await _next_json(ws) # lobby
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"type": "input", # legal in PLAYING, not in LOBBY
|
|
"seq": 1,
|
|
"move": {"x": 1.0, "y": 0.0},
|
|
"act": False,
|
|
"face": None,
|
|
}
|
|
)
|
|
)
|
|
err = await _next_json(ws)
|
|
assert err["type"] == "err"
|
|
assert err["code"] == "wrong_state"
|
|
# One shape violation: the connection survives.
|
|
await ws.send(json.dumps({"type": "ping", "t": 3}))
|
|
pong = await _next_json(ws)
|
|
assert pong["type"] == "pong"
|
|
|
|
|
|
async def test_unknown_type_refused_and_connection_stays(ws_server: Any) -> None:
|
|
with ws_server() as (url, _app):
|
|
code, _ = _create_room(url)
|
|
async with await _connect(url, code, "tok-bogus") as ws:
|
|
await _next_json(ws) # welcome
|
|
await _next_json(ws) # lobby
|
|
await ws.send(json.dumps({"type": "teleport", "x": 1}))
|
|
err = await _next_json(ws)
|
|
assert err["type"] == "err"
|
|
assert err["code"] == "unknown_type"
|
|
await ws.send(json.dumps({"type": "ping", "t": 4}))
|
|
pong = await _next_json(ws)
|
|
assert pong["type"] == "pong"
|
|
|
|
|
|
# ------------------------------------------------------------------- the disconnect
|
|
|
|
|
|
async def test_abrupt_close_notifies_room_once_and_releases(
|
|
ws_server: Any,
|
|
) -> None:
|
|
with ws_server() as (url, app):
|
|
code, _ = _create_room(url, "tok-abrupt")
|
|
supervisor = app.state.supervisor
|
|
calls: list[tuple[str, str]] = []
|
|
original = supervisor.mark_disconnected
|
|
|
|
def counting(raw_code: str, token: str) -> None:
|
|
calls.append((raw_code, token))
|
|
original(raw_code, token)
|
|
|
|
supervisor.mark_disconnected = counting
|
|
records: list[logging.LogRecord] = []
|
|
|
|
class _Collector(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
records.append(record)
|
|
|
|
collector = _Collector(level=logging.ERROR)
|
|
logging.getLogger().addHandler(collector)
|
|
try:
|
|
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
|
await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-abrupt"}))
|
|
assert json.loads(await ws.recv())["type"] == "welcome"
|
|
room = supervisor.get(code)
|
|
assert room.seat_for_token("tok-abrupt").connected is True
|
|
ws.transport.close() # abrupt: the transport vanishes, no close handshake
|
|
# Both halves of the teardown (the `mark_disconnected` and the
|
|
# registry release) must be visible before the assertions — they
|
|
# run in the server's loop, this one in the test's.
|
|
await _poll(
|
|
lambda: room.seat_for_token("tok-abrupt").connected is False
|
|
and app.state.ws_registry.socket_count == 0,
|
|
"the seat to be marked disconnected and the socket released",
|
|
)
|
|
finally:
|
|
supervisor.mark_disconnected = original
|
|
logging.getLogger().removeHandler(collector)
|
|
|
|
# Exactly one on_disconnect — and for the right token.
|
|
assert calls == [(code, "tok-abrupt")]
|
|
# No exception in the server log: the drop is a clean no-op.
|
|
errors = [record for record in records if record.levelno >= logging.ERROR]
|
|
exceptions = [
|
|
record for record in errors if record.exc_info is not None
|
|
]
|
|
assert not exceptions, (
|
|
"the server log carries an exception for the abrupt close:\n"
|
|
+ "\n".join(repr(record.exc_info) for record in exceptions)
|
|
)
|