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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e2c21c4db2
commit
bf8c2ffaae
@@ -0,0 +1,201 @@
|
||||
#!/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())
|
||||
+436
-1
@@ -28,11 +28,38 @@ AcknowledgeEggFile back on this socket. The console must STAY CONNECTED (a
|
||||
disconnect trips the pod's console-loss path, which also closes the game
|
||||
listener -- an engine bug).
|
||||
|
||||
Usage:
|
||||
Usage (legacy dial-out console -- the pod-authentic direction):
|
||||
python btconsole.py <egg-file> <host:port> [<host:port> ...]
|
||||
Example (two instances on one box, -net 1501 / -net 1601):
|
||||
python btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601
|
||||
|
||||
RELAY MODE (D1 -- internet play; pods dial OUT to this process):
|
||||
python btconsole.py --relay <consolePort> <egg-file> [--bind ADDR] [--udp-drop PCT]
|
||||
Example:
|
||||
python btconsole.py --relay 1500 MP_RELAY.EGG
|
||||
The relay listens on <consolePort>/tcp (console protocol: streams the egg to each
|
||||
pod that connects, sends the LAUNCH pair once ALL pods have the egg), on
|
||||
<consolePort>+1/tcp (the GAME relay: envelope-framed frame router, see
|
||||
RelayEnvelope below), and on <consolePort>+1/udp (the unreliable channel
|
||||
forwarder). Pods run with BT_RELAY=<host>:<consolePort> and a unique
|
||||
BT_SELF=<their [pilots] entry>.
|
||||
|
||||
RelayEnvelope (game TCP): { int32 route; uint32 length; } + length payload bytes
|
||||
route >= 2 : client->relay: unicast to that hostID
|
||||
relay->client: the sender's hostID (frames also self-identify
|
||||
via NetworkPacketHeader.fromHost)
|
||||
route == -1: broadcast to every registered pod except the sender
|
||||
route == -2: HELLO (payload: int32 magic 'BTR1', int32 myHostID)
|
||||
route == -3: PEER_UP (payload: int32 hostID) relay->client only
|
||||
route == -4: PEER_DOWN (payload: int32 hostID) relay->client only
|
||||
UDP envelope: { int32 route; int32 fromHost; uint32 seq; } + frame
|
||||
route as above; -2 = HELLO/keepalive (no payload) -> relay answers -5
|
||||
(HELLO-ACK). The relay learns each pod's public endpoint from every
|
||||
inbound datagram and forwards verbatim; unknown target endpoint falls
|
||||
back to wrapping the frame onto the target's game TCP connection.
|
||||
"""
|
||||
import random
|
||||
import selectors
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
@@ -144,7 +171,415 @@ def serve_pod(hostport, egg_bytes):
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
#############################################################################
|
||||
# RELAY MODE (D1)
|
||||
#############################################################################
|
||||
|
||||
ENV_FMT_TCP = "<iI" # route, length
|
||||
ENV_FMT_UDP = "<iiI" # route, fromHost, seq
|
||||
ENV_TCP_SIZE = 8
|
||||
ENV_UDP_SIZE = 12
|
||||
ROUTE_BCAST = -1
|
||||
ROUTE_HELLO = -2
|
||||
ROUTE_PEER_UP = -3
|
||||
ROUTE_PEER_DOWN = -4
|
||||
ROUTE_UDP_ACK = -5
|
||||
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
|
||||
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
|
||||
FIRST_GAME_HOST_ID = 2 # FirstLegalHostID(1) == the console; pods 2..N+1
|
||||
STATS_PERIOD = 10.0
|
||||
|
||||
|
||||
def parse_egg_roster(egg_path):
|
||||
"""Count the [pilots] pilot= entries -> expected hostIDs 2..N+1 in egg order.
|
||||
The relay's ONLY game knowledge; it never parses game frames."""
|
||||
entries = []
|
||||
in_pilots = False
|
||||
for raw in open(egg_path, "rb").read().replace(b"\r\n", b"\n").split(b"\n"):
|
||||
line = raw.strip()
|
||||
if line.startswith(b"["):
|
||||
in_pilots = (line.lower() == b"[pilots]")
|
||||
continue
|
||||
if in_pilots and line.lower().startswith(b"pilot="):
|
||||
entries.append(line.split(b"=", 1)[1].decode("ascii", "replace"))
|
||||
return entries
|
||||
|
||||
|
||||
class RelayGameConn:
|
||||
"""One accepted game-TCP connection: ACCEPTED -> (HELLO) REGISTERED -> GONE."""
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.buf = b""
|
||||
self.host_id = None # None until HELLO registers it
|
||||
|
||||
def name(self):
|
||||
hid = self.host_id if self.host_id is not None else "?"
|
||||
return f"game[{self.addr[0]}:{self.addr[1]} host={hid}]"
|
||||
|
||||
|
||||
class RelayConsoleConn:
|
||||
"""One accepted console connection (legacy raw protocol, relay-terminated)."""
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.egg_sent = False
|
||||
|
||||
|
||||
class Relay:
|
||||
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct):
|
||||
self.console_port = console_port
|
||||
self.game_port = console_port + 1
|
||||
self.bind_addr = bind_addr
|
||||
self.udp_drop_pct = udp_drop_pct
|
||||
self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read())
|
||||
self.roster = parse_egg_roster(egg_path)
|
||||
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
|
||||
FIRST_GAME_HOST_ID + len(self.roster)))
|
||||
self.sel = selectors.DefaultSelector()
|
||||
self.console_conns = []
|
||||
self.game_conns = [] # RelayGameConn (incl. unregistered)
|
||||
self.by_host = {} # hostID -> RelayGameConn
|
||||
self.udp_endpoint = {} # hostID -> (addr, port)
|
||||
self.udp_sock = None
|
||||
self.launch_at = None
|
||||
self.launches_sent = 0
|
||||
self.stats = {"tcp_rx": 0, "tcp_tx": 0, "udp_rx": 0, "udp_tx": 0,
|
||||
"udp_dropped": 0, "udp_tcp_fallback": 0}
|
||||
self.stats_at = time.time() + STATS_PERIOD
|
||||
|
||||
# ---------------- lifecycle ----------------
|
||||
|
||||
def run(self):
|
||||
print(f"[relay] roster: {len(self.roster)} pilot(s) -> hostIDs "
|
||||
f"{sorted(self.expected_ids)}: {self.roster}", flush=True)
|
||||
con_l = self._listener(self.console_port)
|
||||
game_l = self._listener(self.game_port)
|
||||
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.udp_sock.bind((self.bind_addr, self.game_port))
|
||||
self.udp_sock.setblocking(False)
|
||||
self.sel.register(con_l, selectors.EVENT_READ, ("con_listen", None))
|
||||
self.sel.register(game_l, selectors.EVENT_READ, ("game_listen", None))
|
||||
self.sel.register(self.udp_sock, selectors.EVENT_READ, ("udp", None))
|
||||
print(f"[relay] console {self.bind_addr}:{self.console_port} | game tcp/udp "
|
||||
f"{self.bind_addr}:{self.game_port} | udp-drop {self.udp_drop_pct}%",
|
||||
flush=True)
|
||||
while True:
|
||||
for key, _ in self.sel.select(timeout=0.5):
|
||||
kind, obj = key.data
|
||||
if kind == "con_listen":
|
||||
self._accept_console(key.fileobj)
|
||||
elif kind == "game_listen":
|
||||
self._accept_game(key.fileobj)
|
||||
elif kind == "console":
|
||||
self._console_read(obj)
|
||||
elif kind == "game":
|
||||
self._game_read(obj)
|
||||
elif kind == "udp":
|
||||
self._udp_read()
|
||||
self._tick_launch()
|
||||
self._tick_stats()
|
||||
|
||||
def _listener(self, port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((self.bind_addr, port))
|
||||
s.listen(16)
|
||||
s.setblocking(False)
|
||||
return s
|
||||
|
||||
# ---------------- console side (legacy protocol, relay-terminated) ----------------
|
||||
|
||||
def _accept_console(self, listener):
|
||||
sock, addr = listener.accept()
|
||||
sock.setblocking(False)
|
||||
conn = RelayConsoleConn(sock, addr)
|
||||
self.console_conns.append(conn)
|
||||
self.sel.register(sock, selectors.EVENT_READ, ("console", conn))
|
||||
# Stream the egg immediately (blocking sendall is fine: the pod's console
|
||||
# pad drains fast and the egg is a few KB).
|
||||
sock.setblocking(True)
|
||||
try:
|
||||
n = 0
|
||||
for pkt in egg_packets(self.egg_bytes):
|
||||
sock.sendall(pkt)
|
||||
n += 1
|
||||
conn.egg_sent = True
|
||||
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent "
|
||||
f"({n} chunks); {self._eggs_out()}/{len(self.roster)} pods have it",
|
||||
flush=True)
|
||||
except OSError as e:
|
||||
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg send failed {e!r}",
|
||||
flush=True)
|
||||
self._drop_console(conn)
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
sock.setblocking(False)
|
||||
except OSError:
|
||||
pass
|
||||
# GLOBAL launch timer: arm/rearm when the LAST pod has its egg.
|
||||
if self._eggs_out() >= len(self.roster) and self.launches_sent == 0:
|
||||
self.launch_at = time.time() + LAUNCH_SETTLE_SECONDS
|
||||
print(f"[relay] all pods have the egg; LAUNCH pair in "
|
||||
f"{LAUNCH_SETTLE_SECONDS}s", flush=True)
|
||||
|
||||
def _eggs_out(self):
|
||||
return sum(1 for c in self.console_conns if c.egg_sent)
|
||||
|
||||
def _console_read(self, conn):
|
||||
try:
|
||||
data = conn.sock.recv(4096)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
print(f"[relay] console conn {conn.addr[0]}:{conn.addr[1]} closed",
|
||||
flush=True)
|
||||
self._drop_console(conn)
|
||||
return
|
||||
if len(data) >= 24:
|
||||
(clid, _gid, fh, _ts) = struct.unpack_from("<iiii", data, 0)
|
||||
(mlen, mid) = struct.unpack_from("<Ii", data, 16)
|
||||
print(f"[relay] pod->console clientID={clid} fromHost={fh} msgID={mid} "
|
||||
f"len={mlen}", flush=True)
|
||||
|
||||
def _drop_console(self, conn):
|
||||
try:
|
||||
self.sel.unregister(conn.sock)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
conn.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if conn in self.console_conns:
|
||||
self.console_conns.remove(conn)
|
||||
|
||||
def _tick_launch(self):
|
||||
if self.launch_at is None or self.launches_sent >= 2:
|
||||
return
|
||||
if time.time() < self.launch_at:
|
||||
return
|
||||
pkt = run_mission_packet()
|
||||
for conn in list(self.console_conns):
|
||||
try:
|
||||
conn.sock.setblocking(True)
|
||||
conn.sock.sendall(pkt)
|
||||
conn.sock.setblocking(False)
|
||||
except OSError as e:
|
||||
print(f"[relay] launch send failed to {conn.addr}: {e!r}", flush=True)
|
||||
self.launches_sent += 1
|
||||
self.launch_at = time.time() + LAUNCH_STEP_SECONDS
|
||||
print(f"[relay] RunMission #{self.launches_sent} sent to "
|
||||
f"{len(self.console_conns)} pod(s)", flush=True)
|
||||
|
||||
# ---------------- game TCP side ----------------
|
||||
|
||||
def _accept_game(self, listener):
|
||||
sock, addr = listener.accept()
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.setblocking(False)
|
||||
conn = RelayGameConn(sock, addr)
|
||||
self.game_conns.append(conn)
|
||||
self.sel.register(sock, selectors.EVENT_READ, ("game", conn))
|
||||
print(f"[relay] {conn.name()} accepted (awaiting HELLO)", flush=True)
|
||||
|
||||
def _game_read(self, conn):
|
||||
try:
|
||||
data = conn.sock.recv(8192)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
self._drop_game(conn, "closed")
|
||||
return
|
||||
conn.buf += data
|
||||
while len(conn.buf) >= ENV_TCP_SIZE:
|
||||
route, length = struct.unpack_from(ENV_FMT_TCP, conn.buf, 0)
|
||||
if length > FRAME_CAP:
|
||||
self._drop_game(conn, f"oversize frame ({length})")
|
||||
return
|
||||
if len(conn.buf) < ENV_TCP_SIZE + length:
|
||||
break # partial; wait for more
|
||||
payload = conn.buf[ENV_TCP_SIZE:ENV_TCP_SIZE + length]
|
||||
conn.buf = conn.buf[ENV_TCP_SIZE + length:]
|
||||
self.stats["tcp_rx"] += 1
|
||||
self._handle_game_frame(conn, route, payload)
|
||||
if conn.sock.fileno() < 0: # dropped mid-loop
|
||||
return
|
||||
|
||||
def _handle_game_frame(self, conn, route, payload):
|
||||
if conn.host_id is None:
|
||||
# First frame MUST be a valid HELLO.
|
||||
if route != ROUTE_HELLO or len(payload) < 8:
|
||||
self._drop_game(conn, f"first frame not HELLO (route={route})")
|
||||
return
|
||||
magic, host_id = struct.unpack_from("<iI", payload, 0)
|
||||
host_id = int(host_id)
|
||||
if magic != HELLO_MAGIC:
|
||||
self._drop_game(conn, f"bad HELLO magic 0x{magic:x}")
|
||||
return
|
||||
if host_id not in self.expected_ids:
|
||||
self._drop_game(conn, f"HELLO hostID {host_id} not in roster "
|
||||
f"{sorted(self.expected_ids)}")
|
||||
return
|
||||
if host_id in self.by_host:
|
||||
self._drop_game(conn, f"HELLO hostID {host_id} already registered")
|
||||
return
|
||||
conn.host_id = host_id
|
||||
self.by_host[host_id] = conn
|
||||
print(f"[relay] {conn.name()} REGISTERED "
|
||||
f"({len(self.by_host)}/{len(self.roster)})", flush=True)
|
||||
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.
|
||||
for other_id in sorted(self.by_host):
|
||||
if other_id != host_id:
|
||||
self._send_control(conn, ROUTE_PEER_UP, other_id)
|
||||
for other in self.by_host.values():
|
||||
if other is not conn:
|
||||
self._send_control(other, ROUTE_PEER_UP, host_id)
|
||||
return
|
||||
if route == ROUTE_BCAST:
|
||||
env = struct.pack(ENV_FMT_TCP, conn.host_id, len(payload))
|
||||
for other in list(self.by_host.values()):
|
||||
if other is not conn:
|
||||
self._send_raw(other, env + payload)
|
||||
elif route >= FIRST_GAME_HOST_ID:
|
||||
target = self.by_host.get(route)
|
||||
if target is None:
|
||||
return # peer gone; drop silently
|
||||
env = struct.pack(ENV_FMT_TCP, conn.host_id, len(payload))
|
||||
self._send_raw(target, env + payload)
|
||||
else:
|
||||
print(f"[relay] {conn.name()} bad route {route} -- ignored", flush=True)
|
||||
|
||||
def _send_control(self, conn, route, host_id):
|
||||
self._send_raw(conn, struct.pack(ENV_FMT_TCP, route, 4)
|
||||
+ struct.pack("<i", host_id))
|
||||
|
||||
def _send_raw(self, conn, data):
|
||||
try:
|
||||
conn.sock.setblocking(True)
|
||||
conn.sock.sendall(data)
|
||||
conn.sock.setblocking(False)
|
||||
self.stats["tcp_tx"] += 1
|
||||
except OSError as e:
|
||||
self._drop_game(conn, f"send failed {e!r}")
|
||||
|
||||
def _drop_game(self, conn, why):
|
||||
print(f"[relay] {conn.name()} dropped: {why}", flush=True)
|
||||
try:
|
||||
self.sel.unregister(conn.sock)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
conn.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if conn in self.game_conns:
|
||||
self.game_conns.remove(conn)
|
||||
if conn.host_id is not None and self.by_host.get(conn.host_id) is conn:
|
||||
del self.by_host[conn.host_id]
|
||||
self.udp_endpoint.pop(conn.host_id, None)
|
||||
for other in self.by_host.values():
|
||||
self._send_control(other, ROUTE_PEER_DOWN, conn.host_id)
|
||||
|
||||
# ---------------- game UDP side ----------------
|
||||
|
||||
def _udp_read(self):
|
||||
while True:
|
||||
try:
|
||||
data, endpoint = self.udp_sock.recvfrom(4096)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
if len(data) < ENV_UDP_SIZE:
|
||||
continue
|
||||
route, from_host, _seq = struct.unpack_from(ENV_FMT_UDP, data, 0)
|
||||
if from_host not in self.by_host:
|
||||
continue # not TCP-registered: drop
|
||||
self.stats["udp_rx"] += 1
|
||||
# NAT-rebind tolerant: every datagram refreshes the endpoint map.
|
||||
self.udp_endpoint[from_host] = endpoint
|
||||
if route == ROUTE_HELLO:
|
||||
ack = struct.pack(ENV_FMT_UDP, ROUTE_UDP_ACK, CONSOLE_HOST_ID, 0)
|
||||
try:
|
||||
self.udp_sock.sendto(ack, endpoint)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if route == ROUTE_BCAST:
|
||||
targets = [h for h in self.by_host if h != from_host]
|
||||
elif route >= FIRST_GAME_HOST_ID and route in self.by_host:
|
||||
targets = [route]
|
||||
else:
|
||||
continue
|
||||
for host_id in targets:
|
||||
if self.udp_drop_pct and random.random() * 100.0 < self.udp_drop_pct:
|
||||
self.stats["udp_dropped"] += 1
|
||||
continue
|
||||
ep = self.udp_endpoint.get(host_id)
|
||||
if ep is not None:
|
||||
try:
|
||||
self.udp_sock.sendto(data, ep) # forward VERBATIM
|
||||
self.stats["udp_tx"] += 1
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
# Target's UDP endpoint unknown/broken: wrap the frame onto its
|
||||
# game TCP connection (asymmetric-UDP-blockage fallback).
|
||||
frame = data[ENV_UDP_SIZE:]
|
||||
target = self.by_host.get(host_id)
|
||||
if target is not None and frame:
|
||||
env = struct.pack(ENV_FMT_TCP, from_host, len(frame))
|
||||
self._send_raw(target, env + frame)
|
||||
self.stats["udp_tcp_fallback"] += 1
|
||||
|
||||
# ---------------- stats ----------------
|
||||
|
||||
def _tick_stats(self):
|
||||
if time.time() < self.stats_at:
|
||||
return
|
||||
self.stats_at = time.time() + STATS_PERIOD
|
||||
s = self.stats
|
||||
print(f"[relay-stats] tcp rx/tx {s['tcp_rx']}/{s['tcp_tx']} | "
|
||||
f"udp rx/tx {s['udp_rx']}/{s['udp_tx']} "
|
||||
f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}) | "
|
||||
f"registered {sorted(self.by_host)} udp-known {sorted(self.udp_endpoint)}",
|
||||
flush=True)
|
||||
|
||||
|
||||
def relay_main(argv):
|
||||
"""argv: --relay <consolePort> <egg-file> [--bind ADDR] [--udp-drop PCT]"""
|
||||
args = argv[:]
|
||||
bind_addr = "0.0.0.0"
|
||||
udp_drop = 0.0
|
||||
if "--bind" in args:
|
||||
i = args.index("--bind")
|
||||
bind_addr = args[i + 1]
|
||||
del args[i:i + 2]
|
||||
if "--udp-drop" in args:
|
||||
i = args.index("--udp-drop")
|
||||
udp_drop = float(args[i + 1])
|
||||
del args[i:i + 2]
|
||||
if len(args) != 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
console_port = int(args[0])
|
||||
egg_path = args[1]
|
||||
relay = Relay(console_port, egg_path, bind_addr, udp_drop)
|
||||
if not relay.roster:
|
||||
print(f"[relay] ERROR: no [pilots] entries in {egg_path}")
|
||||
return 2
|
||||
try:
|
||||
relay.run()
|
||||
except KeyboardInterrupt:
|
||||
print("[relay] shutting down")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "--relay":
|
||||
return relay_main(sys.argv[2:])
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
Reference in New Issue
Block a user