Claude
Scanner v18.33 + AI copilot: 14 new pairs, rotation, pool pricing, Telegram payout removal
9317c45 unverified | """ | |
| 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") | |