verify the parked-relay/remote-console path END TO END (it works; 15/15)

The operator asked whether the console is actually set for the parked-relay
deployment or whether I was just vouching for earlier work.  Honest answer was
that prior verification was unit-level (relay log lines fed to an offscreen
widget) plus LOCAL-relay rig cycles -- the remote path had never been driven for
real.  Now it has:

  parked relay (standalone btconsole.py, cwd=content, all interfaces)
    + 2 real pods dialling in
    + the REAL operator GUI in REMOTE mode over a real TCP control socket

15/15, twice, with clean teardown.  Proven: AUTH; the relay REGISTERS both pods;
the GUI adopts the roster and lights the seats; LAUNCH / END MISSION / Re-arm all
ENABLE over the remote link (the exact things that were broken before today);
LAUNCH really reaches the pods (RunMission pair) and END MISSION really stops
them; mission settings cross the wire and rewrite the relay's OWN egg
(map=cavern time=night weather=soup verified in the relay log); and the parked
relay SURVIVES the operator disconnecting -- the property the whole deployment
depends on.

A LAN address is used deliberately, not loopback: btoperator treats
localhost/127.0.0.1 in the relay-host field as LOCAL mode, so a loopback test
would have silently exercised the wrong code path and proved nothing.

Three defects found in my own harness on the way, all fixed here (none in the
product):
  1. Pods announced BT_SELF on the LAN address while FOGDAY.EGG's roster lists
     127.0.0.1:1502/1602 -- seat identity mismatch, so they took the egg and
     closed.  The relay was RIGHT to refuse: "LAUNCH pressed but NO players are
     seated yet".  Pods now announce the address the egg's roster lists.
  2. The roster assertion counted EGG-CONFIGURED callsigns (always present) and
     would have passed with zero live players.  It now counts the relay's own
     REGISTERED lines.
  3. The relay's `set` really rewrites its egg -- pointed at a TRACKED file it
     dirtied content\FOGDAY.EGG (caught by git status, restored with git
     checkout).  The test now runs on a throwaway copy and deletes it.
  Also: cleanup now walks the PROCESS TREE, because btl4's front-end relaunches
  itself for the mission, so the surviving pod is a CHILD of the spawned PID --
  killing only our own handles left a live pod holding its log open.  Still
  PID-only, never by image name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-26 18:31:27 -05:00
