352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""Server lifecycle: probe, filelock, sentinel, atexit cleanup.
|
|
|
|
Pattern: probe `/health` → if alive return handle → else acquire lock, re-probe,
|
|
spawn detached, write sentinel, register atexit kill (only the spawner cleans up).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
import httpx
|
|
|
|
from surya.logging import get_logger
|
|
from surya.settings import settings
|
|
|
|
logger = get_logger()
|
|
|
|
|
|
def _cache_dir() -> Path:
|
|
base = Path(os.path.expanduser("~/.cache/datalab/surya"))
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
return base
|
|
|
|
|
|
def _sentinel_path(backend: str) -> Path:
|
|
return _cache_dir() / f"{backend}_server.json"
|
|
|
|
|
|
def _lock_path(backend: str) -> Path:
|
|
return _cache_dir() / f"{backend}_server.lock"
|
|
|
|
|
|
def find_free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def probe_health(base_url: str, timeout: float = 1.0) -> bool:
|
|
"""Returns True if the server reports healthy at /health."""
|
|
try:
|
|
# llama.cpp returns 200 on /health when ready; vllm returns 200 on /health too.
|
|
with httpx.Client(timeout=timeout) as client:
|
|
r = client.get(f"{base_url}/health")
|
|
return r.status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def wait_for_health(
|
|
base_url: str, total_timeout: float = 300.0, interval: float = 1.0
|
|
) -> bool:
|
|
deadline = time.time() + total_timeout
|
|
while time.time() < deadline:
|
|
if probe_health(base_url):
|
|
return True
|
|
time.sleep(interval)
|
|
return False
|
|
|
|
|
|
def probe_model_id(openai_base: str, timeout: float = 5.0) -> Optional[str]:
|
|
"""Returns the model id reported by the running server, or None on failure."""
|
|
try:
|
|
with httpx.Client(timeout=timeout) as client:
|
|
r = client.get(f"{openai_base}/models")
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
models = data.get("data") or []
|
|
if models:
|
|
return models[0].get("id")
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class SpawnedServer:
|
|
base_url: str # full openai base, e.g. "http://127.0.0.1:8765/v1"
|
|
health_url: str # base for /health, e.g. "http://127.0.0.1:8765"
|
|
model_name: str # what to pass as `model`
|
|
pid: Optional[int]
|
|
backend: str
|
|
spawned_by_us: bool
|
|
|
|
|
|
class SpawnError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _read_sentinel(backend: str) -> Optional[dict]:
|
|
p = _sentinel_path(backend)
|
|
if not p.exists():
|
|
return None
|
|
try:
|
|
return json.loads(p.read_text())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _write_sentinel(backend: str, data: dict) -> None:
|
|
_sentinel_path(backend).write_text(json.dumps(data))
|
|
|
|
|
|
def _delete_sentinel(backend: str) -> None:
|
|
p = _sentinel_path(backend)
|
|
if p.exists():
|
|
try:
|
|
p.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _stop_process(pid: int, name: str) -> None:
|
|
try:
|
|
# Graceful first
|
|
os.kill(pid, 15) # SIGTERM
|
|
for _ in range(20):
|
|
try:
|
|
os.kill(pid, 0) # still alive?
|
|
except ProcessLookupError:
|
|
logger.info(f"Stopped {name} (pid {pid})")
|
|
return
|
|
time.sleep(0.5)
|
|
# Hard
|
|
os.kill(pid, 9)
|
|
logger.warning(f"Force-killed {name} (pid {pid})")
|
|
except ProcessLookupError:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"Failed to stop {name} (pid {pid}): {e}")
|
|
|
|
|
|
def _capture_server_logs(handle: "SpawnHandle", tail: int = 100) -> str:
|
|
"""Best-effort tail of a server's logs, for surfacing startup failures."""
|
|
try:
|
|
if handle.cleanup_kind == "docker":
|
|
r = subprocess.run(
|
|
["docker", "logs", "--tail", str(tail), handle.cleanup_id],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
return (r.stdout or "") + (r.stderr or "") or "(no docker logs)"
|
|
# llama.cpp process backend logs to this file (see llamacpp.py)
|
|
log_path = Path("~/.cache/datalab/surya/llamacpp_server.log").expanduser()
|
|
if log_path.exists():
|
|
lines = log_path.read_text(errors="replace").splitlines()
|
|
return "\n".join(lines[-tail:]) or "(empty log)"
|
|
except Exception as e:
|
|
return f"(could not capture logs: {e})"
|
|
return "(no logs available)"
|
|
|
|
|
|
def _stop_docker_container(name: str) -> None:
|
|
try:
|
|
subprocess.run(
|
|
["docker", "stop", name], check=False, capture_output=True, timeout=30
|
|
)
|
|
logger.info(f"Stopped docker container {name}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to stop docker container {name}: {e}")
|
|
|
|
|
|
def attach_or_spawn(
|
|
backend: str,
|
|
expected_model_name: str,
|
|
spawn_fn: Callable[[int], "SpawnHandle"],
|
|
health_url_for: Callable[[int], str],
|
|
openai_url_for: Callable[[int], str],
|
|
startup_timeout: float = 600.0,
|
|
) -> SpawnedServer:
|
|
"""Generic attach-or-spawn with file lock and sentinel.
|
|
|
|
`spawn_fn(port)` must launch the server detached and return a SpawnHandle
|
|
with `pid` (int or None for docker) and a `cleanup_id` (e.g. container name).
|
|
"""
|
|
# 0. If user pinned an external URL, attach without lock
|
|
if settings.SURYA_INFERENCE_URL:
|
|
base_url = settings.SURYA_INFERENCE_URL.rstrip("/")
|
|
health_url = base_url[: -len("/v1")] if base_url.endswith("/v1") else base_url
|
|
if not probe_health(health_url):
|
|
raise SpawnError(
|
|
f"SURYA_INFERENCE_URL={base_url} is not reachable at /health. "
|
|
"Start the server or unset the variable."
|
|
)
|
|
model_name = probe_model_id(base_url) or expected_model_name
|
|
if model_name != expected_model_name:
|
|
raise SpawnError(
|
|
f"Model mismatch at {base_url}: expected {expected_model_name!r}, got {model_name!r}. "
|
|
"Stop the running server or unset SURYA_INFERENCE_URL."
|
|
)
|
|
return SpawnedServer(
|
|
base_url=base_url,
|
|
health_url=health_url,
|
|
model_name=model_name,
|
|
pid=None,
|
|
backend=backend,
|
|
spawned_by_us=False,
|
|
)
|
|
|
|
# 1. Probe sentinel without lock
|
|
existing = _read_sentinel(backend)
|
|
if existing:
|
|
port = existing.get("port")
|
|
pid = existing.get("pid")
|
|
if port and probe_health(health_url_for(port)):
|
|
running_model = probe_model_id(openai_url_for(port)) or expected_model_name
|
|
if running_model != expected_model_name:
|
|
raise SpawnError(
|
|
f"Existing {backend} server on port {port} serves {running_model!r}, "
|
|
f"expected {expected_model_name!r}. Stop it before continuing."
|
|
)
|
|
logger.info(f"Attaching to existing {backend} server on port {port}")
|
|
return SpawnedServer(
|
|
base_url=openai_url_for(port),
|
|
health_url=health_url_for(port),
|
|
model_name=running_model,
|
|
pid=pid,
|
|
backend=backend,
|
|
spawned_by_us=False,
|
|
)
|
|
else:
|
|
_delete_sentinel(backend)
|
|
|
|
if not settings.SURYA_INFERENCE_AUTOSTART:
|
|
raise SpawnError(
|
|
f"No running {backend} server and SURYA_INFERENCE_AUTOSTART is False. "
|
|
"Set the variable to True or start the server manually."
|
|
)
|
|
|
|
# 2. Acquire filelock to prevent races
|
|
try:
|
|
from filelock import FileLock
|
|
except ImportError as e:
|
|
raise SpawnError(
|
|
"filelock is required for server spawn. pip install filelock"
|
|
) from e
|
|
|
|
lock = FileLock(str(_lock_path(backend)), timeout=120)
|
|
with lock:
|
|
# Re-check sentinel inside the lock
|
|
existing = _read_sentinel(backend)
|
|
if existing:
|
|
port = existing.get("port")
|
|
if port and probe_health(health_url_for(port)):
|
|
running_model = (
|
|
probe_model_id(openai_url_for(port)) or expected_model_name
|
|
)
|
|
if running_model != expected_model_name:
|
|
raise SpawnError(
|
|
f"Existing {backend} server on port {port} serves {running_model!r}, "
|
|
f"expected {expected_model_name!r}."
|
|
)
|
|
return SpawnedServer(
|
|
base_url=openai_url_for(port),
|
|
health_url=health_url_for(port),
|
|
model_name=running_model,
|
|
pid=existing.get("pid"),
|
|
backend=backend,
|
|
spawned_by_us=False,
|
|
)
|
|
|
|
# 3. Spawn fresh
|
|
port = settings.SURYA_INFERENCE_PORT or find_free_port()
|
|
logger.info(f"Spawning {backend} server on port {port}")
|
|
spawn_handle = spawn_fn(port)
|
|
|
|
# 4. Write sentinel
|
|
_write_sentinel(
|
|
backend,
|
|
{
|
|
"port": port,
|
|
"pid": spawn_handle.pid,
|
|
"model": expected_model_name,
|
|
"backend": backend,
|
|
"cleanup_id": spawn_handle.cleanup_id,
|
|
"cleanup_kind": spawn_handle.cleanup_kind,
|
|
},
|
|
)
|
|
|
|
# 5. Register atexit cleanup (only spawner). Skipped when keep-alive is
|
|
# set so the server outlives this process and later commands attach to
|
|
# it via the sentinel. (_cleanup is still callable below on startup
|
|
# failure, where we always tear a half-started server down.)
|
|
def _cleanup():
|
|
try:
|
|
if spawn_handle.cleanup_kind == "docker":
|
|
_stop_docker_container(spawn_handle.cleanup_id)
|
|
elif spawn_handle.cleanup_kind == "process":
|
|
if spawn_handle.pid:
|
|
_stop_process(spawn_handle.pid, backend)
|
|
finally:
|
|
_delete_sentinel(backend)
|
|
|
|
if settings.SURYA_INFERENCE_KEEP_ALIVE:
|
|
logger.info(
|
|
f"keep-alive: {backend} server on port {port} will stay up "
|
|
f"after exit (cleanup_id={spawn_handle.cleanup_id!r})"
|
|
)
|
|
else:
|
|
atexit.register(_cleanup)
|
|
|
|
# 6. Wait for health
|
|
health_url = health_url_for(port)
|
|
if not wait_for_health(health_url, total_timeout=startup_timeout):
|
|
# Grab the server's own logs *before* cleanup tears the (--rm)
|
|
# container down, otherwise the actual failure reason is lost and
|
|
# all the caller sees is this timeout.
|
|
logs = _capture_server_logs(spawn_handle)
|
|
_cleanup()
|
|
raise SpawnError(
|
|
f"{backend} server failed to become healthy at {health_url} "
|
|
f"within {startup_timeout}s.\n"
|
|
f"--- last {backend} server logs ---\n{logs}"
|
|
)
|
|
|
|
# 7. Verify model name
|
|
running_model = probe_model_id(openai_url_for(port))
|
|
if running_model and running_model != expected_model_name:
|
|
logger.warning(
|
|
f"{backend} server reports model={running_model!r} "
|
|
f"but expected {expected_model_name!r}; using reported name."
|
|
)
|
|
expected_model_name = running_model
|
|
|
|
logger.info(
|
|
f"{backend} server ready on port {port} (model={expected_model_name})"
|
|
)
|
|
return SpawnedServer(
|
|
base_url=openai_url_for(port),
|
|
health_url=health_url,
|
|
model_name=expected_model_name,
|
|
pid=spawn_handle.pid,
|
|
backend=backend,
|
|
spawned_by_us=True,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SpawnHandle:
|
|
pid: Optional[int]
|
|
cleanup_id: str # container name for docker, str(pid) for process
|
|
cleanup_kind: str # "docker" | "process"
|