Files
BT411/scratchpad/test_relay_rearm.py
T
arcattackandClaude Opus 5 1cda880c6d console/relay: document it properly + fix two regressions the review caught
DOCS (the ask: after a compaction this session lost track of how the console
works and launched the wrong program, twice).

  * NEW context/operator-console.md -- the dedicated topic that was missing.
    Leads with the thing I got wrong: btoperator.py is the PySide6 GUI the
    operator uses; btconsole.py is the headless relay it spawns.  Then ports,
    the route table, the roster/seat/identity model, the full round lifecycle
    with every launch gate, liveness, mode-specific traps, and log locations.
  * NEW docs/OPERATOR_GUIDE.md -- sysop-facing: start the console, set up a
    mission, watch pods arrive, launch, run back-to-back rounds, what to press
    when LAUNCH looks dead, a troubleshooting table keyed on the exact log
    lines, and what to save BEFORE restarting a session (Start Session
    truncates operator_relay.log, so restarting to clear a problem destroys the
    evidence of it).
  * CLAUDE.md: two Quick Lookup rows + a DO-NOT entry naming the two programs,
    so the distinction survives the next compaction.
  * context/multiplayer.md: a pointer out of the scattered console notes to the
    new topic (they were buried across ~8 places in a large file, which is
    exactly why they evaporated).

FIXES -- both are regressions in my own previous commit, found by the review
pass, and one would have made a games night WORSE:

  * THE REAPER WOULD HAVE KILLED HEALTHY PLAYERS.  It was gated only on "no
    mission running", which a round RESET satisfies -- so it was armed for the
    whole BETWEEN-ROUNDS wait, and that is a period when a pod is legitimately
    byte-silent: its seat beacon is write-only for the process lifetime
    (L4NET.CPP: "the relay ignores its silence") and its console pad has no egg
    yet so it cannot ACK.  A real night showed 12-minute and 5-minute gaps; the
    180s deadline would have dropped healthy pods and forced their clients to
    relaunch.  It now runs ONLY in the active staging window, skips pads with no
    egg and conns that have not HELLO'd, and last_seen is also stamped from
    inbound UDP (a pod streaming updates while its TCP idles was being counted
    as silent).  Half-open detection is keepalive's job; this is just a backstop.
  * UnboundLocalError in the operator UI.  My end_sent reset was an `elif` in
    the chain that assigns `head`, so that branch left `head` unbound -- a crash
    on the first status refresh after End Mission, which is exactly the path the
    relay's "StopMission sent" line produces.  Moved out of the chain.

Plus one pre-existing wedge with the same symptom as the reported bug, live-
proven in operator_relay.log (~5 minutes of a night lost): _abort_round clears
eggs_released BEFORE the survivors' sockets close, so _maybe_reset_round's own
`if not self.eggs_released: return` skips the template restore forever -- the
roster stays trimmed, the release gates can never be met, and walk-ups get
ROSTER FULL.  _rearm_for_new_round's restore is therefore now UNCONDITIONAL (it
was gated on eggs_released, which made Re-arm useless in the one state that most
needs it) and it clears round_hold_until so an abort's settle window is not
inherited.  Aborts are ordinary: nothing on the wire distinguishes a straggler's
late FIN from a pod dying mid-load.

scratchpad/test_relay_rearm.py now 19 checks, all passing, including the two new
regression guards (a 15-minute-silent waiting pod is NOT reaped; a pad that was
never sent an egg is NOT reaped) and the mid-pair no-re-arm guard.

Still open, recorded in the new topic's frontmatter: the UDP endpoint map trusts
the sender's self-declared fromHost; the egg-ACK is a fixed-offset parse of one
recv with no reassembly; remote-operator mode can never enable LAUNCH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:36:14 -05:00

192 lines
7.2 KiB
Python

