Completes the travelling-operator deployment. The relay stays parked on the
home machine (so no player ever edits join.bat) and the console dials in from
anywhere -- but until now two things needed hands on that machine: bringing the
relay back when it died, and restarting it (the only way to clear a wedge or
pick up a roster resize). Both are covered now.
tools/btrelay_park.py -- the supervisor:
* runs the relay with cwd=content\ , which is what pins WHICH
operator_secret.txt is live (the trap documented last commit);
* relaunches on ANY exit, with 2/5/15/30/60s backoff when a relay dies inside
20s, so a permanent fault (port taken, missing egg) cannot become a spin;
* rotates content\parked_relay.log to .1 first, so the dead generation's
evidence survives the relaunch that replaces it;
* Ctrl-C stops it for good. NOT a Windows service on purpose: session 0
would hide the window an operator wants to tail.
tools/park_relay.cmd -- double-click to park; a shortcut to it in shell:startup
gives start-after-reboot with no admin rights.
`restart` on the control port (btconsole.py): sets restart_requested, the run
loop returns, relay_main exits RESTART_EXIT_CODE=42 and the supervisor relaunches
-- re-reading the egg. REFUSED while a mission is running (launches_sent >= 2
and not stop_sent): bouncing then would drop every pod out of a live round.
Local stdin gets the same command for parity.
btoperator.py: Restart Session in REMOTE mode now sends `restart` and reconnects
6s later instead of just tearing down our own link -- the old behaviour looked
like "Restart does nothing" against a parked relay. (QTimer had to be imported;
it was missing, which py_compile does not catch -- it would have been a runtime
NameError on the first click.)
VERIFIED by scratchpad/test_parked_supervisor.py, 12/12, and the full
test_remote_console_e2e.py re-run green afterwards (15/15):
* kill the relay -> a NEW pid is serving again, and the old log is kept as .1;
* remote `restart` -> ACKed, new pid, egg re-read, serving again;
* the supervisor distinguishes the two -- "operator requested a restart" vs
the crash/backoff path -- proven from its own log, not assumed;
* the mid-mission guard is present and wired to launches_sent/stop_sent.
Two harness defects fixed while proving it, both mine: a check written with
`or True` that could never fail (replaced with two real assertions against the
supervisor's log), and a recv that treated the relay's correct
close-after-restart as a ConnectionResetError failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
5.9 KiB
Python
153 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""btrelay_park.py -- keep the relay PARKED and alive on the host machine.
|
|
|
|
The travelling-operator deployment: the relay lives permanently on one machine
|
|
so every player's join.bat address never changes, and the operator console
|
|
dials into its control port from anywhere. For that to be true the relay has
|
|
to outlive crashes, reboots and roster changes without anyone at the keyboard --
|
|
which is what this supervisor is for.
|
|
|
|
python tools/btrelay_park.py [--egg OPERATOR.EGG] [--port 1500]
|
|
[--manual-launch] [--once]
|
|
|
|
What it does:
|
|
* starts the relay with cwd = content\\ (the working directory decides which
|
|
operator_secret.txt is live -- see context/operator-console.md);
|
|
* relaunches it when it exits, whether that was a crash or the operator's
|
|
remote `restart` (RESTART_EXIT_CODE), with a short backoff so a relay that
|
|
dies instantly cannot spin the CPU;
|
|
* writes everything to content\\parked_relay.log, rotating the previous run
|
|
to .1 so a crash's evidence survives the relaunch;
|
|
* stops cleanly on Ctrl-C, taking the relay with it.
|
|
|
|
Deliberately NOT a Windows service: a service runs in session 0 and the relay
|
|
is something the operator wants to see, tail and Ctrl-C. For start-at-boot,
|
|
put a shortcut to tools\\park_relay.cmd in the Startup folder (shell:startup) --
|
|
no elevation, trivially removable.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CONTENT = os.path.join(REPO, "content")
|
|
RESTART_EXIT_CODE = 42 # must match tools/btconsole.py
|
|
|
|
# A relay that dies faster than this was not really running: back off so a
|
|
# permanent fault (port in use, missing egg) cannot become a spin loop.
|
|
HEALTHY_SECONDS = 20
|
|
BACKOFF = [2, 5, 15, 30, 60]
|
|
|
|
|
|
def rotate(path):
|
|
try:
|
|
if os.path.exists(path):
|
|
prev = path + ".1"
|
|
if os.path.exists(prev):
|
|
os.remove(prev)
|
|
os.replace(path, prev)
|
|
except OSError:
|
|
pass # a locked log must never stop the relay
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--egg", default="OPERATOR.EGG")
|
|
ap.add_argument("--port", type=int, default=1500)
|
|
ap.add_argument("--manual-launch", action="store_true", default=True,
|
|
help="hold the launch for the operator (the default)")
|
|
ap.add_argument("--auto-launch", dest="manual_launch",
|
|
action="store_false",
|
|
help="let the relay launch by itself once all pods stage")
|
|
ap.add_argument("--once", action="store_true",
|
|
help="do not relaunch -- run the relay a single time")
|
|
args = ap.parse_args()
|
|
|
|
egg = args.egg if os.path.isabs(args.egg) \
|
|
else os.path.join(CONTENT, args.egg)
|
|
if not os.path.exists(egg):
|
|
sys.exit("no such egg: %s" % egg)
|
|
|
|
secret = os.path.join(CONTENT, "operator_secret.txt")
|
|
log_path = os.path.join(CONTENT, "parked_relay.log")
|
|
|
|
print("=" * 68)
|
|
print(" BattleTech relay -- PARKED")
|
|
print(" egg : %s" % egg)
|
|
print(" ports : console %d | game %d | operator control %d"
|
|
% (args.port, args.port + 1, args.port + 7))
|
|
print(" secret : %s%s" % (secret,
|
|
"" if os.path.exists(secret)
|
|
else " (will be generated)"))
|
|
print(" log : %s (previous run kept as .1)" % log_path)
|
|
print(" launch : %s" % ("operator presses LAUNCH"
|
|
if args.manual_launch else "automatic"))
|
|
print(" Ctrl-C here stops the relay for good; everything else")
|
|
print(" (crash, or the operator's remote `restart`) relaunches it.")
|
|
print("=" * 68, flush=True)
|
|
|
|
cmd = [sys.executable, "-u", os.path.join(REPO, "tools", "btconsole.py"),
|
|
"--relay", str(args.port), egg]
|
|
if args.manual_launch:
|
|
cmd.append("--manual-launch")
|
|
|
|
generation = 0
|
|
strikes = 0
|
|
while True:
|
|
generation += 1
|
|
rotate(log_path)
|
|
started = time.time()
|
|
print("[park] generation %d starting %s"
|
|
% (generation, time.strftime("%H:%M:%S")), flush=True)
|
|
try:
|
|
with open(log_path, "w", encoding="utf-8") as log:
|
|
proc = subprocess.Popen(cmd, cwd=CONTENT, stdout=log,
|
|
stderr=subprocess.STDOUT)
|
|
code = proc.wait()
|
|
except KeyboardInterrupt:
|
|
print("\n[park] Ctrl-C -- stopping the parked relay", flush=True)
|
|
try:
|
|
proc.terminate()
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
except OSError as e:
|
|
print("[park] could not start the relay: %r" % e, flush=True)
|
|
return 1
|
|
|
|
lived = time.time() - started
|
|
if args.once:
|
|
print("[park] --once: relay exited %d after %.0fs" % (code, lived),
|
|
flush=True)
|
|
return code
|
|
|
|
if code == RESTART_EXIT_CODE:
|
|
strikes = 0 # an ASKED-FOR restart is healthy
|
|
print("[park] operator requested a restart -- relaunching "
|
|
"(the egg is re-read, so roster changes take effect)",
|
|
flush=True)
|
|
time.sleep(1)
|
|
continue
|
|
|
|
# Anything else is a death: crash, killed, or a startup failure.
|
|
if lived >= HEALTHY_SECONDS:
|
|
strikes = 0
|
|
wait = BACKOFF[min(strikes, len(BACKOFF) - 1)]
|
|
strikes += 1
|
|
print("[park] relay exited %d after %.0fs -- relaunching in %ds "
|
|
"(evidence in %s.1)" % (code, lived, wait, log_path), flush=True)
|
|
try:
|
|
time.sleep(wait)
|
|
except KeyboardInterrupt:
|
|
print("\n[park] Ctrl-C -- staying down", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
sys.exit(0)
|