Files
BT411/scratchpad/relay_stub_test.py
T
arcattackandClaude Fable 5 bf8c2ffaae D1 phase 1: relay TCP core in btconsole.py (--relay mode)
The console emulator gains a relay/dispatcher mode for internet play (D1):
pods dial OUT to this process (NAT-friendly) instead of the console dialing
into each pod.  Legacy dial-out mode is untouched (verified: an unmodified
2-node mesh session still forms end-to-end through it).

--relay <consolePort> <egg> [--bind ADDR] [--udp-drop PCT], single
selectors-based event loop:
- console listener (consolePort): streams the NUL-line egg to each pod on
  connect; GLOBAL launch timer -- RunMission x2 to ALL pods at +20s/+4s after
  the LAST pod has its egg (fixes the per-pod skew of the legacy tool).
- game TCP listener (consolePort+1): envelope router {route i32, length u32}.
  First frame must be HELLO ('BTR1' + hostID validated against the egg
  [pilots] roster); PEER_UP/PEER_DOWN exchange; route>=2 unicast (route
  rewritten to sender), -1 broadcast-except-sender (the client sends each
  broadcast ONCE -- kills the N-1x upload duplication).  Frame cap 1600
  (NETWORKMANAGER_BUFFER_SIZE); protocol violations drop the connection.
- game UDP socket (consolePort+1/udp): endpoint learned per-datagram
  (NAT-rebind tolerant), HELLO->HELLO-ACK, verbatim forward by route,
  TCP-wrap fallback when the target's UDP endpoint is unknown; --udp-drop
  test hook for the robustness phase.

Verified: scratchpad/relay_stub_test.py (self-contained; spawns the relay +
stub clients) -- 24/24 checks pass: egg delivery, registration, PEER events,
unicast route-rewrite, broadcast fan-out (sender excluded), UDP ack/forward/
fallback, and all reject paths (bad magic, dup id, out-of-roster, oversize).

