Files
wehub-resource-sync 925e56bb5f
Unit tests / build (t4_gpu) (push) Has been cancelled
Unit tests / build (ubuntu-latest) (push) Has been cancelled
Unit tests / build (windows-latest) (push) Has been cancelled
Test CLI scripts / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:56 +08:00

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"