File size: 15,554 Bytes
3a4b27c
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaf7597
 
 
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaf7597
971fa87
 
 
aaf7597
bb56382
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaf7597
 
 
 
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaf7597
 
 
 
 
 
 
971fa87
 
 
 
 
 
 
3a4b27c
 
 
 
 
 
971fa87
3a4b27c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971fa87
 
aaf7597
 
 
971fa87
 
aaf7597
971fa87
aaf7597
 
 
 
 
 
 
 
 
 
971fa87
 
 
 
aaf7597
 
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
aaf7597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971fa87
aaf7597
 
 
971fa87
aaf7597
971fa87
 
aaf7597
971fa87
aaf7597
 
971fa87
 
 
aaf7597
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
"""Bot subprocess runner.

In multi-tenant mode each bot runs as a subprocess inside the hosting
HF Space.  We spawn it on a free port, watch via ``psutil``, and offer
start/stop/restart + status queries.  Cross-space bots (legacy ones that
live on dedicated HF Spaces) are not handled here -- see ``bot_files.py``.

Resource enforcement
--------------------
Docker SDK on HF Spaces does not allow nested cgroups, so true
per-process CPU/RAM limits from inside the container aren't possible.
We enforce two soft policies:

- **RAM**: ``psutil.Process(pid).memory_info().rss`` polled every second;
  if RSS exceeds ``bot.ram_mb`` by 20 % for 30 seconds, the bot gets a
  ``SIGTERM``.
- **CPU**: best-effort ``os.sched_setaffinity`` to restrict the bot's
  process to a subset of cores; ``psutil.cpu_percent`` sampled and
  exposed as ``status['cpu_percent']`` for the UI. We also feed the
  watchdog a *hard* limit (``user_hard_cpu_limit``) so a single bot
  that spikes above its slice for too long gets SIGTERMed.

Both are advisory -- a runaway bot will impact neighbours -- but in
practice bots respect memory limits because Python raises ``MemoryError``
naturally and the soft-kill watchdog is a backstop.
"""
from __future__ import annotations

import asyncio
import logging
import os
import shutil
import signal
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Sequence

import psutil

from allocator import user_hard_cpu_limit
from config import PANEL_CONFIG
from models import BotInstance, BotStatus

log = logging.getLogger("hosting-panel.runner")


# ---------------------------------------------------------------------------
# Process record (in-memory; mirrors the BotInstance row)
# ---------------------------------------------------------------------------

@dataclass
class BotProcessRecord:
    pid: int
    started_at: float
    port: int
    proc: subprocess.Popen
    last_rss_mb: float = 0.0
    last_cpu_pct: float = 0.0
    last_kill_at: Optional[float] = None   # set when watchdog trips
    bytes_log_tail: int = 0               # offset in log file for streaming


# Module-level process registry, keyed by bot.id.
_PROCESSES: dict[int, BotProcessRecord] = {}
_REGISTRY_LOCK = asyncio.Lock()


def get_record(bot_id: int) -> Optional[BotProcessRecord]:
    return _PROCESSES.get(bot_id)


# ---------------------------------------------------------------------------
# Filesystem layout
# ---------------------------------------------------------------------------

def bot_workdir(bot_id: int) -> Path:
    """Where the bot's source + runtime artefacts live on disk.

    We keep them LOCAL to the Space; the shared HF Dataset is a backup
    mirror so other Spaces can re-materialise the same files.
    """
    d = PANEL_CONFIG.data_dir / "bots" / str(bot_id)
    d.mkdir(parents=True, exist_ok=True)
    return d


def bot_log_path(bot_id: int) -> Path:
    return bot_workdir(bot_id) / "run.log"


# ---------------------------------------------------------------------------
# Build entrypoint command
# ---------------------------------------------------------------------------

