Connection audit: every connect/disconnect scenario handled + verified

Systematic actor-death x lifecycle-phase sweep (operator request after
the WaitingForEgg zombie).  New handling, all live-verified:

1. PRE-EGG console-pad loss (the real zombie phase): before the egg the
   pod's only link is the console pad -- the game socket doesn't exist,
   so the earlier RelayGameDown hook could never fire (the verification
   run itself caught this).  Detection now lives in
   HostDisconnectedMessageHandler ConsoleHostType (relayMode +
   pre-scene -> BTRelayRejoinNow); mesh keeps 1995 behavior.
2. MID-MISSION relay loss: the STOP is relay-sent (1995: the console
   owned the clock), so a pod losing its relay mid-match played a
   peer-less FOREVER-mission.  RelayGameDown (scene presented) now
   posts StopMissionMessage at +15s -> normal end -> lobby relaunch.
3. POST-RELEASE pod death stalled the round (survivors wait forever on
   the dead peer's connection gate): relay _abort_round -> route -11
   REJOIN to survivors -> everyone re-seats in seconds (reset-first
   ordering makes the drop cascade re-trigger-proof).
4. BTRelayRejoinNow carries BT_SEAT_CLAIM (mirrored tag): without it
   the rejoiner's own reclaim-hold blocked assignment -> ROSTER FULL
   loop for the 90s window (found by verification round 2).
5. Beacon NAT keepalive (SIO_KEEPALIVE_VALS 60s/10s) so long
   between-rounds waits can't be silently unseated.
6. BT_LOG_APPEND=1 preserves the log across lobby-loop generations
   (truncation erased round-1 verification evidence).

Verified non-issues: relay pods never bind the -net listener (no
double-launch collision on the internet path); mesh bind-fail exits
cleanly.  Full matrix + evidence: context/multiplayer.md;
scratchpad/audit_verify.sh is the repeatable rig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-23 09:23:23 -05:00
co-authored by Claude Opus 4.8
parent 56ca2f5c13
commit c8cba8c764
4 changed files with 194 additions and 2 deletions
+29
View File
@@ -227,6 +227,7 @@ ROUTE_SEAT_ASSIGN = -7 # relay->client: int32 hostID + NUL-terminated tag
ROUTE_SEAT_FULL = -8 # relay->client: no free seats
ROUTE_MATCHLOG = -9 # client->relay: matchlog upload (filename NUL + bytes)
ROUTE_READY = -10 # client->relay: mission load complete (READY light)
ROUTE_REJOIN = -11 # relay->client: abandon the round, rejoin (peer died mid-load)
# the 18 certified mech tags (mirror of the FE kVehicles catalog)
VEHICLE_TAGS = {"blkhawk", "loki", "bhk1", "madcat", "thor", "owens",
"own1", "thr1", "lok1", "mad1", "avatar", "ava1",
@@ -1040,6 +1041,30 @@ class Relay:
except OSError as e:
self._drop_game(conn, f"send failed {e!r}")
def _abort_round(self, why):
"""ROUND ABORT (connection audit 2026-07-23): a registered pod died
AFTER egg release but BEFORE launch -- the engine's all-connections
gate would leave every survivor waiting on it forever. Bounce all
remaining pods back to their seats (REJOIN) and reset the round; the
reclaim window puts everyone back in the same seats in seconds."""
if not self.eggs_released or self.launches_sent >= 2:
return
print(f"[relay] ROUND ABORT ({why}) -- sending every pod back to "
f"the join wait for a fresh round", flush=True)
# reset FIRST so the cascade of resulting drops can't re-trigger
self.eggs_released = False
self.launch_at = None
self.launch_requested = False
self.eggs_done_at = None
self._launch_blocked_warned = False
survivors = list(self.by_host.values())
for conn in survivors:
try:
self._send_raw(conn, struct.pack(ENV_FMT_TCP, ROUTE_REJOIN, 0))
except OSError:
pass
# the full state restore happens in _maybe_reset_round as they drop
def _drop_game(self, conn, why):
print(f"[relay] {conn.name()} dropped: {why}", flush=True)
try:
@@ -1066,12 +1091,16 @@ class Relay:
print(f"[relay] PLAYER {seat - FIRST_GAME_HOST_ID + 1} LEFT "
f"(host {seat}) tag='{tag}' "
f"({len(self.seat_beacons)} seated)", flush=True)
was_registered = (conn.host_id is not None
and self.by_host.get(conn.host_id) is 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 list(self.by_host.values()): # snapshot: a failed
# send drops that peer INSIDE the loop (mutates by_host)
self._send_control(other, ROUTE_PEER_DOWN, conn.host_id)
if was_registered:
self._abort_round(f"host {conn.host_id} dropped mid-load")
self._maybe_reset_round()
# ---------------- game UDP side ----------------