co-authored by Claude Opus 5
parent 9df7dff787
commit 05f1ffb194
+231
View File
@@ -0,0 +1,231 @@
"""END-TO-END test of the PARKED-RELAY / REMOTE-CONSOLE deployment (2026-07-26).
The operator is travelling: the relay stays parked on the home machine (so no
player ever edits join.bat) and the console dials in from anywhere. Previous
verification of this mode was unit-level (relay lines fed to an offscreen
widget); this drives the WHOLE path for real:
parked relay (standalone btconsole.py, bound to all interfaces, cwd=content)
+ 2 real pods dialling in over the LAN address
+ the REAL operator GUI in REMOTE mode over a real TCP control socket
and asserts an entire night's workflow: AUTH, roster lights, LAUNCH enabling
and actually launching, mission settings applied to the relay's own egg, END
MISSION, and re-arm.
NOTE the reason a LAN address is used rather than loopback: the GUI treats
"localhost"/"127.0.0.1" in the relay-host field as LOCAL mode (btoperator.py
_start_session), so a loopback test would silently exercise the wrong path.
Spawns its own relay + pods and kills ONLY those PIDs.
"""
import os
import re
import socket
import subprocess
import sys
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
REPO = r"C:\git\bt411"
CONTENT = os.path.join(REPO, "content")
sys.path.insert(0, os.path.join(REPO, "tools"))
PORT = 1500
SRC_EGG = "FOGDAY.EGG" # the rig's known-good 2-seat egg
EGG = "_e2e_parked.EGG" # a COPY: `set` really rewrites this file
RELAY_LOG = os.path.join(os.environ.get("TEMP", "."), "parked_relay.log")
fails = []
procs = []
def check(label, ok, extra=""):
print(" %-56s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra))
if not ok:
fails.append(label)
def lan_ip():
"""This machine's primary LAN address (no traffic actually sent)."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
finally:
s.close()
def relay_text():
try:
with open(RELAY_LOG, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return ""
def wait_for(pattern, timeout, app=None):
"""Pump the Qt loop (if given) while waiting for a relay-log pattern."""
deadline = time.time() + timeout
while time.time() < deadline:
if re.search(pattern, relay_text()):
return True
if app is not None:
app.processEvents()
time.sleep(0.25)
return False
def cleanup():
"""Kill what we spawned -- INCLUDING DESCENDANTS.
btl4's front-end RELAUNCHES itself for the mission, so the process holding
the pod at the end is a CHILD of the PID we started: killing only our own
Popen handles left a live pod behind (observed 2026-07-26) which then held
its log file open. Walk the tree by PID; never kill by image name (that
would take out the operator's own session).
"""
ours = set()
for p in procs:
ours.add(p.pid)
try:
out = subprocess.run(
["wmic", "process", "get", "ProcessId,ParentProcessId"],
capture_output=True, text=True).stdout
pairs = []
for line in out.splitlines():
bits = line.split()
if len(bits) == 2 and bits[0].isdigit() and bits[1].isdigit():
pairs.append((int(bits[0]), int(bits[1]))) # parent, pid
for _ in range(4): # a few generations
for parent, pid in pairs:
if parent in ours:
ours.add(pid)
except Exception:
pass
for p in procs: # our own handles first
try:
if p.poll() is None:
p.kill()
except Exception:
pass
for pid in ours: # then any surviving descendant
subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"],
capture_output=True)
try:
ip = lan_ip()
print("PARKED-RELAY E2E -- relay/pods on %s:%d" % (ip, PORT))
pre = subprocess.run(["tasklist", "/FI", "IMAGENAME eq btl4.exe", "/NH"],
capture_output=True, text=True).stdout
if "btl4.exe" in pre:
sys.exit("ABORT: btl4.exe already running -- refusing to disturb it")
# ---- park the relay exactly as the guide says (cwd=content, no --bind) ----
secret_path = os.path.join(CONTENT, "operator_secret.txt")
with open(secret_path, "r", encoding="ascii") as f:
secret = f.read().strip()
print(" secret: content\\operator_secret.txt (%d chars)" % len(secret))
import shutil
shutil.copyfile(os.path.join(CONTENT, SRC_EGG), os.path.join(CONTENT, EGG))
print(" egg: %s (copy of %s -- the tracked file is never written)"
% (EGG, SRC_EGG))
log = open(RELAY_LOG, "w", encoding="utf-8")
procs.append(subprocess.Popen(
[sys.executable, "-u", os.path.join(REPO, "tools", "btconsole.py"),
"--relay", str(PORT), os.path.join(CONTENT, EGG), "--manual-launch"],
cwd=CONTENT, stdout=log, stderr=subprocess.STDOUT))
check("parked relay started", wait_for(r"operator control on port", 20))
check("control port is %d" % (PORT + 7),
("operator control on port %d" % (PORT + 7)) in relay_text())
# ---- two real pods dial in over the LAN address ----
env = dict(os.environ)
env.update({"BT_START_INSIDE": "1", "BT_DEV_GAUGES": "1",
"BT_MATCHLOG": "0", "BT_RELAY": "%s:%d" % (ip, PORT)})
exe = os.path.join(REPO, "build", "Release", "btl4.exe")
for tag, netport in (("r_a.log", 1502), ("r_b.log", 1602)):
e = dict(env)
e["BT_LOG"] = tag
e["BT_SELF"] = "127.0.0.1:%d" % netport # must match the egg roster
procs.append(subprocess.Popen(
[exe, "-egg", EGG, "-net", str(netport)], cwd=CONTENT, env=e))
time.sleep(2)
check("both pods staged on the parked relay",
wait_for(r"WAITING FOR OPERATOR", 90))
# ---- the console attaches REMOTELY, as from the road ----
from PySide6.QtWidgets import QApplication
import btoperator
app = QApplication.instance() or QApplication([])
win = btoperator.Operator()
win.mode.setCurrentIndex(0) # relay mode
win.f_relayhost.setText(ip) # non-loopback => REMOTE path
win.f_port.setValue(PORT)
win.f_secret.setText(secret)
win._start_session()
check("GUI took the REMOTE path (remote_link exists, no child relay)",
getattr(win, "remote_link", None) is not None
and getattr(win, "console_proc", None) is None)
check("relay AUTHENTICATED the remote operator",
wait_for(r"operator AUTHENTICATED", 20, app))
# roster adoption + LAUNCH enabling are the two things that were broken
deadline = time.time() + 30
while time.time() < deadline and not win.launch_btn.isEnabled():
app.processEvents()
time.sleep(0.25)
registered = len(re.findall(r"REGISTERED", relay_text()))
check("relay REGISTERED both pods (live seats, not egg text)",
registered >= 2, "registered lines=%d" % registered)
check("GUI monitor sees seated pilots",
getattr(win.monitor, "seated", lambda: 0)() >= 2
if callable(getattr(win.monitor, "seated", None))
else sum(1 for v in getattr(win.monitor, "state", {}).values()
if getattr(v, "registered", v) ))
check("LAUNCH enabled remotely", win.launch_btn.isEnabled())
check("END MISSION enabled remotely", win.stop_btn.isEnabled())
check("Re-arm enabled remotely", win.rearm_btn.isEnabled())
# ---- mission settings over the wire, into the relay's OWN egg ----
win.f_weather.setCurrentText(win.values.weathers[-1])
win._apply_mission_settings()
check("mission settings applied by the remote operator",
wait_for(r"mission settings applied by", 20, app))
# ---- launch, for real ----
win._launch_mission()
check("LAUNCH reached the pods (RunMission pair)",
wait_for(r"RunMission #2 sent to 2 pod", 40, app))
# ---- end the round, then re-arm ----
win._end_mission()
check("END MISSION reached the pods",
wait_for(r"StopMission sent to 2 pod", 30, app))
win._rearm_round()
check("re-arm accepted after the round",
wait_for(r"re-arm|round RESET", 30, app))
# ---- disconnecting the operator must NOT kill the parked relay ----
win._stop_session()
time.sleep(2)
check("parked relay still alive after the operator disconnects",
procs[0].poll() is None)
finally:
cleanup()
try:
os.remove(os.path.join(CONTENT, EGG))
except OSError:
pass
print()
print("ALL PASS" if not fails else "FAILURES: %s" % fails)
sys.exit(1 if fails else 0)