Garden-Angel-Ai-35Bot / process_manager.py
Claude
Scanner v18.33 + AI copilot: 14 new pairs, rotation, pool pricing, Telegram payout removal
9317c45 unverified
Raw
History Blame Contribute Delete
7.49 kB
"""
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")