#!/usr/bin/env python3 """Dependency-free maze32 WebSocket protocol and six-client verifier.""" from __future__ import annotations import argparse import base64 import hashlib import http.client import json import os import socket import struct import threading import time from typing import Any MALFORMED_STRESS_MESSAGES = 80 class WebSocket: def __init__(self, host: str, port: int = 80, timeout: float = 8.0): self.host = host self.sequence = 0 self.socket = socket.create_connection((host, port), timeout=timeout) self.socket.settimeout(timeout) key = base64.b64encode(os.urandom(16)).decode("ascii") request = ( f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n" f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n" "Sec-WebSocket-Version: 13\r\n\r\n" ) self.socket.sendall(request.encode("ascii")) response = self._receive_until(b"\r\n\r\n") if not response.startswith(b"HTTP/1.1 101"): self.socket.close() raise RuntimeError(response.decode("utf-8", "replace").strip()) expected = base64.b64encode( hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() ) if expected not in response: self.socket.close() raise RuntimeError("WebSocket accept key did not match") def _receive_until(self, marker: bytes) -> bytes: data = bytearray() while marker not in data: chunk = self.socket.recv(1024) if not chunk: raise ConnectionError("connection closed") data.extend(chunk) return bytes(data) def send(self, message_type: str, payload: dict[str, Any], request_id: str) -> None: self.sequence += 1 message = json.dumps( {"v": 1, "type": message_type, "seq": self.sequence, "requestId": request_id, "payload": payload}, ensure_ascii=False, separators=(",", ":"), ).encode("utf-8") self.send_raw(message) def send_raw(self, message: bytes) -> None: mask = os.urandom(4) length = len(message) if length < 126: header = bytes((0x81, 0x80 | length)) else: header = bytes((0x81, 0x80 | 126)) + struct.pack("!H", length) masked = bytes(value ^ mask[index % 4] for index, value in enumerate(message)) self.socket.sendall(header + mask + masked) def receive(self) -> dict[str, Any]: first, second = self._read_exact(2) opcode = first & 0x0F length = second & 0x7F if length == 126: length = struct.unpack("!H", self._read_exact(2))[0] elif length == 127: length = struct.unpack("!Q", self._read_exact(8))[0] if second & 0x80: mask = self._read_exact(4) else: mask = None payload = self._read_exact(length) if mask: payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) if opcode == 0x8: raise ConnectionError("WebSocket closed") if opcode != 0x1: return self.receive() return json.loads(payload.decode("utf-8")) def receive_type(self, message_type: str, attempts: int = 20) -> dict[str, Any]: for _ in range(attempts): message = self.receive() if message.get("type") == message_type: return message raise RuntimeError(f"did not receive {message_type}") def _read_exact(self, length: int) -> bytes: data = bytearray() while len(data) < length: chunk = self.socket.recv(length - len(data)) if not chunk: raise ConnectionError("connection closed") data.extend(chunk) return bytes(data) def close(self) -> None: try: self.socket.sendall(b"\x88\x80" + b"\0\0\0\0") except OSError: pass self.socket.close() def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any]: payload = {"role": role} if name is not None: payload["name"] = name client.send("join", payload, f"join-{role}") return client.receive_type("session")["payload"] def receive_view(client: WebSocket, maximum_messages: int = 800) -> dict[str, Any]: parts: dict[int, dict[str, Any]] = {} sequence: int | None = None expected = 0 for _ in range(maximum_messages): message = client.receive() if message.get("type") != "view": continue payload = message["payload"] if sequence != message["seq"]: sequence = message["seq"] expected = payload["chunks"] parts = {} parts[payload["chunk"]] = payload if len(parts) == expected: first = dict(parts[0]) first["cells"] = [cell for index in range(expected) for cell in parts[index]["cells"]] first["objects"] = [ item for index in range(expected) for item in parts[index]["objects"] ] return first raise RuntimeError("did not receive a complete view") def open_direction(view: dict[str, Any]) -> str: passages = next(cell[1] for cell in view["cells"] if cell[0] == view["self"]) for bit, direction in ((1, "north"), (2, "east"), (4, "south"), (8, "west")): if passages & bit: return direction raise AssertionError("player cell has no open passage") def diagnostics(host: str) -> dict[str, Any]: connection = http.client.HTTPConnection(host, 80, timeout=8.0) try: connection.request("GET", "/diagnostics") response = connection.getresponse() payload = response.read() if response.status != 200: raise RuntimeError(f"diagnostics returned HTTP {response.status}") return json.loads(payload.decode("utf-8")) finally: connection.close() def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None: clients: list[WebSocket] = [] try: baseline = diagnostics(host) player_1 = WebSocket(host, timeout=30.0) player_2 = WebSocket(host, timeout=30.0) clients.extend((player_1, player_2)) join(player_1, "player", "Нагрузочный Один") session_2 = join(player_2, "player", "Нагрузочный Два") observers = [WebSocket(host, timeout=30.0) for _ in range(4)] clients.extend(observers) for observer in observers: join(observer, "observer") observer_views: list[dict[str, Any] | None] = [None] * len(observers) failures: list[BaseException] = [] def read_observer(index: int) -> None: try: observers[index].receive_type("active", attempts=50) observer_views[index] = receive_view(observers[index]) except BaseException as error: # Preserve worker failure for the main thread. failures.append(error) readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(len(observers))] for reader in readers: reader.start() player_1.send("start", {}, "m010-start") player_1.receive_type("active", attempts=50) player_2.receive_type("active", attempts=50) player_1_view = receive_view(player_1) receive_view(player_2) for reader in readers: reader.join(timeout=40.0) assert not failures and all(not reader.is_alive() for reader in readers) assert all(view is not None and len(view["cells"]) == 8192 for view in observer_views) six_client_sample = diagnostics(host) limits = six_client_sample["limits"] assert six_client_sample["activeConnections"] == 6 assert six_client_sample["connectionHighWater"] == 6 assert six_client_sample["queueHighWater"] <= limits["queueEntries"] print("M010 six-client load: PASS (2 players + 4 complete observers)") observers[0].send("restart", {}, "m010-observer-restart") observer_error = observers[0].receive_type("error", attempts=30) assert observer_error["payload"]["code"] == "read_only" heartbeat_latencies_ms: list[float] = [] for index, client in enumerate(clients): started = time.monotonic() client.send("heartbeat", {}, f"m010-heartbeat-{index}") client.receive_type("ack", attempts=30) heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0) assert max(heartbeat_latencies_ms) <= limits["playerLatencyBudgetMs"] print(f"M010 six-client responsiveness: PASS (max {max(heartbeat_latencies_ms):.1f} ms)") slow_observer = observers[0] slow_observer.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024) drain_stop = threading.Event() drain_failures: list[BaseException] = [] def drain_fast_observer(observer: WebSocket) -> None: observer.socket.settimeout(1.0) while not drain_stop.is_set(): try: observer.receive() except socket.timeout: continue except BaseException as error: # Preserve an unexpected disconnect. drain_failures.append(error) return drainers = [threading.Thread(target=drain_fast_observer, args=(observer,)) for observer in observers[1:]] for drainer in drainers: drainer.start() opposite = {"north": "south", "east": "west", "south": "north", "west": "east"} direction = open_direction(player_1_view) command_latencies_ms: list[float] = [] for move_index in range(stress_moves): time.sleep(0.16) started = time.monotonic() player_1.send("move", {"direction": direction}, f"m010-move-{move_index}") player_1.receive_type("ack", attempts=50) command_latencies_ms.append((time.monotonic() - started) * 1000.0) player_1_view = receive_view(player_1) receive_view(player_2) direction = opposite[direction] drain_stop.set() for drainer in drainers: drainer.join(timeout=3.0) assert not drain_failures and all(not drainer.is_alive() for drainer in drainers) for observer in observers[1:]: observer.socket.settimeout(30.0) abusive_observer = observers[1] def malformed_flood() -> None: try: for _ in range(MALFORMED_STRESS_MESSAGES): abusive_observer.send_raw(b"{") except OSError: pass flood = threading.Thread(target=malformed_flood) flood.start() stress_heartbeat_latencies_ms: list[float] = [] for index in range(10): started = time.monotonic() player_1.send("heartbeat", {}, f"m010-stress-heartbeat-{index}") player_1.receive_type("ack", attempts=50) stress_heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0) time.sleep(0.02) flood.join(timeout=10.0) assert not flood.is_alive() stressed = diagnostics(host) maximum_latency_ms = max(command_latencies_ms + stress_heartbeat_latencies_ms) assert maximum_latency_ms <= limits["playerLatencyBudgetMs"] assert stressed["malformedMessages"] > baseline["malformedMessages"] assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"] assert stressed["queueHighWater"] <= limits["queueEntries"] assert stressed["minimumInternalFreeBytes"] > 32768 assert stressed["minimumInternalLargestBlockBytes"] > 16384 print( "M010 slow/malformed stress: PASS " f"(max player latency {maximum_latency_ms:.1f} ms, " f"dropped {stressed['messagesDropped'] - baseline['messagesDropped']})" ) for observer in observers: observer.close() if observer in clients: clients.remove(observer) observers.clear() time.sleep(1.0) reset_heap_samples: list[int] = [] for match_index in range(repeat_matches): token_2 = session_2["token"] player_2.close() clients.remove(player_2) player_1.receive_type("pause", attempts=100) player_1.socket.settimeout(70.0) result = player_1.receive_type("match_result", attempts=200)["payload"] assert result["result"] == "draw" player_2 = WebSocket(host, timeout=30.0) clients.append(player_2) player_2.send("restore", {"token": token_2}, f"m010-restore-{match_index}") session_2 = player_2.receive_type("session", attempts=50)["payload"] player_1.send("restart", {}, f"m010-restart-{match_index}") player_1.receive_type("ack", attempts=100) time.sleep(0.2) sample = diagnostics(host) reset_heap_samples.append(sample["internalFreeBytes"]) if match_index + 1 < repeat_matches: player_1.send("start", {}, f"m010-start-{match_index + 1}") player_1.receive_type("active", attempts=100) player_2.receive_type("active", attempts=100) receive_view(player_1) receive_view(player_2) final_sample = diagnostics(host) assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches assert final_sample["matchesReset"] - baseline["matchesReset"] >= repeat_matches if len(reset_heap_samples) > 1: assert max(reset_heap_samples) - min(reset_heap_samples) <= 32768 print( f"M010 repeated-match stability: PASS ({repeat_matches} matches, " f"heap range {max(reset_heap_samples) - min(reset_heap_samples)} bytes)" ) print(json.dumps(final_sample, ensure_ascii=False, sort_keys=True)) finally: for client in clients: client.close() def verify_m009(host: str) -> None: clients: list[WebSocket] = [] try: player_1 = WebSocket(host) player_2 = WebSocket(host) clients.extend((player_1, player_2)) join(player_1, "player", "Наблюдаемый Один") session_2 = join(player_2, "player", "Наблюдаемый Два") observers = [WebSocket(host) for _ in range(4)] clients.extend(observers) for observer in observers: join(observer, "observer") observer.socket.settimeout(30.0) observer_views: list[dict[str, Any] | None] = [None] * 4 failures: list[BaseException] = [] def read_observer(index: int) -> None: try: observers[index].receive_type("active", attempts=40) observer_views[index] = receive_view(observers[index]) except BaseException as error: # Preserve worker failure for the main thread. failures.append(error) readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(4)] for reader in readers: reader.start() player_1.send("start", {}, "m009-start") player_1.receive_type("countdown") player_2.receive_type("countdown") player_1.receive_type("active") player_2.receive_type("active") player_1_view = receive_view(player_1) player_2_view = receive_view(player_2) for reader in readers: reader.join(timeout=35.0) assert not failures and all(not reader.is_alive() for reader in readers) assert all(view is not None and len(view["cells"]) == 8192 for view in observer_views) print("four-observer full-map delivery: PASS (8192 cells each)") observers[0].send("move", {"direction": "north"}, "observer-move") error = observers[0].receive_type("error") assert error["payload"]["code"] == "read_only" print("observer mutation authorization: PASS (read_only)") direction = open_direction(player_1_view) player_1.send("move", {"direction": direction}, "before-observer-close") player_1.receive_type("ack") player_1_view = receive_view(player_1) observers[-1].close() clients.remove(observers[-1]) observers.pop() time.sleep(0.2) player_1.send("move", {"direction": open_direction(player_1_view)}, "after-observer-close") player_1.receive_type("ack") print("observer disconnect continuity: PASS") preserved_cell = player_2_view["self"] preserved_inventory = player_2_view.get("inventory") token_2 = session_2["token"] player_2.close() clients.remove(player_2) pause = player_1.receive_type("pause", attempts=30)["payload"] assert pause["slot"] == 2 player_1.send("move", {"direction": "north"}, "paused-move") error = player_1.receive_type("error", attempts=30) assert error["payload"]["code"] == "premature" restored = WebSocket(host) clients.append(restored) restored.send("restore", {"token": token_2}, "restore-player-2") restored_session = restored.receive_type("session")["payload"] assert restored_session["slot"] == 2 and restored_session["token"] == token_2 player_1.receive_type("resume", attempts=30) restored_view = receive_view(restored) assert restored_view["self"] == preserved_cell assert restored_view.get("inventory") == preserved_inventory print("player pause and reconnect: PASS (same position and inventory)") restored.close() clients.remove(restored) player_1.socket.settimeout(70.0) started = time.monotonic() result = player_1.receive_type("match_result", attempts=60)["payload"] elapsed = time.monotonic() - started assert result["result"] == "draw" and elapsed >= 59.0 print(f"reconnect timeout draw: PASS ({elapsed:.1f} seconds)") finally: for client in clients: client.close() def verify(host: str) -> None: clients: list[WebSocket] = [] try: player_1 = WebSocket(host) clients.append(player_1) player_1.send_raw(b"{") error = player_1.receive_type("error") assert error["payload"]["code"] == "malformed" session_1 = join(player_1, "player", "Тестовый Пилот") player_1.send_raw( b'{"v":1,"type":"start","seq":1,"requestId":"stale","payload":{}}' ) error = player_1.receive_type("error") assert error["payload"]["code"] == "stale_sequence" print("malformed and stale message rejection: PASS") duplicate = WebSocket(host) clients.append(duplicate) duplicate.send("join", {"role": "player", "name": "Тестовый Пилот"}, "duplicate") error = duplicate.receive_type("error") assert error["payload"]["code"] == "duplicate_name" duplicate.send("join", {"role": "player", "name": "Запасной Пилот"}, "player-2") session_2 = duplicate.receive_type("session")["payload"] assert session_1["slot"] == 1 and session_2["slot"] == 2 observers = [] for _ in range(4): observer = WebSocket(host) clients.append(observer) join(observer, "observer") observers.append(observer) lobby = observers[-1].receive_type("lobby")["payload"] assert lobby["ready"] and lobby["observers"] == 4 print("six-client capacity: PASS (2 players + 4 observers)") print("duplicate player name: PASS") try: excess = WebSocket(host, timeout=1.0) except (OSError, RuntimeError, ConnectionError): print("seventh connection rejection: PASS") else: try: excess.send("join", {"role": "observer"}, "excess") excess.receive() except (OSError, ConnectionError): print("seventh connection rejection: PASS") else: raise AssertionError("seventh WebSocket unexpectedly remained connected") finally: excess.close() time.sleep(0.5) duplicate.send("start", {}, "not-owner") error = duplicate.receive_type("error") assert error["payload"]["code"] == "not_owner" print("Player 2 start authorization: PASS") token_2 = session_2["token"] duplicate.close() clients.remove(duplicate) time.sleep(0.15) restored = WebSocket(host) clients.append(restored) restored.send("restore", {"token": token_2}, "restore") restored_session = restored.receive_type("session")["payload"] assert restored_session["slot"] == 2 and restored_session["token"] == token_2 print("session refresh restoration: PASS (Player 2 retained slot 2)") player_1.send("start", {}, "owner-start") countdown_1 = player_1.receive_type("countdown")["payload"] countdown_2 = restored.receive_type("countdown")["payload"] assert countdown_1 == countdown_2 assert countdown_1["steps"] == [3, 2, 1] active_1 = player_1.receive_type("active")["payload"] active_2 = restored.receive_type("active")["payload"] assert active_1["activatedAtMs"] == countdown_1["activationAtMs"] assert active_1 == active_2 print( "authoritative countdown: PASS " f"(activationAtMs={countdown_1['activationAtMs']})" ) observer = observers.pop() observer.close() clients.remove(observer) time.sleep(0.5) oversized = WebSocket(host) oversized.send_raw(b"x" * 513) try: error = oversized.receive_type("error") assert error["payload"]["code"] == "too_large" except (OSError, ConnectionError): pass print("oversized message rejection: PASS") oversized.close() finally: for client in clients: client.close() def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("host", help="maze32 IP address or hostname") parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario") parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario") parser.add_argument("--verify-m010", action="store_true", help="run the Milestone 010 load and stability scenario") parser.add_argument("--stress-moves", type=int, default=80, help="accepted moves sent while one observer is not reading") parser.add_argument("--repeat-matches", type=int, default=2, help="real 60-second timeout matches completed and reset") arguments = parser.parse_args() if arguments.stress_moves < 1 or arguments.repeat_matches < 1: parser.error("--stress-moves and --repeat-matches must be positive") if arguments.verify_m010: verify_m010(arguments.host, arguments.stress_moves, arguments.repeat_matches) elif arguments.verify_m009: verify_m009(arguments.host) elif arguments.verify: verify(arguments.host) else: client = WebSocket(arguments.host) client.send("suggest_name", {}, "manual") print(json.dumps(client.receive(), ensure_ascii=False, indent=2)) client.close() if __name__ == "__main__": main()