| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145 |
- """Executor supervisor: run the Motor-CAD task executor under a watchdog.
- Why this exists (incident 2026-09-04):
- The executor once became a zombie - process alive, but all threads frozen
- (a hung COM call holding the GIL is the prime suspect). No heartbeat, no
- task polling, no traceback, no log. User-submitted scans sat unclaimed for
- hours until someone noticed. In-process watchdogs cannot help when the GIL
- itself is seized, so supervision must live in a SEPARATE process.
- What it does:
- 1. Starts scripts/run_task_executor.py as a child process.
- 2. Every CHECK_INTERVAL_S seconds:
- - child dead -> restart (with backoff)
- - child alive but its executor OFFLINE at the backend for longer than
- OFFLINE_GRACE_S -> kill and restart (the zombie case)
- 3. Logs everything to output/executor_logs/supervisor_*.log and stdout.
- Usage:
- python scripts/executor_supervisor.py --config executor_config.json
- """
- import argparse
- import logging
- import os
- import signal
- import subprocess
- import sys
- import time
- from datetime import datetime
- from pathlib import Path
- import requests
- CHECK_INTERVAL_S = 15
- OFFLINE_GRACE_S = 180 # how long "alive but offline" is tolerated
- RESTART_BACKOFF_S = 10 # delay between restarts
- BACKEND_URL = "http://127.0.0.1:8000"
- def setup_logger(log_path: Path) -> logging.Logger:
- logger = logging.getLogger("supervisor")
- logger.setLevel(logging.INFO)
- fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
- fh = logging.FileHandler(log_path, encoding="utf-8")
- fh.setFormatter(fmt)
- sh = logging.StreamHandler(sys.stdout)
- sh.setFormatter(fmt)
- logger.addHandler(fh)
- logger.addHandler(sh)
- return logger
- def executor_online(backend: str, pid: int):
- """Return (reachable, online) for the executor belonging to child pid.
- Executor ids are 'motorcad-executor-<pid>-<rand>'; match by pid segment.
- reachable=False means the backend itself is down - not the child's fault,
- so the caller must NOT treat it as a zombie.
- """
- try:
- r = requests.get(f"{backend}/api/executor/status", timeout=5)
- data = r.json()
- except Exception:
- return False, None
- for e in data.get("executors", []):
- if f"-{pid}-" in str(e.get("executor_id", "")):
- return True, bool(e.get("online"))
- return True, None # backend up, but our executor has not registered yet
- def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--config", default="executor_config.json")
- args = ap.parse_args()
- root = Path(__file__).resolve().parent.parent
- log_dir = root / "output" / "executor_logs"
- log_dir.mkdir(parents=True, exist_ok=True)
- logger = setup_logger(log_dir / f"supervisor_{datetime.now():%Y%m%d_%H%M%S}.log")
- child_cmd = [
- sys.executable,
- str(root / "scripts" / "run_task_executor.py"),
- "--config", str(root / args.config),
- ]
- logger.info("supervisor started; child cmd: %s", " ".join(child_cmd))
- child = None
- offline_since = None
- stopping = False
- def stop_child(sig=None, frame=None):
- nonlocal stopping
- stopping = True
- if child and child.poll() is None:
- logger.info("stopping child pid=%s", child.pid)
- child.terminate()
- try:
- child.wait(timeout=10)
- except subprocess.TimeoutExpired:
- child.kill()
- signal.signal(signal.SIGINT, stop_child)
- signal.signal(signal.SIGTERM, stop_child)
- while not stopping:
- if child is None or child.poll() is not None:
- if child is not None:
- logger.warning("child exited rc=%s; restarting in %ds",
- child.returncode, RESTART_BACKOFF_S)
- time.sleep(RESTART_BACKOFF_S)
- child = subprocess.Popen(child_cmd, cwd=str(root))
- offline_since = None
- logger.info("child started pid=%s", child.pid)
- continue
- reachable, online = executor_online(BACKEND_URL, child.pid)
- if not reachable:
- # Backend down: nothing the child can do; just wait.
- offline_since = None
- elif online is False:
- if offline_since is None:
- offline_since = time.monotonic()
- logger.warning("child pid=%s registered but OFFLINE; grace %ds",
- child.pid, OFFLINE_GRACE_S)
- elif time.monotonic() - offline_since > OFFLINE_GRACE_S:
- logger.error("ZOMBIE: child pid=%s alive but offline >%ds; killing",
- child.pid, OFFLINE_GRACE_S)
- child.kill()
- child.wait(timeout=10)
- child = None
- offline_since = None
- else:
- if offline_since is not None:
- logger.info("child pid=%s back online", child.pid)
- offline_since = None
- time.sleep(CHECK_INTERVAL_S)
- stop_child()
- logger.info("supervisor stopped")
- if __name__ == "__main__":
- main()
|