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:
co-authored by
Claude Opus 5
parent
9df7dff787
commit
05f1ffb194
@@ -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)
|
||||
Reference in New Issue
Block a user