console review round 2: six must-fixes from the adversarial pass (one of its fixes corrected)

The 4-dimension review of 4736cba..820caf8 returned 14 surviving findings.  All
six must-fixes plus the four deferables are in; one of the review's own
prescriptions was wrong and is fixed differently (below).

THE REAPER, FINAL DOCTRINE (blocker + major).  Rev 2 -- "reap anything silent in
the staging window" -- was still wrong twice over: a pad sends exactly ONE
message in its life (the egg ACK, L4NET.CPP:1259) and is then silent FOREVER, so
any manual-launch hold >180s reaped every healthy ACKed pad and force-relaunched
their clients; and a REGISTERED game conn is quiet on TCP while loading, so with
BT_RELAY_TCP_ONLY=1 (or blocked UDP) the reaper _abort_round()ed the whole night
every ~3 minutes blaming a healthy player.  The rule is now: an app-level
deadline is valid only while a RESPONSE IS OWED.  Only egg-sent-never-ACKed pads
are reaped, with the debt clock starting at EGG SEND (a pad that sat through a
long held-egg wait gets its full window -- an edge neither review round caught);
game conns are never reaped at all.  Half-open ghosts are TCP keepalive's job
(~130s, faster than the deadline anyway).

RE-ARM GUARDED ON EVERY CHANNEL (major).  The launch_at guard lived only on the
LAUNCH-press path; the ctl `rearm` command, stdin, and the always-lit GUI button
reached _rearm_for_new_round unconditionally -- one press mid-mission zeroed the
counters, permanently killing the mission clock AND making End Mission print "no
mission is running": an unstoppable round, the exact class this feature exists
to eliminate.  Now refused (loudly) while a mission is running or a pair is in
flight, and the button greys while launched.  THE REVIEW'S OWN FIX WAS WRONG
here: it prescribed refusing on `launches_sent >= 2`, but that stays 2 after a
FINISHED round -- applying it verbatim resurrected the original dead-LAUNCH
wedge, caught immediately by the regression suite.  "Running" is
`launches_sent >= 2 AND not stop_sent`.

RE-ARM RE-KEYS SEATS (major).  It restored the template roster but left
seat_beacons/seat_prefs keyed by the trimmed round's positional ids, so round
2's trim minted a departed player's tag into the egg and trimmed a present
player's out, shifting every callsign/mech a slot.  The re-key block is factored
out of _maybe_reset_round (_rekey_seats_to_roster) and shared.

RESTART SESSION NO LONGER BAKES A WALK-UP'S NAME INTO THE EGG (major).
_stop_session and _start_session now restore the stashed configured
callsign/mech into the cells BEFORE _collect_egg can snapshot them
(_restore_seat_defaults); previously the departed name went into the egg (name=
plus rasterized bitmaps) and was then re-captured as the seat's permanent
"configured default".

LATE REMOTE OPERATOR GETS A ROSTER (major).  The only line the GUI can adopt
tags from was printed once at relay startup and aged out of the 400-line control
history in ~33 minutes of stats chatter -- AUTH now re-issues the live roster
line ahead of the replay, so a remote operator connecting at any point gets
pilot lights and a working LAUNCH button.

DEFERABLES, all four: _drop_game blames the tag stashed AT REGISTRATION (the
live-roster resolve named the wrong tag for a trimmed-round conn dying after a
re-arm restore -- and the tag-trusting GUI would clear the wrong seat); an
operator-BLANKED callsign cell now restores (empty string is a real configured
value; the falsy skip left the departed name up and re-captured it); a returning
player displaces their own half-open registered ghost (same IP, no mission
running) instead of eating ROSTER FULL until keepalive fires; udp_spoofed now
rides the [relay-stats] line when non-zero and the warn set is capped at 256.