Plan: ~/.claude/plans/partitioned-snuggling-piglet.md (D1 relay + UDP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 08:44:01 -05:00

202 lines
6.9 KiB
Python

#!/usr/bin/env python
"""relay_stub_test.py -- Phase-1 verification for btconsole.py --relay mode.
Spawns the relay with a 3-pilot test egg, then drives stub clients through the
whole protocol: console egg delivery, game HELLO/registration, PEER_UP/DOWN,
unicast + broadcast routing (route rewrite), UDP HELLO/ACK, UDP forward,
UDP->TCP fallback, and the reject paths (bad magic, dup id, out-of-roster,
oversize). Exits 0 with 'ALL PASS' or 1 with the first failure.
"""
import os
import socket
import struct
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.join(HERE, "..", "tools")
CONSOLE_PORT = 27500
GAME_PORT = CONSOLE_PORT + 1
ENV_TCP = "<iI"
ENV_UDP = "<iiI"
HELLO_MAGIC = 0x31525442
R_BCAST, R_HELLO, R_PEER_UP, R_PEER_DOWN, R_UDP_ACK = -1, -2, -3, -4, -5
fails = []
def check(cond, what):
tag = "ok" if cond else "FAIL"
print(f" [{tag}] {what}")
if not cond:
fails.append(what)
def recv_exact(sock, n, timeout=5.0):
sock.settimeout(timeout)
buf = b""
while len(buf) < n:
d = sock.recv(n - len(buf))
if not d:
raise ConnectionError("peer closed")
buf += d
return buf
def recv_env(sock, timeout=5.0):
route, length = struct.unpack(ENV_TCP, recv_exact(sock, 8, timeout))
payload = recv_exact(sock, length, timeout) if length else b""
return route, payload
def send_env(sock, route, payload):
sock.sendall(struct.pack(ENV_TCP, route, len(payload)) + payload)
def hello(sock, host_id):
send_env(sock, R_HELLO, struct.pack("<iI", HELLO_MAGIC, host_id))
def game_connect():
s = socket.create_connection(("127.0.0.1", GAME_PORT), timeout=5)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
return s
def main():
egg = os.path.join(HERE, "relay_test.egg")
with open(egg, "w", newline="\n") as f:
f.write("[pilots]\npilot=10.99.0.1:1502\npilot=10.99.0.2:1502\n"
"pilot=10.99.0.3:1502\n[10.99.0.1:1502]\nhostType=0\n"
"[10.99.0.2:1502]\nhostType=0\n[10.99.0.3:1502]\nhostType=0\n")
relay = subprocess.Popen(
[sys.executable, os.path.join(TOOLS, "btconsole.py"),
"--relay", str(CONSOLE_PORT), egg, "--bind", "127.0.0.1"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(1.0)
try:
run_tests()
finally:
relay.terminate()
try:
out, _ = relay.communicate(timeout=5)
print("---- relay log ----")
print(out)
except subprocess.TimeoutExpired:
relay.kill()
if fails:
print(f"\n{len(fails)} FAILURE(S):")
for f_ in fails:
print(f" - {f_}")
return 1
print("\nALL PASS")
return 0
def run_tests():
print("== console side: egg delivery ==")
con = socket.create_connection(("127.0.0.1", CONSOLE_PORT), timeout=5)
first = recv_exact(con, 1040)
clid, gid, fh, ts = struct.unpack_from("<iiii", first, 0)
mlen, mid, mfl, seq, total, this = struct.unpack_from("<IiIiii", first, 16)
check(clid == 0 and fh == 1 and mid == 3 and seq == 0,
f"egg chunk header (clid={clid} fh={fh} mid={mid} seq={seq})")
got = this
while got < total:
pkt = recv_exact(con, 1040)
got += struct.unpack_from("<IiIiii", pkt, 16)[5]
check(got == total, f"egg fully delivered ({got}/{total} bytes)")
print("== game side: HELLO/registration/PEER_UP ==")
a = game_connect()
hello(a, 2)
b = game_connect()
hello(b, 3)
route, payload = recv_env(a)
check(route == R_PEER_UP and struct.unpack("<i", payload)[0] == 3,
"A got PEER_UP(3) when B joined")
route, payload = recv_env(b)
check(route == R_PEER_UP and struct.unpack("<i", payload)[0] == 2,
"B (newcomer) got PEER_UP(2)")
print("== routing: unicast + route rewrite ==")
frame = b"\x00" * 16 + struct.pack("<IiI", 12, 99, 1) # minimal fake frame
send_env(a, 3, frame) # A -> unicast to 3
route, payload = recv_env(b)
check(route == 2 and payload == frame,
f"B received A's unicast with route rewritten to sender (route={route})")
print("== routing: broadcast fan-out ==")
c = game_connect()
hello(c, 4)
for s_, who in ((a, "A"), (b, "B")):
route, payload = recv_env(s_)
check(route == R_PEER_UP and struct.unpack("<i", payload)[0] == 4,
f"{who} got PEER_UP(4)")
for _ in range(2): # C gets 2 PEER_UPs
recv_env(c)
send_env(a, R_BCAST, frame) # A broadcasts ONCE
for s_, who in ((b, "B"), (c, "C")):
route, payload = recv_env(s_)
check(route == 2 and payload == frame, f"{who} received A's broadcast")
a.settimeout(0.5)
try:
a.recv(1)
check(False, "A must NOT receive its own broadcast")
except socket.timeout:
check(True, "A did not receive its own broadcast")
print("== UDP: HELLO/ACK + forward ==")
ua = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ua.bind(("127.0.0.1", 0))
ua.settimeout(5)
ua.sendto(struct.pack(ENV_UDP, R_HELLO, 2, 0), ("127.0.0.1", GAME_PORT))
ack, _ = ua.recvfrom(4096)
check(struct.unpack_from(ENV_UDP, ack)[0] == R_UDP_ACK, "UDP HELLO acked")
ub = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ub.bind(("127.0.0.1", 0))
ub.settimeout(5)
ub.sendto(struct.pack(ENV_UDP, R_HELLO, 3, 0), ("127.0.0.1", GAME_PORT))
ub.recvfrom(4096)
dg = struct.pack(ENV_UDP, 3, 2, 7) + frame # A -> unicast 3
ua.sendto(dg, ("127.0.0.1", GAME_PORT))
fwd, _ = ub.recvfrom(4096)
check(fwd == dg, "UDP unicast forwarded verbatim")
dg2 = struct.pack(ENV_UDP, R_BCAST, 2, 8) + frame # A -> broadcast
ua.sendto(dg2, ("127.0.0.1", GAME_PORT))
fwd2, _ = ub.recvfrom(4096)
check(fwd2 == dg2, "UDP broadcast reached B")
# C never sent UDP HELLO -> its copy must fall back to TCP.
route, payload = recv_env(c)
check(route == 2 and payload == frame,
"C (no UDP endpoint) got the broadcast via TCP fallback")
print("== reject paths ==")
bad = game_connect()
send_env(bad, R_HELLO, struct.pack("<iI", 0xDEAD, 5))
check(bad.recv(1) == b"", "bad HELLO magic -> connection dropped")
dup = game_connect()
hello(dup, 2)
check(dup.recv(1) == b"", "duplicate hostID -> dropped")
oor = game_connect()
hello(oor, 99)
check(oor.recv(1) == b"", "out-of-roster hostID -> dropped")
big = game_connect()
big.sendall(struct.pack(ENV_TCP, 3, 5000))
check(big.recv(1) == b"", "oversize frame -> dropped")
print("== PEER_DOWN ==")
c.close()
for s_, who in ((a, "A"), (b, "B")):
s_.settimeout(5)
route, payload = recv_env(s_)
check(route == R_PEER_DOWN and struct.unpack("<i", payload)[0] == 4,
f"{who} got PEER_DOWN(4)")
if __name__ == "__main__":
sys.exit(main())