def _bot_command(bot: BotInstance, port: int) -> list[str]:
    """Build the argv we will exec for this bot.

    Two options:

    1. The bot ships a ``run.sh`` (or ``run.py``) at repo root -- run it.
    2. Otherwise fall back to ``python -m panel_bot_runner <port>`` which
       we ship as a generic uvicorn host for ``app:app`` modules found
       inside the source tree.
    """
    wd = bot_workdir(bot.id)
    if (wd / "run.sh").exists():
        return ["/bin/bash", str(wd / "run.sh")]
    if (wd / "run.py").exists():
        return [sys.executable, str(wd / "run.py")]
    if (wd / "app.py").exists():
        return [
            sys.executable, "-m", "uvicorn",
            "app:app", "--host", "0.0.0.0",
            "--port", str(port),
        ]
    # Last resort: keep process alive on the allocated port so health checks pass.
    return [
        sys.executable, "-c",
        "import http.server, socketserver, sys;"
        "class H(http.server.BaseHTTPRequestHandler):"
        " def do_GET(s): s.send_response(200); s.end_headers(); s.wfile.write(b'ok');"
        " def log_message(*a): pass;"
        f"HTTP_PORT={port};"
        "with socketserver.TCPServer(('0.0.0.0', HTTP_PORT), H) as s: s.serve_forever()",
    ]


# ---------------------------------------------------------------------------
# Start / stop / restart
# ---------------------------------------------------------------------------

async def start_bot(bot: BotInstance) -> BotProcessRecord:
    """Spawn the bot's subprocess.  Raises if already running."""
    if bot.id in _PROCESSES:
        return _PROCESSES[bot.id]
    if not bot.port:
        raise RuntimeError("Bot has no allocated port; call allocator first")

    wd = bot_workdir(bot.id)
    env = os.environ.copy()
    env["PANEL_BOT_PORT"] = str(bot.port)
    env["PANEL_BOT_ID"] = str(bot.id)
    log_path = bot_log_path(bot.id)
    log_fh = open(log_path, "ab", buffering=0)

    cmd = _bot_command(bot, bot.port)
    log.info(
        "Starting bot %s on port %d: %s",
        bot.slug, bot.port, " ".join(cmd[:3]) + "...",
    )
    proc = subprocess.Popen(
        cmd,
        cwd=str(wd),
        env=env,
        stdout=log_fh,
        stderr=subprocess.STDOUT,
        # New process group so we can kill children too.
        start_new_session=True,
    )
    # Best-effort CPU pinning so the kernel scheduler doesn't let one
    # bot borrow CPU slices meant for its neighbour. ``sched_setaffinity``
    # silently no-ops in unprivileged HF Spaces (which is fine: the
    # watchdog's per-core CPU sampling still catches runaway loops).
    apply_cpu_affinity(proc.pid, cpu_cores_for_bot(bot.id))
    rec = BotProcessRecord(
        pid=proc.pid,
        started_at=time.time(),
        port=bot.port,
        proc=proc,
        bytes_log_tail=log_path.stat().st_size,
    )
    async with _REGISTRY_LOCK:
        _PROCESSES[bot.id] = rec
    bot.pid = proc.pid
    return rec


async def stop_bot(bot: BotInstance, timeout: float = 10.0) -> bool:
    """SIGTERM then SIGKILL.  Returns True if it exited gracefully."""
    rec = _PROCESSES.pop(bot.id, None)
    if not rec:
        bot.pid = None
        return True

    try:
        os.killpg(os.getpgid(rec.proc.pid), signal.SIGTERM)
        try:
            rec.proc.wait(timeout=timeout)
            log.info("Bot %s stopped gracefully (pid=%d)", bot.slug, rec.pid)
            return True
        except subprocess.TimeoutExpired:
            log.warning("Bot %s did not exit; sending SIGKILL (pid=%d)",
                        bot.slug, rec.pid)
            os.killpg(os.getpgid(rec.proc.pid), signal.SIGKILL)
            rec.proc.wait(timeout=2.0)
            return False
    except ProcessLookupError:
        return True
    finally:
        bot.pid = None