VERIFIED: rearm suite grown to 25 checks (ACKed pad never reaped however silent;
never-ACKed pad reaped; game conns never reaped; the egg-send debt clock; re-arm
refused mid-mission, allowed after round end) -- plus net 17/17, roster 22/22,
checkctx CLEAN.  Live rig: mid-mission `rearm` refused with the message and the
mission survived; End Mission -> re-arm -> full re-seat -> second mission
launched.  One bounded artifact observed and documented: each waiting pod
bounces once (identity resync) after an explicit re-arm.

Docs rewritten to the final doctrine (context/operator-console.md reaper +
re-arm + roster-replay + udp_spoofed sections; OPERATOR_GUIDE + tooltip now say
re-arm is between-rounds-only and warn about the one-bounce resync).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-26 10:11:07 -05:00
co-authored by Claude Opus 5
parent 820caf8765
commit c9e561d9ba
5 changed files with 260 additions and 95 deletions
+20 -17
View File
@@ -202,7 +202,7 @@ surviving pods' sockets close, so every later `_maybe_reset_round` hits its own
three automatic release paths compare against `len(self.roster)`, so with N1 pods back nothing
releases, walk-ups get `ROSTER FULL`, and the relay sits dead.
`_rearm_for_new_round`'s template restore is therefore **unconditional** — an earlier cut gated it
`_rearm_for_new_round` is **guarded on every channel** (ctl command, stdin, the GUI button -- the guard lived only on the LAUNCH path at first, so a ctl `rearm` mid-mission killed the mission clock and made the round unstoppable): it refuses while a mission is RUNNING (`launches_sent >= 2 and not stop_sent` -- NOT `launches_sent` alone, which stays 2 after a finished round and would resurrect the original wedge) and while a launch pair is in flight. It also re-keys `seat_beacons`/`seat_prefs` to the restored roster (`_rekey_seats_to_roster`, shared with the round reset) -- without that, round 2 trimmed the WRONG seats into the egg. Its template restore is **unconditional** — an earlier cut gated it
on `eggs_released`, which made Re-arm useless in the one state that most needs it. It also clears
`round_hold_until` so an abort's settle window is not inherited.
@@ -229,19 +229,20 @@ phantom seat in the launch count. Only restarting the relay cleared it.
Now: keepalive on every accepted socket (`_enable_keepalive`), a `last_seen` stamp on every read
**and on every inbound UDP datagram**, and `_reap_dead_peers()` with `DEAD_PEER_SECONDS = 180`.
**The reaper is deliberately narrow, and the narrowness is load-bearing.** It runs **only in the
active staging window** — egg released, mission not yet running — and skips console pads with no egg
sent and game conns that have not HELLO'd. The first cut was gated only on "no mission running",
which armed it for the entire **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 the pod sets its own keepalive on it), and its
console pad has no egg yet so it cannot ACK. A real night showed 12-minute and 5-minute gaps
between rounds, so a 180 s deadline would have dropped **healthy** players and forced their clients
to relaunch — strictly worse than the ghost the reaper exists to remove. Caught in review before it
ever ran live. Regression guards: `scratchpad/test_relay_rearm.py` cases 5b/5c.
**The reaper's final doctrine (third revision — review 2026-07-26 — KEEP IT):** an app-level
deadline is valid only where silence is ABNORMAL, i.e. while a protocol response is OWED. The only
thing reaped is a console pad that was sent the egg and never ACKed within `DEAD_PEER_SECONDS`
(measured from EGG SEND, not from connect — a pad that sat through a long held-egg wait must get its
full window). Everything else is TCP keepalive's job (~130 s detection via `SIO_KEEPALIVE_VALS`).
Half-open sockets are TCP keepalive's job; this deadline is only the backstop for platforms where
the keepalive tunables do not take.
Why each earlier revision was wrong, so nobody re-widens it:
- Rev 1 armed it whenever no mission ran → it would have reaped healthy pods during 12-minute
BETWEEN-ROUNDS waits (a waiting pod is byte-silent by design).
- Rev 2 armed it for the whole STAGING window → it reaped every healthy **ACKed** pad 180 s into a
manual-launch hold, because a pad sends exactly ONE message in its life (the ACK) and is then
silent forever; and it reaped **registered game conns** that are quiet on TCP while loading
(fatal with `BT_RELAY_TCP_ONLY=1` or blocked UDP: `_abort_round` bounced everyone, blaming a
healthy player, every ~3 minutes). Game conns are now never reaped at all.
### Console-protocol reassembly [FIXED 2026-07-26]
@@ -264,8 +265,7 @@ id — and the victim simply stopped receiving on a channel that still looked he
now bound to the identity we actually authenticated: the **IP of that host's live TCP game
connection**. The **port is deliberately not checked** (it moves on a NAT rebind, which is the whole
reason the endpoint map refreshes per datagram), and a genuine IP change cannot happen without the
TCP connection breaking and re-registering, so this never rejects a legitimate pod. Rejections are
counted (`udp_spoofed`) and logged once per offending `(host, IP)` pair.
TCP connection breaking and re-registering, so this never rejects a legitimate pod. Rejections are counted and now SURFACED — `spoofed N` rides the `[relay-stats]` line whenever non-zero — and logged once per offending `(host, IP)` pair (the warn set is capped at 256 under a flood).
Limitation worth knowing: two pods on the **same machine** share an IP, so this cannot tell them
apart — same-machine trust is assumed.
@@ -320,8 +320,11 @@ doing nothing:
`Launch local instances` was refused by the same guard even though the code below it already
built `BT_RELAY` from the remote host. Now: all three enable conditions accept **either** channel
(`console_proc` *or* `remote_link`), and the monitor **adopts the roster** from the relay's
`roster: N pilot(s) -> hostIDs [...]: [...]` line, which the relay replays to every newly AUTHed
operator — so a remote operator gets real pilot lights and a real seat count.
`roster: N pilot(s) -> hostIDs [...]: [...]` line, which the relay **re-issues from live state to
every newly AUTHed operator ahead of the history replay** — relying on the startup line alone
broke after ~33 minutes, when stats chatter aged it out of the 400-line history and a
late-connecting remote operator got a blank roster and a dead LAUNCH button. So a remote operator
gets real pilot lights and a real seat count at any time of night.
- **`Start session` silently overwrites the egg on disk** (including re-rasterized callsigns), with
no prompt and no backup.
- **`Apply` writes only the six `[mission]` keys** (map/time/weather/scenario/temperature/length).
+7 -1
View File
@@ -100,6 +100,12 @@ button is greyed out, or pressing it does nothing:
Re-arm clears the finished round's state and re-opens the launcher **while keeping everyone who is
already connected**. You do **not** need Stop Session / Start Session, which disconnects everybody.
Re-arm is **for between rounds only**, and the console enforces that: while a mission is running it
is greyed out (and the relay refuses it with *"press End Mission first"*), and while a launch is in
the middle of firing it is refused with *"launch sequence in flight"*. So you cannot break a live
round with it. Expect each already-waiting player's client to bounce once (a few seconds) right
after a Re-arm — that is the seat re-sync, not a crash.
**Why this happens:** the console used to only consider itself ready for a new round if *every*
player from the last round came back, or if *all* of them left. In between — the normal case when
one person closes their window or their client crashes — it got stuck, and said nothing. That is
@@ -157,7 +163,7 @@ raise the seat count if you're simply out of seats.
| `egg HELD (n/N pods present)` | normal staging — waiting for the rest of the seats | wait, or press LAUNCH to start with who is here |
| LAUNCH greyed out after a round | the launcher is still holding the last round | press **↻ Re-arm** |
| `LAUNCH pressed but NO players are seated yet` | nobody has claimed a seat | check the players are pointed at the right address/port |
| `LAUNCH pressed but NOT all seats have ACKed` | some pods are still taking the mission file | wait a few seconds; if one is never coming, press **Re-arm** |
| `LAUNCH pressed but NOT all seats have ACKed` | some pods are still taking the mission file | wait a few seconds; if one is never coming, press **Re-arm** (between rounds only — it is refused mid-launch/mid-mission) |
| `launch HELD -- still loading: PLAYER n` | that pod has not finished loading | wait; it force-starts after 3 minutes |
| `*** WARNING: ... EMPTY seat(s) ... the mission will STALL` | **can be a false alarm** after the roster is trimmed | if the round starts and plays fine, ignore it |
| `seat RECLAIMED by identity` | a player who dropped got their seat back | nothing — this is working as intended |
+54 -8
View File
@@ -127,20 +127,66 @@ 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 ===")
print("\n=== 5. REAPER: only a pad that OWES an ACK is ever reaped ===")
# Doctrine (2nd review, 2026-07-26): silence is abnormal ONLY while a response
# is owed. An ACKed pad's silence is protocol-normal FOREVER (the ACK is its
# only message ever) -- the earlier cut reaped every healthy pad 180s into any
# manual-launch staging hold. Half-open ACKed ghosts are TCP keepalive's job.
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.eggs_released = True
owes = FakeConn(acked=False) # egg sent, never ACKed: dead
owes.egg_sent_at = btconsole.time.time() - (btconsole.DEAD_PEER_SECONDS + 30)
owes.last_seen = owes.egg_sent_at
acked_quiet = FakeConn(acked=True) # healthy: silent since its ACK
acked_quiet.last_seen = btconsole.time.time() - 9999
r.console_conns = [owes, acked_quiet]
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)
check("the never-ACKed pad was reaped", owes in dropped)
check("an ACKed pad is NEVER reaped however silent", acked_quiet not in dropped)
print("\n=== 5d. REAPER never touches game conns (loading pods are quiet) ===")
r = new_relay()
r.launches_sent = 0
r.eggs_released = True
class FakeGame: # registered, TCP-only, loading
addr = ("10.0.0.7", 700); host_id = 2
last_seen = btconsole.time.time() - 9999
r.console_conns = []
r.game_conns = [FakeGame()]
r._drop_game = lambda c, why: fails.append("game conn reaped: " + why)
r._reap_dead_peers()
check("a silent REGISTERED game conn is never reaped",
not any("game conn reaped" in f for f in fails))
print("\n=== 5e. the ACK-debt clock starts at EGG SEND, not connect ===")
r = new_relay()
r.launches_sent = 0
r.eggs_released = True
early = FakeConn(acked=False) # connected long ago (held egg)
early.last_seen = btconsole.time.time() - 9999 # ...but the egg JUST went out
early.egg_sent_at = btconsole.time.time() - 5
r.console_conns = [early]
r.game_conns = []
reaped = []
r._drop_console = lambda c: reaped.append(c)
r._reap_dead_peers()
check("a pad whose egg just went out is given its full window", not reaped)
print("\n=== 5f. RE-ARM refusals: running mission / in-flight pair ===")
r = new_relay()
r.launches_sent = 2
r.stop_sent = False # mission genuinely RUNNING
r.eggs_released = True
r.console_conns = []
r._rearm_for_new_round("test")
check("re-arm REFUSED while a mission is running", r.launches_sent == 2)
r.stop_sent = True # round has ENDED
r._rearm_for_new_round("test")
check("re-arm allowed once the round has ended", r.launches_sent == 0)
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
+138 -64
View File
@@ -657,6 +657,7 @@ class Relay:
_send_all_guarded(conn.sock, pkt, "egg chunk")
n += 1
conn.egg_sent = True
conn.egg_sent_at = time.time() # the ACK-debt clock starts NOW
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent "
f"({n} chunks); awaiting pod ACK", flush=True)
except OSError as e:
@@ -803,6 +804,30 @@ class Relay:
print(f"[relay] seat-pref egg rewrite FAILED ({e!r}) -- "
f"serving the original egg", flush=True)
def _rekey_seats_to_roster(self):
"""Re-key live beacons/prefs by each TAG's position in the CURRENT
roster. Needed whenever the roster changes shape (round reset, re-arm):
seat ids are positional, so a beacon keyed by a TRIMMED round's host id
points at the wrong seat once the template roster is restored -- a
departed player's tag was being minted into the next round's egg while a
present player's was trimmed out (review 2026-07-26). Beacons whose tag
is no longer in the roster are dropped from the maps (their seats free
up); reservations are cleared wholesale, matching the round-reset
semantics."""
old_beacons = self.seat_beacons
old_prefs = self.seat_prefs
self.seat_beacons = {}
self.seat_prefs = {}
self.seat_reservations = {}
for old_id, conn in old_beacons.items():
tag = getattr(conn, "seat_tag", None)
if tag in self.roster:
new_id = FIRST_GAME_HOST_ID + self.roster.index(tag)
conn.seat_host = new_id
self.seat_beacons[new_id] = conn
if old_id in old_prefs:
self.seat_prefs[new_id] = old_prefs[old_id]
def _maybe_reset_round(self):
# All pods gone from a FINALIZED round -> restore the template so the
# next round's joins are honored (held egg, fresh prefs/trim). Live
@@ -827,78 +852,55 @@ class Relay:
self.stop_sent = False
self.stop_requested = False
self.mission_started_at = None
old_beacons = self.seat_beacons
old_prefs = self.seat_prefs
self.seat_beacons = {}
self.seat_prefs = {}
self.seat_reservations = {}
for old_id, conn in old_beacons.items():
tag = getattr(conn, "seat_tag", None)
if tag in self.roster:
new_id = FIRST_GAME_HOST_ID + self.roster.index(tag)
conn.seat_host = new_id
self.seat_beacons[new_id] = conn
if old_id in old_prefs:
self.seat_prefs[new_id] = old_prefs[old_id]
self._rekey_seats_to_roster()
print(f"[relay] round RESET -- roster/egg restored "
f"({len(self.seat_beacons)} player(s) already waiting)",
flush=True)
def _reap_dead_peers(self):
"""Age out peers that stopped speaking entirely.
"""Age out a peer that OWES a protocol response and has gone silent.
TCP keepalive (set on accept) handles most half-open sockets, but it is
best-effort and platform-dependent, and a peer can also be alive at the
TCP level while its game process is gone. A silent connection is what
used to pin the relay: an acked console conn blocks the round reset
forever, and a registered game conn holds its by_host seat, so BOTH
launch re-arm paths stay unreachable and the operator has to restart.
THE DOCTRINE (rewritten twice in review, 2026-07-26 -- keep it): an
app-level deadline is only valid where silence is ABNORMAL, i.e. while a
response is owed. Everything else is TCP keepalive's job (set on every
accept; ~130s detection via SIO_KEEPALIVE_VALS), which kills the socket
and lands in the normal recv/drop path.
Deliberately generous (DEAD_PEER_SECONDS): a pod legitimately sends
nothing while sitting at the front end between rounds, so this must only
catch the genuinely gone. Never applied while a mission is running --
an in-mission pod that goes quiet is the netcode's problem, not ours.
What a healthy peer's silence looks like, so nobody re-widens this:
* a console pad sends exactly ONE message in its life -- the egg ACK.
After ACKing it is silent FOREVER. The first cut reaped ACKed pads
180s into any staging hold (a manual-launch operator waiting on a
straggler), force-relaunching every healthy client. [blocker]
* a REGISTERED game conn is loading the mission: quiet on TCP until
READY, and its 15s UDP HELLOs never arrive if the pod runs
BT_RELAY_TCP_ONLY=1 or UDP is blocked (the documented TCP fallback).
Reaping it _abort_round()s the whole night, every ~3 minutes,
blaming a healthy player. [major]
* between rounds every pod is byte-silent by design (the seat beacon
is write-only: L4NET.CPP "the relay ignores its silence").
So the ONLY thing this reaps is a console pad that was sent the egg and
never ACKed within DEAD_PEER_SECONDS -- a wedged or dead client holding
the eggs_done gate open. Debt paid (acked) = exempt. No egg = exempt.
Game conns are never reaped here at all.
"""
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.)
#
if self.launches_sent >= 2 or not self.eggs_released:
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:
if not conn.egg_sent or conn.acked:
continue # nothing owed / debt already paid
# Measure the debt from EGG SEND, not from the last byte: a pad that
# connected early and sat through a long held-egg wait would
# otherwise be over the deadline the instant the egg went out.
owed_since = max(getattr(conn, "last_seen", now),
getattr(conn, "egg_sent_at", now))
if now - owed_since > 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 while staging "
f"(acked={conn.acked}); it can no longer hold the round "
f"open", flush=True)
f"REAPED -- egg sent {now - owed_since:.0f}s ago and "
f"never ACKed; it can no longer hold the round 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 while staging -- reaped"
% (now - conn.last_seen))
def _tag_of(self, host_id):
"""The LIVE roster tag for a host id ('?' if out of range)."""
@@ -1057,6 +1059,35 @@ class Relay:
start a new round. Reset only the ROUND state -- seats, beacons, prefs
and connections are left alone, so whoever is here stays here.
"""
#
# GUARDED ON EVERY CHANNEL (review 2026-07-26). The `launch_at is None`
# guard lived only on the LAUNCH-press path, but the ctl `rearm` command,
# the stdin reader and the always-lit GUI button all reached here
# unconditionally. Mid-mission (launches_sent==2) a re-arm zeroes the
# counters, which permanently kills the mission clock AND makes End
# Mission print "no mission is running" -- the round becomes
# unstoppable except by Stop Session, the exact outcome this feature
# exists to eliminate. Mid-pair (launch_at set) it destroys the
# RunMission pair and re-releases eggs to mid-launch pods -- the re-arm
# storm. Refuse both, loudly.
#
# "Running" is launches_sent >= 2 AND no StopMission issued yet. NOT
# launches_sent alone: after a FINISHED round (clock expiry or End
# Mission -> stop_sent True) launches_sent still reads 2 until the
# re-arm itself clears it -- refusing on that would resurrect the very
# wedge this feature exists to fix (caught by the regression suite when
# the review's own proposed guard was applied verbatim).
if self.launches_sent >= 2 and not self.stop_sent:
print("[relay] RE-ARM refused -- a mission is RUNNING; press "
"End Mission first", flush=True)
self.launch_requested = False # do not let the press linger
return
if self.launch_at is not None:
print(f"[relay] RE-ARM refused -- a launch sequence is in flight "
f"(fires in {max(0.0, self.launch_at - time.time()):.0f}s)",
flush=True)
self.launch_requested = False
return
print(f"[relay] RE-ARM for a new round ({why}); "
f"previous round state cleared", flush=True)
self.launches_sent = 0
@@ -1090,6 +1121,12 @@ class Relay:
FIRST_GAME_HOST_ID + len(self.roster)))
self.round_hold_until = 0 # do not inherit an abort's settle window
self._reload_egg_file()
# Re-key beacons/prefs to the RESTORED roster (review 2026-07-26): they
# were keyed by the previous round's TRIMMED host ids, and the launch
# fall-through would reinterpret those against the full roster -- a
# departed player's tag minted into round 2's egg, a present player's
# trimmed out, and every callsign/mech shifted a slot.
self._rekey_seats_to_roster()
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
@@ -1346,7 +1383,19 @@ class Relay:
continue
host_id = FIRST_GAME_HOST_ID + i
if host_id in self.by_host:
break # actively PLAYING: not ours
# A half-open REGISTERED conn (machine slept, power
# cut) squats here until keepalive reaps it (~2 min),
# and its own player got ROSTER FULL meanwhile. No
# mission running + same source IP = this player's
# own ghost: displace it, exactly like a zombie
# beacon (review 2026-07-26).
ghost = self.by_host[host_id]
if (self.launches_sent < 2
and ghost.addr[0] == req_ip):
self._drop_game(
ghost, "displaced by its returning player")
else:
break # actively PLAYING: not ours
old = self.seat_beacons.get(host_id)
if old is not None: # zombie duplicate: displace it
print(f"[relay] displacing stale beacon on seat "
@@ -1459,9 +1508,14 @@ class Relay:
# trim remaps seat ids the GUI would light (and, since the roster
# display fix, clear) the WRONG row. The relay's roster here is the
# live -- possibly trimmed -- one, so this tag is the true identity.
# Stash the tag AT REGISTRATION: _drop_game resolves against the
# LIVE roster, and a trimmed-round conn dying after a re-arm restored
# the full roster would otherwise be blamed on the wrong tag -- and
# the tag-trusting GUI would clear the wrong seat (review 2026-07-26).
conn.reg_tag = self._tag_of(host_id)
print(f"[relay] {conn.name()} REGISTERED "
f"({len(self.by_host)}/{len(self.roster)}) "
f"tag='{self._tag_of(host_id)}'", flush=True)
f"tag='{conn.reg_tag}'", flush=True)
if len(self.by_host) >= len(self.roster):
self._release_eggs() # fallback; normally already fired
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.
@@ -1550,8 +1604,10 @@ class Relay:
def _drop_game(self, conn, why):
# tag='...' for registered conns, same reason as the REGISTERED print:
# the GUI must not resolve a remapped post-trim hostID positionally.
drop_tag = (f" tag='{self._tag_of(conn.host_id)}'"
if conn.host_id is not None else "")
drop_tag = ""
if conn.host_id is not None:
_dt = getattr(conn, "reg_tag", None) or self._tag_of(conn.host_id)
drop_tag = " tag='%s'" % _dt
print(f"[relay] {conn.name()} dropped: {why}{drop_tag}", flush=True)
try:
self.sel.unregister(conn.sock)
@@ -1625,6 +1681,8 @@ class Relay:
if not hasattr(self, "_udp_spoof_warned"):
self._udp_spoof_warned = set()
key = (from_host, endpoint[0])
if len(self._udp_spoof_warned) > 256:
self._udp_spoof_warned.clear() # bounded under a flood
if key not in self._udp_spoof_warned:
self._udp_spoof_warned.add(key)
print(f"[relay] UDP from {endpoint[0]}:{endpoint[1]} CLAIMS "
@@ -1771,6 +1829,16 @@ class Relay:
# under the lock so no line is lost or duplicated between
# the replay and the live stream
with _CONTROL_LOCK:
# The GUI adopts its roster from the startup "roster:" line,
# which is printed ONCE and ages out of the 400-line history
# in ~33 minutes of stats chatter -- an operator AUTHing
# late into the night got a permanently blank roster and a
# dead LAUNCH button (review 2026-07-26). Re-issue the LIVE
# roster in the same adoptable format before the replay.
conn.queue_out(
(f"[relay] roster: {len(self.roster)} pilot(s) -> "
f"hostIDs {sorted(self.expected_ids)}: "
f"{self.roster}\n").encode("utf-8", "replace"))
conn.queue_out(b"[ctl] --- session history replay ---\n")
for past in _CONTROL_HISTORY:
conn.queue_out(past)
@@ -1879,9 +1947,15 @@ class Relay:
return
self.stats_at = time.time() + STATS_PERIOD
s = self.stats
# spoofed rides along only when non-zero: the anti-hijack counter was
# invisible (never printed anywhere), so an actual hijack attempt left
# no operator-visible trace beyond the one-shot warning line.
spoofed = s.get("udp_spoofed", 0)
spoof_part = f", spoofed {spoofed}" if spoofed else ""
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"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}"
f"{spoof_part}) | "
f"registered {sorted(self.by_host)} udp-known {sorted(self.udp_endpoint)}",
flush=True)
+41 -5
View File
@@ -511,7 +511,8 @@ class Operator(QMainWindow):
"Clear the finished round's state and re-open the launcher, "
"keeping everyone who is already connected. "
"Use this if LAUNCH looks dead after a round instead of "
"restarting the session.")
"restarting the session. Between rounds only: refused while "
"a mission is running or a launch is firing.")
self.rearm_btn.clicked.connect(self._rearm_round)
self.rearm_btn.setEnabled(False)
self.launch_local_btn = QPushButton("Launch local instances")
@@ -817,12 +818,35 @@ class Operator(QMainWindow):
# ------------------------------------------------------------- session --
def _restore_seat_defaults(self):
"""Write every stashed configured (callsign, mech) back into its row and
clear the stash. MUST run before anything snapshots or saves the table:
Restart Session used to _collect_egg() with a departed walk-up's name
still in the cell, baking it into the next session's egg (name= AND the
rasterized bitmaps) and then re-capturing it as the seat's 'configured
default' -- the reported roster bug returning through the egg file
(review 2026-07-26)."""
for tag, (callsign, mech) in list(self._seat_defaults.items()):
for r in range(self.table.rowCount()):
item = self.table.item(r, 0)
if item is None or item.text().strip() != tag:
continue
cell = self.table.item(r, 1)
if callsign is not None and cell is not None:
cell.setText(callsign)
combo = self.table.cellWidget(r, 2)
if mech is not None and combo is not None:
combo.setCurrentText(mech)
break
self._seat_defaults.clear()
def _start_session(self):
self.end_sent = False
# A session stopped while a seat was OCCUPIED leaves its stash entry
# behind (the seat never emptied, so it was never popped); a new
# session must not restore last session's snapshot.
self._seat_defaults.clear()
# behind (the seat never emptied, so it was never popped). Write those
# configured values BACK into the cells before _collect_egg can bake a
# walk-up's name into the egg, then start clean.
self._restore_seat_defaults()
relay_host = self.f_relayhost.text().strip()
if (self.mode.currentIndex() == 0 and relay_host
and relay_host.lower() not in ("localhost", "127.0.0.1")):
@@ -916,6 +940,7 @@ class Operator(QMainWindow):
self.pod_status.setText("remote link closed")
def _stop_session(self):
self._restore_seat_defaults() # departed walk-up names must not outlive the session
if getattr(self, "remote_link", None):
link = self.remote_link
self.remote_link = None # closed-signal becomes a no-op
@@ -1130,8 +1155,16 @@ class Operator(QMainWindow):
callsign, mech = info
else:
# Empty: restore the configured pilot and forget the stash, so
# the next player to take this seat re-snapshots.
# the next player to take this seat re-snapshots. Restore on
# `is not None`: an operator-BLANKED cell is a real configured
# value, and skipping it as falsy left the departed name up and
# then re-captured it as the default (review 2026-07-26).
callsign, mech = self._seat_defaults.pop(tag, (None, None))
if callsign is not None and cell is not None and cell.text() != callsign:
cell.setText(callsign)
if mech is not None and combo is not None and combo.currentText() != mech:
combo.setCurrentText(mech)
continue
if callsign and cell is not None and cell.text() != callsign:
cell.setText(callsign)
if mech and combo is not None and combo.currentText() != mech:
@@ -1145,6 +1178,9 @@ class Operator(QMainWindow):
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
and not self.monitor.launched
and have_relay)
# Re-arm is refused by the relay mid-mission/mid-pair; grey it too so the
# button beside END MISSION does not invite the press (review 2026-07-26).
self.rearm_btn.setEnabled(have_relay and not self.monitor.launched)
if self.monitor.stats:
self.stats_label.setText(self.monitor.stats)