# session_manager.py — the GPU mutex. one warm model at a time on workpc. # owns: model registry, llama-server subprocess lifecycle, session leases + reaper. # workpc holds no durable state; this only guards the single local GPU. # # subprocess models (gemma4): this process starts/stops llama-server and health-waits. # in-process models (siglip2, dots): no server — the lease just reserves the GPU; the # worker loads the transformers model itself after /session/open returns (port=None). # the guarantee that matters is the mutex: a second open() gets 409 until the first closes. import os, time, uuid, threading, subprocess import requests from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() MODELS = { "gemma4": { "binary": "/mnt/D/AI/llama-cpp/llama.cpp/build/bin/llama-server", "model": "/mnt/D/AI/gemma4/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf", "mmproj": "/mnt/D/AI/gemma4/mmproj-F16.gguf", "port": 8090, "ctx": 32768, # 12B fits 32k KV unquantized on 16GB ROCm; do NOT -ctk/-ctv quant (leaks on ROCm) # MTP speculative decoding: the ~430M gemma-4 draft head drafts up to 4 tokens the 12B verifies. # needs `--spec-type draft-mtp` to actually engage (without it llama-server loads the draft but # silently runs no speculation). ~42% accept on our JSON stages -> faster decode; ~0.9GB VRAM. "args_extra": ["--jinja", "--reasoning-budget", "0", "-md", "/mnt/D/AI/gemma4/mtp-gemma-4-12b-it-BF16.gguf", "-ngld", "99", "--spec-type", "draft-mtp", "--spec-draft-n-max", "4"], }, # in-process models: the worker loads them and holds ~5GB resident. `worker` is the port whose # /unload frees that GPU memory on close -- the mutex alone can't reclaim it (leaked across runs, # starving the next model on open). subprocess models (gemma4) free via terminate instead. "siglip2": {"binary": "transformers", "model": "google/siglip2-so400m-patch16-384", "port": None, "worker": 8003}, "dots": {"binary": "transformers", "model": "rednote-hilab/dots.tts-base", "port": None, "worker": 8006}, } SKIP_HEALTH = False # self-check flips this; real runs health-wait the llama-server _lock = threading.Lock() # the single resident session, or None. {model, session_id, opened_at, port, ttl, last_beat, proc} _active = None class OpenReq(BaseModel): model: str ttl: int = 3600 class SessionReq(BaseModel): session_id: str def _health_wait(port: int, timeout: int = 300): deadline = time.time() + timeout while time.time() < deadline: try: if requests.get(f"http://127.0.0.1:{port}/health", timeout=2).status_code == 200: return except requests.RequestException: pass time.sleep(1) raise TimeoutError(f"llama-server on :{port} never became healthy") def _start_subprocess(cfg): """spawn llama-server for a subprocess-type model, return the Popen once healthy.""" cmd = [cfg["binary"], "-m", cfg["model"], "--port", str(cfg["port"]), "-c", str(cfg.get("ctx", 4096)), "-ngl", "99"] if cfg.get("mmproj"): cmd += ["--mmproj", cfg["mmproj"]] cmd += cfg.get("args_extra", []) # keep llama-server's stderr — it prints the assert / CUDA error / context-overflow that kills it. # DEVNULL here is why crashes showed up as "no error in the logs". append so a respawn keeps history. log = open(f"/tmp/llama-server-{cfg['port']}.log", "a") proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT) if not SKIP_HEALTH and cfg["port"]: _health_wait(cfg["port"]) return proc def _teardown(sess): proc = sess.get("proc") if proc: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=15) # wait() even on an already-dead proc, else it stays a zombie except subprocess.TimeoutExpired: proc.kill() # in-process models live in the worker, not here -- ask it to free the GPU (best-effort; # if the worker is down the VRAM is already gone with it). worker = sess.get("worker") if worker: try: requests.post(f"http://127.0.0.1:{worker}/unload", timeout=30) except requests.RequestException: pass @app.post("/session/open") def open_session(req: OpenReq): if req.model not in MODELS: raise HTTPException(400, f"unknown model {req.model}") global _active cfg = MODELS[req.model] session_id = uuid.uuid4().hex with _lock: if _active is not None: raise HTTPException(409, f"{_active['model']} resident (session {_active['session_id']})") # ponytail: claim the slot before the slow spawn/health-wait so /active and /close # for other sessions aren't blocked behind it; only the actual subprocess start runs unlocked. _active = { "model": req.model, "session_id": session_id, "opened_at": time.time(), "port": cfg.get("port"), "ttl": req.ttl, "last_beat": time.time(), "proc": None, "worker": cfg.get("worker"), } proc = _start_subprocess(cfg) if cfg["binary"] != "transformers" else None with _lock: if _active is not None and _active["session_id"] == session_id: _active["proc"] = proc return {"session_id": session_id, "port": cfg.get("port")} @app.post("/session/close") def close_session(req: SessionReq): with _lock: global _active if _active is None or _active["session_id"] != req.session_id: raise HTTPException(404, "no such active session") _teardown(_active) _active = None return {"ok": True} @app.get("/session/active") def active(): with _lock: if _active is None: return None return {k: _active[k] for k in ("model", "session_id", "opened_at", "port")} @app.post("/session/heartbeat") def heartbeat(req: SessionReq): with _lock: if _active is None or _active["session_id"] != req.session_id: raise HTTPException(404, "no such active session") _active["last_beat"] = time.time() return {"ok": True} def _reap_once(): with _lock: global _active if _active and time.time() - _active["last_beat"] > _active["ttl"]: _teardown(_active) _active = None return True return False def _supervise_once(): """Respawn a crashed subprocess model in place. llama-server can OOM/die mid-session; the worker talks to the port directly, so if we don't bring it back every remaining panel of the stage fails with 'connection refused'. Restarting under the SAME session keeps the lease + port valid so the orchestrator never has to re-open. Returns True if a respawn happened.""" with _lock: global _active sess = _active if not sess: return False proc = sess.get("proc") if proc is None or proc.poll() is None: return False # in-process model, or subprocess still alive try: proc.wait(timeout=1) # reap the zombie except subprocess.TimeoutExpired: pass print(f"[supervise] gemma subprocess died (rc={proc.returncode}) session {sess['session_id']}; " f"respawning", flush=True) try: sess["proc"] = _start_subprocess(MODELS[sess["model"]]) sess["last_beat"] = time.time() # don't count the downtime against the TTL reaper return True except Exception as e: print(f"[supervise] respawn failed: {e}; clearing session so the mutex frees", flush=True) _active = None return False def _reaper(): while True: time.sleep(1) if _reap_once(): continue _supervise_once() @app.on_event("startup") def _startup(): threading.Thread(target=_reaper, daemon=True).start() if __name__ == "__main__": # self-check: mutex (409 on second open), close releases, reaper force-closes a stale lease. SKIP_HEALTH = True MODELS["gemma4"] = {"binary": "/bin/sleep", "model": "5", "port": None, "args_extra": []} s1 = open_session(OpenReq(model="gemma4", ttl=3600)) assert active()["model"] == "gemma4" try: open_session(OpenReq(model="siglip2")) assert False, "second open must 409" except HTTPException as e: assert e.status_code == 409 close_session(SessionReq(session_id=s1["session_id"])) assert active() is None s2 = open_session(OpenReq(model="gemma4", ttl=2)) _active["last_beat"] -= 5 # simulate a lease that missed its heartbeat window assert _reap_once() is True assert active() is None # supervisor: a crashed subprocess is respawned in place, keeping the same session_id + port. s4 = open_session(OpenReq(model="gemma4", ttl=3600)) old_proc = _active["proc"] old_proc.terminate(); old_proc.wait() # simulate the llama-server crashing mid-session assert _supervise_once() is True assert active()["session_id"] == s4["session_id"] # lease survived assert _active["proc"] is not old_proc and _active["proc"].poll() is None # fresh live proc close_session(SessionReq(session_id=s4["session_id"])) assert active() is None # in-process model: close must POST /unload to the worker so its resident VRAM is freed. calls = [] requests.post = lambda url, **kw: calls.append(url) or type("R", (), {"status_code": 200})() s3 = open_session(OpenReq(model="siglip2", ttl=3600)) close_session(SessionReq(session_id=s3["session_id"])) assert any("/unload" in u and ":8003" in u for u in calls), calls print("session_manager self-check ok")