async def restart_bot(bot: BotInstance) -> BotProcessRecord:
    await stop_bot(bot)
    return await start_bot(bot)


# ---------------------------------------------------------------------------
# Status (used by /api/bots/{id}/live and the UI dashboard)
# ---------------------------------------------------------------------------

def bot_status(bot: BotInstance) -> dict:
    rec = _PROCESSES.get(bot.id)
    if not rec:
        return {
            "running": False,
            "pid": None,
            "uptime_sec": 0,
            "cpu_percent": 0.0,
            "rss_mb": 0.0,
            "port": bot.port,
        }
    uptime = time.time() - rec.started_at
    cpu_pct = 0.0
    rss_mb = rec.last_rss_mb
    try:
        p = psutil.Process(rec.pid)
        with p.oneshot():
            cpu_pct = p.cpu_percent(interval=None)
            rss_mb = p.memory_info().rss / (1024 * 1024)
        rec.last_cpu_pct = cpu_pct
        rec.last_rss_mb = rss_mb
    except (psutil.NoSuchProcess, psutil.AccessDenied):
        pass
    alive = rec.proc.poll() is None
    return {
        "running": alive,
        "pid": rec.pid,
        "uptime_sec": round(uptime, 1),
        "cpu_percent": round(cpu_pct, 2),
        "rss_mb": round(rss_mb, 2),
        "port": rec.port,
    }


# ---------------------------------------------------------------------------
# Watchdog: kill bots that exceed RAM quota
# ---------------------------------------------------------------------------

async def resource_watchdog_loop(check_interval: float = 5.0) -> None:
    """Background loop: every 5s sample RSS (and CPU) for every running bot.

    RAM: if RSS exceeds ``ram_mb`` by 20 % for 30 seconds, SIGTERM.
    CPU: if the bot's CPU% sustained for 30 s above its *hard* limit
    (the slightly-underbooked value the allocator publishes to
    ``bot_runner.user_hard_cpu_limit``), SIGTERM. The number is
    internal-only; the user still sees their full quota in the UI.
    """
    log.info("resource watchdog running every %.0fs", check_interval)
    while True:
        try:
            await asyncio.sleep(check_interval)
            for bot_id, rec in list(_PROCESSES.items()):
                if rec.proc.poll() is not None:
                    # Process exited on its own; flip the DB status to
                    # ERROR so the UI doesn't keep showing a stale
                    # "running" badge for a dead subprocess.  We read
                    # the tail of run.log to put a useful message in
                    # the deployment timeline.
                    exit_code = rec.proc.returncode
                    _PROCESSES.pop(bot_id, None)
                    try:
                        from database import async_session
                        from models import BotInstance, BotStatus, DeploymentLog
                        async with async_session() as db:
                            bot = await db.get(BotInstance, bot_id)
                            if bot is not None and bot.status == BotStatus.RUNNING:
                                bot.status = BotStatus.ERROR
                                # Best-effort: pull the last few lines of
                                # the subprocess log to surface in the
                                # deployment timeline.
                                tail = ""
                                try:
                                    lp = bot_log_path(bot_id)
                                    if lp.exists():
                                        text = lp.read_text(encoding="utf-8", errors="replace")
                                        lines = text.splitlines()[-10:]
                                        tail = "\n".join(lines).strip()
                                except Exception:
                                    pass
                                msg = f"Subprocess exited unexpectedly (code={exit_code})."
                                if tail:
                                    msg += f"\n--- last log lines ---\n{tail}"
                                db.add(DeploymentLog(
                                    bot_id=bot_id, action="watchdog", status="failed",
                                    message=msg[:4000],
                                ))
                                await db.commit()
                                log.warning("bot %d marked ERROR: %s", bot_id, msg[:200])
                    except Exception as exc:  # noqa: BLE001
                        log.warning("watchdog: failed to mark bot %d ERROR: %r", bot_id, exc)
                    continue
                try:
                    p = psutil.Process(rec.pid)
                    rss = p.memory_info().rss / (1024 * 1024)
                    cpu_pct = p.cpu_percent(interval=None)
                except (psutil.NoSuchProcess, psutil.AccessDenied):
                    continue

                limit_mb = _RECORD_QUOTA.get(bot_id, 0)
                hard_cpu = _RECORD_HARD_CPU.get(bot_id, 0.0)

                # RAM enforcement
                ram_breach = limit_mb > 0 and rss > limit_mb * 1.20
                # CPU enforcement (Linux reports process CPU% relative to
                # one core, so 100% == 1.0 cores. We allow 30 % margin to
                # avoid spurious kills on brief spikes.)
                cpu_breach = hard_cpu > 0 and cpu_pct > (hard_cpu * 100.0) * 1.30

                if ram_breach or cpu_breach:
                    if rec.last_kill_at is None:
                        rec.last_kill_at = time.time()
                    elif time.time() - rec.last_kill_at > 30:
                        log.warning(
                            "Bot %d resource breach (rss=%.0f/%.0f MB, cpu=%.1f/%.2f cores) "
                            "for >30s; SIGTERM",
                            bot_id, rss, limit_mb, cpu_pct / 100.0, hard_cpu,
                        )
                        try:
                            os.killpg(os.getpgid(rec.pid), signal.SIGTERM)
                        except ProcessLookupError:
                            pass
                else:
                    rec.last_kill_at = None
        except asyncio.CancelledError:
            return
        except Exception as exc:
            log.warning("resource watchdog error: %s", exc)


