C:\TripServer\tripserver_admin_supervisor.py
# -*- coding: utf-8 -*-
"""
TripServer Admin Supervisor
Runs outside admin_server.py and owns starting/restarting/updating it.
Install as a Windows Scheduled Task at user logon using install_tripserver_supervisor.cmd.
"""
import os
import sys
import time
import json
import shutil
import socket
import urllib.request
import urllib.error
import subprocess
import py_compile
from pathlib import Path
BASE_DIR = os.environ.get("TRIPSERVER_BASE_DIR", r"C:\TripServer")
ADMIN_PORT = int(os.environ.get("TRIPSERVER_ADMIN_PORT", "8000"))
ADMIN_SCRIPT = os.environ.get("TRIPSERVER_ADMIN_SCRIPT", os.path.join(BASE_DIR, "admin_server.py"))
SUPERVISOR_DIR = os.path.join(BASE_DIR, "supervisor")
PENDING_ADMIN = os.path.join(SUPERVISOR_DIR, "pending_admin_server.py")
REQUEST_FILE = os.path.join(SUPERVISOR_DIR, "restart_request.json")
REQUEST_DONE_FILE = os.path.join(SUPERVISOR_DIR, "restart_request.done.json")
REQUEST_FAILED_FILE = os.path.join(SUPERVISOR_DIR, "restart_request.failed.json")
LOCK_FILE = os.path.join(SUPERVISOR_DIR, "tripserver_supervisor.lock")
LOG_PATH = os.path.join(BASE_DIR, "tripserver_supervisor.log")
BACKUP_DIR = os.path.join(BASE_DIR, "backups", "admin_server")
CHECK_INTERVAL = float(os.environ.get("TRIPSERVER_SUPERVISOR_INTERVAL", "3"))
START_GRACE_SECONDS = float(os.environ.get("TRIPSERVER_SUPERVISOR_START_GRACE", "8"))
CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0)
CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
def now():
return time.strftime("%Y-%m-%d %H:%M:%S")
def ensure_dirs():
os.makedirs(BASE_DIR, exist_ok=True)
os.makedirs(SUPERVISOR_DIR, exist_ok=True)
os.makedirs(BACKUP_DIR, exist_ok=True)
def log(msg):
ensure_dirs()
line = f"{now()} | supervisor | {msg}"
try:
with open(LOG_PATH, "a", encoding="utf-8", errors="replace") as f:
f.write(line + "\n")
except Exception:
pass
def get_python_console():
exe = sys.executable or "python.exe"
if os.name == "nt":
folder = os.path.dirname(exe)
if os.path.basename(exe).lower() == "pythonw.exe" and folder:
cand = os.path.join(folder, "python.exe")
if os.path.exists(cand):
return cand
return exe
def file_sha256(path):
import hashlib
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def http_ping(timeout=1.5):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{ADMIN_PORT}/ping.json", timeout=timeout) as r:
data = r.read(200)
return r.status == 200 and b"ok" in data
except Exception:
return False
def port_open():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.5)
try:
return s.connect_ex(("127.0.0.1", ADMIN_PORT)) == 0
finally:
try:
s.close()
except Exception:
pass
def port_pids():
if os.name != "nt":
return []
try:
cp = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, errors="replace", timeout=8)
pids = set()
for line in (cp.stdout or "").splitlines():
if f":{ADMIN_PORT}" not in line:
continue
if "LISTENING" not in line.upper():
continue
parts = line.split()
if parts and parts[-1].isdigit():
pids.add(int(parts[-1]))
return sorted(pids)
except Exception as exc:
log("port_pids failed: " + repr(exc))
return []
def kill_pids(pids):
for pid in pids:
if pid == os.getpid():
continue
try:
cp = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True, errors="replace", timeout=10)
log(f"taskkill pid={pid} rc={cp.returncode} out={(cp.stdout or '').strip()} err={(cp.stderr or '').strip()}")
except Exception as exc:
log(f"taskkill pid={pid} failed: {exc!r}")
def start_admin():
if not os.path.exists(ADMIN_SCRIPT):
log("admin script missing: " + ADMIN_SCRIPT)
return False
py = get_python_console()
flags = 0
if os.name == "nt":
flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
try:
proc = subprocess.Popen(
[py, ADMIN_SCRIPT],
cwd=os.path.dirname(ADMIN_SCRIPT) or BASE_DIR,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=flags,
close_fds=False if os.name == "nt" else True,
start_new_session=(os.name != "nt"),
)
log(f"started admin pid={proc.pid} python={py} script={ADMIN_SCRIPT}")
time.sleep(START_GRACE_SECONDS)
log(f"post-start ping={http_ping()} port_open={port_open()} pids={port_pids()}")
return True
except Exception as exc:
log("start_admin failed: " + repr(exc))
return False
def stop_admin():
pids = port_pids()
if pids:
log("stopping admin pids=" + repr(pids))
kill_pids(pids)
for _ in range(20):
if not port_open():
return True
time.sleep(0.5)
log("port still open after stop; pids=" + repr(port_pids()))
return not port_open()
def compile_admin(path):
py_compile.compile(path, doraise=True)
def backup_active():
if not os.path.exists(ADMIN_SCRIPT):
return None
stamp = time.strftime("%Y%m%d_%H%M%S")
target = os.path.join(BACKUP_DIR, f"admin_server_supervisor_backup_{stamp}.py")
shutil.copy2(ADMIN_SCRIPT, target)
return target
def handle_update_request():
if not os.path.exists(REQUEST_FILE):
return False
ensure_dirs()
try:
with open(REQUEST_FILE, "r", encoding="utf-8", errors="replace") as f:
req = json.load(f)
except Exception as exc:
log("bad request file: " + repr(exc))
try:
shutil.move(REQUEST_FILE, REQUEST_FAILED_FILE)
except Exception:
pass
return True
log("update request: " + json.dumps(req, ensure_ascii=False))
pending = req.get("pending_admin_path") or PENDING_ADMIN
try:
if not os.path.exists(pending):
raise RuntimeError("pending admin file missing: " + pending)
compile_admin(pending)
expected = req.get("expected_sha256")
actual = file_sha256(pending)
if expected and actual.lower() != str(expected).lower():
raise RuntimeError(f"pending sha mismatch expected={expected} actual={actual}")
backup = backup_active()
stop_admin()
tmp_active = ADMIN_SCRIPT + ".supervisor_new"
shutil.copy2(pending, tmp_active)
os.replace(tmp_active, ADMIN_SCRIPT)
log(f"admin replaced from pending | sha={actual} | backup={backup}")
try:
os.remove(pending)
except Exception:
pass
start_admin()
req["handled_at"] = now()
req["result"] = "ok"
req["backup"] = backup
with open(REQUEST_DONE_FILE, "w", encoding="utf-8") as f:
json.dump(req, f, ensure_ascii=False, indent=2)
try:
os.remove(REQUEST_FILE)
except Exception:
pass
return True
except Exception as exc:
log("update request failed: " + repr(exc))
req["handled_at"] = now()
req["result"] = "failed"
req["error"] = repr(exc)
try:
with open(REQUEST_FAILED_FILE, "w", encoding="utf-8") as f:
json.dump(req, f, ensure_ascii=False, indent=2)
except Exception:
pass
# Keep request file so user can inspect/retry after fixing; avoid tight loop by renaming it.
try:
os.replace(REQUEST_FILE, REQUEST_FILE + ".failed")
except Exception:
pass
return True
def single_instance_ok():
ensure_dirs()
if os.name != "nt":
return True
# Simple stale lock: if another supervisor is running, this is best-effort only.
try:
if os.path.exists(LOCK_FILE):
age = time.time() - os.path.getmtime(LOCK_FILE)
if age < 30:
log("another supervisor lock is fresh; exiting")
return False
with open(LOCK_FILE, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
return True
except Exception:
return True
def loop():
ensure_dirs()
log("starting supervisor | python=" + sys.executable + " | admin=" + ADMIN_SCRIPT)
if not single_instance_ok():
return
last_ok = 0
while True:
try:
# update lock heartbeat
try:
with open(LOCK_FILE, "w", encoding="utf-8") as f:
f.write(str(os.getpid()) + " " + now())
except Exception:
pass
if handle_update_request():
time.sleep(CHECK_INTERVAL)
continue
if http_ping():
last_ok = time.time()
else:
po = port_open()
pids = port_pids() if po else []
if po:
log("ping failed but port open; killing stuck admin pids=" + repr(pids))
kill_pids(pids)
time.sleep(1.5)
else:
log("admin not listening; starting")
start_admin()
except Exception as exc:
log("loop error: " + repr(exc))
time.sleep(CHECK_INTERVAL)
if __name__ == "__main__":
loop()