""" process_manager.py ────────────────── Thread-safe singleton that manages the bot.py subprocess. Why a separate module? Streamlit reruns app.py on every interaction (button click, page refresh). A naive approach loses the subprocess reference on each rerun. This module is imported *once* by the Python interpreter, so module-level globals survive across Streamlit reruns — giving us a true singleton. Public API start_bot() → (success: bool, message: str) stop_bot() → (success: bool, message: str) is_running() → bool get_stats() → dict | None """ import os import sys import signal import subprocess import threading import time from datetime import datetime from pathlib import Path import psutil # ── Paths ───────────────────────────────────────────────────────────────────── LOG_FILE = Path("/tmp/logs/bot.log") PID_FILE = Path("/tmp/logs/bot.pid") BOT_SCRIPT = str(Path(__file__).parent.parent / "bot.py") # ── Module-level state (survives Streamlit reruns) ──────────────────────────── _process: subprocess.Popen | None = None _lock = threading.Lock() # ── Internal helpers ────────────────────────────────────────────────────────── def _write_pid(pid: int) -> None: PID_FILE.parent.mkdir(parents=True, exist_ok=True) PID_FILE.write_text(str(pid)) def _read_pid() -> int | None: try: return int(PID_FILE.read_text().strip()) except (FileNotFoundError, ValueError): return None def _clear_pid() -> None: PID_FILE.unlink(missing_ok=True) def _proc_alive(pid: int) -> bool: """Return True if OS process `pid` exists and is not a zombie.""" try: p = psutil.Process(pid) return p.is_running() and p.status() != psutil.STATUS_ZOMBIE except (psutil.NoSuchProcess, psutil.AccessDenied): return False def _append_log(text: str) -> None: LOG_FILE.parent.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a", encoding="utf-8") as fh: fh.write(text) # ── Public API ──────────────────────────────────────────────────────────────── def is_running() -> bool: """Check whether the trading bot is alive.""" global _process # 1. In-memory reference (fastest path) if _process is not None and _process.poll() is None: return True # 2. PID file — handles page refreshes / new browser sessions pid = _read_pid() if pid and _proc_alive(pid): return True # Stale PID file cleanup if pid and not _proc_alive(pid): _clear_pid() return False def start_bot() -> tuple[bool, str]: """Launch bot.py as a detached subprocess. Idempotent.""" global _process with _lock: if is_running(): return False, "Bot is already running." if not Path(BOT_SCRIPT).exists(): return False, f"bot.py not found at: {BOT_SCRIPT}" try: LOG_FILE.parent.mkdir(parents=True, exist_ok=True) # Separator so the log is easy to scan _append_log( f"\n{'═' * 60}\n" f"[{datetime.now().isoformat(timespec='seconds')}] ▶ BOT STARTED\n" f"{'═' * 60}\n" ) log_fh = open(LOG_FILE, "a", encoding="utf-8", buffering=1) env = os.environ.copy() _process = subprocess.Popen( [sys.executable, "-u", BOT_SCRIPT], # -u = unbuffered stdout stdout=log_fh, stderr=subprocess.STDOUT, env=env, # start_new_session prevents SIGINT propagation from Streamlit start_new_session=True, ) _write_pid(_process.pid) return True, f"Bot started — PID {_process.pid}" except Exception as exc: # noqa: BLE001 return False, f"Failed to start bot: {exc}" def stop_bot() -> tuple[bool, str]: """Gracefully terminate the bot. Falls back to SIGKILL after 10 s.""" global _process with _lock: if not is_running(): _clear_pid() return False, "Bot is not running." pid = _read_pid() stopped = False # -- Try in-memory handle first -- if _process is not None: try: _process.terminate() _process.wait(timeout=10) stopped = True except subprocess.TimeoutExpired: _process.kill() _process.wait() stopped = True except Exception: pass finally: _process = None # -- Fall back to PID file (cross-session stop) -- if not stopped and pid: try: p = psutil.Process(pid) p.terminate() p.wait(timeout=10) stopped = True except psutil.NoSuchProcess: stopped = True # Already gone — that's fine except psutil.TimeoutExpired: p.kill() stopped = True except Exception: pass _clear_pid() _append_log( f"\n{'═' * 60}\n" f"[{datetime.now().isoformat(timespec='seconds')}] ⏹ BOT STOPPED\n" f"{'═' * 60}\n" ) return stopped, "Bot stopped." if stopped else "Stop attempted (process may have already exited)." def get_stats() -> dict | None: """Return live resource metrics for the bot process, or None.""" pid = _read_pid() if not pid: return None try: p = psutil.Process(pid) with p.oneshot(): mem = p.memory_info() created = datetime.fromtimestamp(p.create_time()) uptime = datetime.now() - created hours, remainder = divmod(int(uptime.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) return { "pid": pid, "status": p.status(), "cpu_pct": p.cpu_percent(interval=0.2), "rss_mb": mem.rss / (1024 ** 2), "vms_mb": mem.vms / (1024 ** 2), "uptime_str": f"{hours:02d}h {minutes:02d}m {seconds:02d}s", "started_at": created.strftime("%Y-%m-%d %H:%M:%S"), } except (psutil.NoSuchProcess, psutil.AccessDenied): _clear_pid() return None def get_logs(n_lines: int = 200) -> str: """Return the last *n_lines* from the log file.""" if not LOG_FILE.exists(): return "(No log output yet — start the bot to see activity.)" try: lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines() return "\n".join(lines[-n_lines:]) if lines else "(Log file is empty.)" except OSError as exc: return f"(Error reading log: {exc})" def clear_logs() -> None: """Truncate the log file.""" if LOG_FILE.exists(): LOG_FILE.write_text("", encoding="utf-8")