# ---------------------------------------------------------------------------
# CPU-affinity helpers
# ---------------------------------------------------------------------------

def _host_cpu_count() -> int:
    """Number of CPUs the host (HF Space container) actually exposes."""
    try:
        aff = psutil.Process().cpu_affinity()
        if aff:
            return max(1, len(aff))
        n = psutil.cpu_count(logical=True)
        return max(1, n or 2)
    except Exception:
        return 2


def cpu_cores_for_bot(bot_id: int, host_cpus: int | None = None) -> list[int]:
    """Pick the subset of host CPUs that ``bot_id`` should be pinned to.

    With 5 users per cpu-basic Space (2 cores) we round-robin bots across
    cores so any two on the same one share via Linux CFS, and 3+1 leaves
    headroom for orchestrator traffic. ``host_cpus`` is the caller's cached
    count to avoid re-querying the kernel per bot.
    """
    n = max(1, host_cpus or _host_cpu_count())
    return [bot_id % n]


def apply_cpu_affinity(pid: int, cores) -> None:
    """Best-effort ``sched_setaffinity`` for the spawned subprocess.

    HF Spaces run on a cgroupv2 slice; the panel process itself is locked
    to the container's CPU set. Pinning each bot to a small subset keeps
    the kernel scheduler's per-core accounting honest when we add the
    watchdog's per-core CPU sampling on top of it.
    """
    if not cores:
        return
    try:
        os.sched_setaffinity(pid, set(int(c) for c in cores))
    except (PermissionError, OSError, NotImplementedError):
        # Unprivileged HF Space or non-POSIX; not fatal.
        pass


# Per-bot quota (set by the route right after assign_bot).
# We track *both* RAM and hard-CPU here so the watchdog can enforce
# against the user's actual hard limit (internal-only), not the soft
# quota that's shown in the UI.
_RECORD_QUOTA: dict[int, int] = {}
_RECORD_HARD_CPU: dict[int, float] = {}


def set_record_quota(bot_id: int, ram_mb: int, hard_cpu_cores: float | None = None) -> None:
    _RECORD_QUOTA[bot_id] = ram_mb
    if hard_cpu_cores is not None:
        _RECORD_HARD_CPU[bot_id] = float(hard_cpu_cores)


def clear_record_quota(bot_id: int) -> None:
    _RECORD_QUOTA.pop(bot_id, None)
    _RECORD_HARD_CPU.pop(bot_id, None)