parked relay: a supervisor that keeps it alive + a REMOTE restart the operator can actually reach

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>
This commit is contained in:
arcattack
2026-07-26 18:48:36 -05:00
co-authored by Claude Opus 5
parent 05f1ffb194
commit 05fdd319d6
7 changed files with 446 additions and 17 deletions
+17 -1
View File
@@ -24,7 +24,8 @@ import socket
import sys
import threading
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment, QThread, Signal
from PySide6.QtCore import (Qt, QProcess, QProcessEnvironment, QThread, QTimer,
Signal)
from PySide6.QtGui import QColor, QFont, QPalette, QAction
from PySide6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
@@ -966,6 +967,21 @@ class Operator(QMainWindow):
def _restart_session(self):
"""Next round: same mission/roster, fresh session. Players just
re-run their join script."""
if getattr(self, "remote_link", None):
# PARKED RELAY: we do not own that process, so tearing down our
# own link would only disconnect us (the old behaviour, which
# looked like "Restart did nothing"). Ask the relay itself to
# restart -- its supervisor relaunches it, re-reading the egg, so
# a roster resize or a wedge is recoverable from the road. Then
# reconnect once it is back up.
host = self.f_relayhost.text().strip()
self.log.appendPlainText(
"== asking the parked relay at %s to RESTART "
"(reconnecting in a few seconds) ==" % host)
self._relay_send("restart")
self._stop_session()
QTimer.singleShot(6000, self._start_session)
return
self.log.appendPlainText("== restarting session ==")
self._stop_session()
self._start_session()