"""Focused state-machine test for the launch re-arm fix (no sockets, no game).
Reproduces the operator's wedge in-process:
a round runs (launches_sent=2), only SOME pods rejoin, LAUNCH is pressed.
Before the fix the press was swallowed with no output; after it, the relay
re-arms and serves the launch.
"""
import io
import os
import sys
REPO = r"C:\git\bt411"
sys.path.insert(0, os.path.join(REPO, "tools"))
os.chdir(os.path.join(REPO, "content"))
import btconsole # noqa: E402
EGG = os.path.join(REPO, "content", "FOGDAY.EGG")
fails = []
def check(label, ok, extra=""):
print(" %-52s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra))
if not ok:
fails.append(label)
def new_relay():
r = btconsole.Relay(1500, EGG, "127.0.0.1", 0, manual_launch=True)
return r
class FakeSock:
"""Records what the relay would have put on the wire."""
def __init__(self):
self.sent = []
def settimeout(self, _t):
pass
def setblocking(self, _b):
pass
def sendall(self, data):
self.sent.append(data)
class FakeConn:
"""Stands in for a pod's console connection."""
def __init__(self, acked=True):
self.egg_sent = True
self.acked = acked
self.addr = ("10.0.0.9", 5000)
self.last_seen = btconsole.time.time()
self.sock = FakeSock()
print("=== 1. THE REPORTED WEDGE: a round ran, a partial rejoin, LAUNCH pressed ===")
r = new_relay()
print(" roster seats: %d" % len(r.roster))
# A mission ran to completion.
r.eggs_released = True
r.launches_sent = 2
r.mission_started_at = btconsole.time.time() - 600
r.stop_sent = True
# Only SOME of the last round's pods came back (fewer acked conns than seats).
r.console_conns = [FakeConn(acked=True)]
assert r._pods_ready() < len(r.roster), "test needs a PARTIAL rejoin"
# The operator presses LAUNCH.
r.launch_requested = True
r._tick_launch()
check("launches_sent reset from 2", r.launches_sent == 0, "-> %d" % r.launches_sent)
check("round re-opened (eggs_released cleared)", r.eggs_released is False)
check("roster restored to the template", len(r.roster) == len(r.orig_roster))
check("launch request still pending (not eaten)", r.launch_requested is True)
check("stale stop_sent cleared", r.stop_sent is False)
print("\n=== 2. A STALE 'End Mission' MUST NOT KILL THE NEXT MISSION ===")
r = new_relay()
r.launches_sent = 0 # between rounds: nothing is running
r.stop_requested = True # operator pressed End Mission out of habit
r._tick_stop()
check("stop latch consumed while idle", r.stop_requested is False)
# and it must not have sent anything / marked a stop
check("no StopMission recorded", r.stop_sent is False)
print("\n=== 3. AN UN-ACKABLE SEAT: held, and the operator is told about RE-ARM ===")
r = new_relay()
r.eggs_released = True # eggs are out ...
r.eggs_done_at = None # ... but a seat will never ACK, so this stays None
r.launches_sent = 0
r.console_conns = [FakeConn(acked=True)]
r.launch_requested = True
r._tick_launch()
# Deliberately NOT auto-re-armed: during normal staging this state just means
# "pods are still ACKing", and re-releasing eggs there would restart the handout
# under an impatient operator. The explicit rearm command covers the stuck case.
check("round NOT silently restarted during staging", r.eggs_released is True)
r._rearm_for_new_round("explicit operator re-arm")
check("explicit re-arm DOES re-open the round", r.eggs_released is False)
print("\n=== 3b. MID-PAIR MUST NEVER RE-ARM (the storm caught on the rig) ===")
r = new_relay()
r.console_conns = [FakeConn(acked=True) for _ in r.roster]
r.eggs_released = True
r.eggs_done_at = btconsole.time.time() - 1000
r.launches_sent = 1 # RunMission #1 has gone out
r.launch_at = btconsole.time.time() + 5 # #2 is timed and pending
r.launch_requested = True # stays set across the pair
r._tick_launch()
check("no re-arm between RunMission #1 and #2", r.launches_sent == 1,
"-> %d" % r.launches_sent)
check("eggs NOT re-released mid-pair", r.eggs_released is True)
check("pair timer still intact", r.launch_at is not None)
print("\n=== 4. THE HAPPY PATH IS UNCHANGED (full roster, first launch) ===")
r = new_relay()
r.console_conns = [FakeConn(acked=True) for _ in r.roster]
r.eggs_released = True
r.eggs_done_at = btconsole.time.time() - 1000 # every pod ACKed long ago
r.launches_sent = 0
r.launch_requested = True
before_roster = len(r.roster)
r._tick_launch()
check("launch ARMED or fired", r.launch_at is not None or r.launches_sent > 0)
check("roster untouched", len(r.roster) == before_roster)
check("did NOT spuriously re-arm (eggs still released)", r.eggs_released is True)
check("RunMission actually reached the pods",
all(c.sock.sent for c in r.console_conns),
"%d pkt(s) to pod 1" % len(r.console_conns[0].sock.sent))
print("\n=== 5. REAPER: reaps a silent peer DURING STAGING only ===")
r = new_relay()
r.launches_sent = 0
r.eggs_released = True # staging: the egg is out, pods should be talking
dead = FakeConn(acked=True)
dead.last_seen = btconsole.time.time() - (btconsole.DEAD_PEER_SECONDS + 30)
live = FakeConn(acked=True)
r.console_conns = [dead, live]
r.game_conns = []
dropped = []
r._drop_console = lambda c: (dropped.append(c), r.console_conns.remove(c))
r._reap_dead_peers()
check("the silent conn was reaped", dead in dropped)
check("the live conn was kept", live in r.console_conns and live not in dropped)
print("\n=== 5b. REAPER MUST NOT TOUCH A WAITING POD BETWEEN ROUNDS ===")
# The regression this guards: a pod waiting for the next mission is byte-silent
# by design (its seat beacon is write-only -- L4NET.CPP "the relay ignores its
# silence" -- and its console pad has no egg to ACK). A real night showed 12-
# and 5-minute gaps between rounds, so a 180s deadline would have dropped
# HEALTHY players and forced their clients to relaunch.
r = new_relay()
r.launches_sent = 0
r.eggs_released = False # between rounds / pre-release
waiting = FakeConn(acked=False)
waiting.egg_sent = False
waiting.last_seen = btconsole.time.time() - 900 # silent for 15 minutes
r.console_conns = [waiting]
r.game_conns = []
reaped = []
r._drop_console = lambda c: reaped.append(c)
r._reap_dead_peers()
check("a 15-min-silent waiting pod is NOT reaped", not reaped)
print("\n=== 5c. REAPER SKIPS A PAD THAT WAS NEVER SENT AN EGG ===")
r = new_relay()
r.launches_sent = 0
r.eggs_released = True
nopad = FakeConn(acked=False)
nopad.egg_sent = False # nothing was asked of it yet
nopad.last_seen = btconsole.time.time() - 900
r.console_conns = [nopad]
r.game_conns = []
reaped = []
r._drop_console = lambda c: reaped.append(c)
r._reap_dead_peers()
check("no egg sent -> not reaped", not reaped)
print("\n=== 6. REAPER IS DISARMED WHILE A MISSION RUNS ===")
r = new_relay()
r.launches_sent = 2 # mission running
r.eggs_released = True
quiet = FakeConn(acked=True)
quiet.last_seen = btconsole.time.time() - 9999
r.console_conns = [quiet]
r.game_conns = []
r._drop_console = lambda c: fails.append("reaped mid-mission!")
r._reap_dead_peers()
check("no reaping during a live mission", "reaped mid-mission!" not in fails)
print("\n%s" % ("ALL CHECKS PASSED" if not fails else "FAILURES: %s" % fails))
sys.exit(1 if fails else 0)