executor_supervisor.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. """Executor supervisor: run the Motor-CAD task executor under a watchdog.
  2. Why this exists (incident 2026-09-04):
  3. The executor once became a zombie - process alive, but all threads frozen
  4. (a hung COM call holding the GIL is the prime suspect). No heartbeat, no
  5. task polling, no traceback, no log. User-submitted scans sat unclaimed for
  6. hours until someone noticed. In-process watchdogs cannot help when the GIL
  7. itself is seized, so supervision must live in a SEPARATE process.
  8. What it does:
  9. 1. Starts scripts/run_task_executor.py as a child process.
  10. 2. Every CHECK_INTERVAL_S seconds:
  11. - child dead -> restart (with backoff)
  12. - child alive but its executor OFFLINE at the backend for longer than
  13. OFFLINE_GRACE_S -> kill and restart (the zombie case)
  14. 3. Logs everything to output/executor_logs/supervisor_*.log and stdout.
  15. Usage:
  16. python scripts/executor_supervisor.py --config executor_config.json
  17. """
  18. import argparse
  19. import logging
  20. import os
  21. import signal
  22. import subprocess
  23. import sys
  24. import time
  25. from datetime import datetime
  26. from pathlib import Path
  27. import requests
  28. CHECK_INTERVAL_S = 15
  29. OFFLINE_GRACE_S = 180 # how long "alive but offline" is tolerated
  30. RESTART_BACKOFF_S = 10 # delay between restarts
  31. BACKEND_URL = "http://127.0.0.1:8000"
  32. def setup_logger(log_path: Path) -> logging.Logger:
  33. logger = logging.getLogger("supervisor")
  34. logger.setLevel(logging.INFO)
  35. fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
  36. fh = logging.FileHandler(log_path, encoding="utf-8")
  37. fh.setFormatter(fmt)
  38. sh = logging.StreamHandler(sys.stdout)
  39. sh.setFormatter(fmt)
  40. logger.addHandler(fh)
  41. logger.addHandler(sh)
  42. return logger
  43. def executor_online(backend: str, pid: int):
  44. """Return (reachable, online) for the executor belonging to child pid.
  45. Executor ids are 'motorcad-executor-<pid>-<rand>'; match by pid segment.
  46. reachable=False means the backend itself is down - not the child's fault,
  47. so the caller must NOT treat it as a zombie.
  48. """
  49. try:
  50. r = requests.get(f"{backend}/api/executor/status", timeout=5)
  51. data = r.json()
  52. except Exception:
  53. return False, None
  54. for e in data.get("executors", []):
  55. if f"-{pid}-" in str(e.get("executor_id", "")):
  56. return True, bool(e.get("online"))
  57. return True, None # backend up, but our executor has not registered yet
  58. def main():
  59. ap = argparse.ArgumentParser()
  60. ap.add_argument("--config", default="executor_config.json")
  61. args = ap.parse_args()
  62. root = Path(__file__).resolve().parent.parent
  63. log_dir = root / "output" / "executor_logs"
  64. log_dir.mkdir(parents=True, exist_ok=True)
  65. logger = setup_logger(log_dir / f"supervisor_{datetime.now():%Y%m%d_%H%M%S}.log")
  66. child_cmd = [
  67. sys.executable,
  68. str(root / "scripts" / "run_task_executor.py"),
  69. "--config", str(root / args.config),
  70. ]
  71. logger.info("supervisor started; child cmd: %s", " ".join(child_cmd))
  72. child = None
  73. offline_since = None
  74. stopping = False
  75. def stop_child(sig=None, frame=None):
  76. nonlocal stopping
  77. stopping = True
  78. if child and child.poll() is None:
  79. logger.info("stopping child pid=%s", child.pid)
  80. child.terminate()
  81. try:
  82. child.wait(timeout=10)
  83. except subprocess.TimeoutExpired:
  84. child.kill()
  85. signal.signal(signal.SIGINT, stop_child)
  86. signal.signal(signal.SIGTERM, stop_child)
  87. while not stopping:
  88. if child is None or child.poll() is not None:
  89. if child is not None:
  90. logger.warning("child exited rc=%s; restarting in %ds",
  91. child.returncode, RESTART_BACKOFF_S)
  92. time.sleep(RESTART_BACKOFF_S)
  93. child = subprocess.Popen(child_cmd, cwd=str(root))
  94. offline_since = None
  95. logger.info("child started pid=%s", child.pid)
  96. continue
  97. reachable, online = executor_online(BACKEND_URL, child.pid)
  98. if not reachable:
  99. # Backend down: nothing the child can do; just wait.
  100. offline_since = None
  101. elif online is False:
  102. if offline_since is None:
  103. offline_since = time.monotonic()
  104. logger.warning("child pid=%s registered but OFFLINE; grace %ds",
  105. child.pid, OFFLINE_GRACE_S)
  106. elif time.monotonic() - offline_since > OFFLINE_GRACE_S:
  107. logger.error("ZOMBIE: child pid=%s alive but offline >%ds; killing",
  108. child.pid, OFFLINE_GRACE_S)
  109. child.kill()
  110. child.wait(timeout=10)
  111. child = None
  112. offline_since = None
  113. else:
  114. if offline_since is not None:
  115. logger.info("child pid=%s back online", child.pid)
  116. offline_since = None
  117. time.sleep(CHECK_INTERVAL_S)
  118. stop_child()
  119. logger.info("supervisor stopped")
  120. if __name__ == "__main__":
  121. main()