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>
This commit is contained in:
arcattack
2026-07-26 00:36:14 -05:00
co-authored by Claude Opus 5
parent 4736cba1ca
commit 1cda880c6d
7 changed files with 595 additions and 25 deletions
+58 -18
View File
@@ -853,17 +853,43 @@ class Relay:
"""
if self.launches_sent >= 2:
return # mission running: leave peers alone
if not self.eggs_released:
#
# BETWEEN ROUNDS / BEFORE THE EGG GOES OUT, SILENCE IS CORRECT and
# can last a long time (a real night showed 12- and 5-minute gaps
# while the operator set up the next mission). A waiting pod has
# nothing to send: its seat beacon is deliberately 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. Reaping here
# would drop HEALTHY players and force their client to relaunch,
# which is worse than the ghost this reaper exists to remove.
# (Caught in review 2026-07-26, before it ever ran on a real night.)
#
return
#
# So the reaper only runs in the ACTIVE STAGING WINDOW: the egg is out and
# we are waiting for ACK/READY, which is exactly when a pod that has gone
# quiet is a pod that is gone. A truly half-open socket is caught sooner
# and more cheaply by TCP keepalive (set on accept, and the pod sets it on
# its own beacon too) -- this deadline is only the backstop for platforms
# where the keepalive tunables do not take.
#
now = time.time()
for conn in list(self.console_conns):
if not conn.egg_sent:
continue # nothing was asked of it yet
if now - getattr(conn, "last_seen", now) > DEAD_PEER_SECONDS:
print(f"[relay] console conn {conn.addr[0]}:{conn.addr[1]} "
f"REAPED -- silent for "
f"{now - conn.last_seen:.0f}s (was acked={conn.acked}); "
f"it can no longer hold the round open", flush=True)
f"{now - conn.last_seen:.0f}s while staging "
f"(acked={conn.acked}); it can no longer hold the round "
f"open", flush=True)
self._drop_console(conn)
for conn in list(self.game_conns):
if conn.host_id is None:
continue # beacon / not yet HELLO'd: silent by design
if now - getattr(conn, "last_seen", now) > DEAD_PEER_SECONDS:
self._drop_game(conn, "silent for %.0fs -- reaped"
self._drop_game(conn, "silent for %.0fs while staging -- reaped"
% (now - conn.last_seen))
def _pods_ready(self):
@@ -995,21 +1021,30 @@ class Relay:
self.mission_started_at = None
self.eggs_done_at = None
self._launch_blocked_warned = False
# Re-open the round for joins/edits: restore the untrimmed roster and
# the template egg, exactly as _maybe_reset_round would, so round 2+
# does not serve last round's trimmed egg (new joiners got ROSTER FULL)
# and between-round mission edits are picked up.
if self.eggs_released:
self.eggs_released = False
self.egg_path = self.orig_egg_path
self.egg_bytes = self.orig_egg_bytes
self.roster = list(self.orig_roster)
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
FIRST_GAME_HOST_ID + len(self.roster)))
self._reload_egg_file()
for c in self.console_conns:
c.acked = False # last round's ACK must not count
c.egg_sent = False # every pod needs THIS round's egg
# Re-open the round for joins/edits: restore the untrimmed roster and the
# template egg, so round 2+ does not serve last round's trimmed egg (new
# joiners got ROSTER FULL) and between-round mission edits are picked up.
#
# UNCONDITIONAL, and that matters. An earlier cut gated this on
# `eggs_released`, which made it useless in the one state that most needs
# it: _abort_round clears eggs_released BEFORE the survivors' sockets
# close, so _maybe_reset_round's own `if not self.eggs_released: return`
# then skips the template restore forever. The roster stays trimmed to
# last round's attendance, the release gates (which all compare against
# len(self.roster)) can never be met, walk-ups get ROSTER FULL, and the
# relay sits dead -- live-proven on 2026-07-25, nearly 5 minutes of a
# games night lost. Re-arm has to be able to dig out of exactly that.
self.eggs_released = False
self.egg_path = self.orig_egg_path
self.egg_bytes = self.orig_egg_bytes
self.roster = list(self.orig_roster)
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
FIRST_GAME_HOST_ID + len(self.roster)))
self.round_hold_until = 0 # do not inherit an abort's settle window
self._reload_egg_file()
for c in self.console_conns:
c.acked = False # last round's ACK must not count
c.egg_sent = False # every pod needs THIS round's egg
def _tick_launch(self):
# RE-ARM ON DEMAND: an operator LAUNCH while a previous round is still
@@ -1514,6 +1549,11 @@ class Relay:
self.stats["udp_rx"] += 1
# NAT-rebind tolerant: every datagram refreshes the endpoint map.
self.udp_endpoint[from_host] = endpoint
# ... and counts as LIVENESS. Without this, a pod streaming updates
# over UDP while its TCP sits idle looks "silent" to the reaper.
live = self.by_host.get(from_host)
if live is not None:
live.last_seen = time.time()
if route == ROUTE_HELLO:
ack = struct.pack(ENV_FMT_UDP, ROUTE_UDP_ACK, CONSOLE_HOST_ID, 0)
try:
+10 -6
View File
@@ -1020,17 +1020,21 @@ class Operator(QMainWindow):
seated = sum(1 for s in self.monitor.state.values()
if s in (ST_SEATED, ST_REGISTERED, ST_READY))
ready_n = sum(1 for s in self.monitor.state.values() if s == ST_READY)
# END MISSION is PER ROUND, not per session. `end_sent` used to be
# cleared only by Start Session, so the button worked exactly once a
# night; and a stale press between rounds used to kill the next mission
# the instant it launched (the relay now refuses that too). This MUST
# sit outside the head-assignment chain below: as an `elif` it swallowed
# a branch and left `head` unbound -> UnboundLocalError on the first
# refresh after End Mission (review catch, 2026-07-26).
if not self.monitor.launched and getattr(self, "end_sent", False):
self.end_sent = False
if self.monitor.launched:
head = "LAUNCHED — mission running"
if (self.console_proc and not self.end_btn.isEnabled()
and not getattr(self, "end_sent", False)):
self.end_btn.setEnabled(True)
elif getattr(self, "end_sent", False):
# END MISSION is PER ROUND, not per session. end_sent used to be
# cleared only by Start Session, so the button worked exactly once a
# night; and a stale press between rounds used to kill the next
# mission the instant it launched (the relay now refuses that too).
self.end_sent = False
elif self.monitor.held_status:
head = "STAGING — " + self.monitor.held_status
elif ready_n: