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:
co-authored by
Claude Opus 5
parent
820caf8765
commit
c9e561d9ba
+20
-17
@@ -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 N−1 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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user