"""Subprocess lifecycle helpers for local smoke servers.""" from __future__ import annotations import os import socket import subprocess import time from collections.abc import Iterator from contextlib import contextmanager, suppress from dataclasses import dataclass from pathlib import Path import httpx from .config import SmokeConfig, redacted @dataclass(slots=True) class RunningServer: base_url: str port: int log_path: Path process: subprocess.Popen[bytes] def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) @contextmanager def start_server( config: SmokeConfig, *, env_overrides: dict[str, str] | None = None, command: list[str] | None = None, name: str = "server", ) -> Iterator[RunningServer]: port = find_free_port() config.results_dir.mkdir(parents=True, exist_ok=True) log_path = config.results_dir / f"{name}-{config.worker_id}-{port}.log" env = os.environ.copy() env.update( { "HOST": "127.0.0.1", "PORT": str(port), "LOG_FILE": str(log_path), "MESSAGING_PLATFORM": "none", "PYTHONUNBUFFERED": "1", } ) if env_overrides: env.update(env_overrides) cmd = command or [ "uv", "run", "uvicorn", "server:app", "--host", "127.0.0.1", "--port", str(port), "--timeout-graceful-shutdown", "5", ] with log_path.open("ab") as log_file: process = subprocess.Popen( cmd, cwd=config.root, env=env, stdout=log_file, stderr=subprocess.STDOUT, ) running = RunningServer( base_url=f"http://127.0.0.1:{port}", port=port, log_path=log_path, process=process, ) try: _wait_for_health(running, timeout_s=config.timeout_s) yield running finally: _stop_process(process) def _wait_for_health(server: RunningServer, *, timeout_s: float) -> None: deadline = time.monotonic() + timeout_s last_error = "" while time.monotonic() < deadline: if server.process.poll() is not None: break try: response = httpx.get(f"{server.base_url}/health", timeout=2.0) if response.status_code == 200: return last_error = f"HTTP {response.status_code}: {response.text[:200]}" except Exception as exc: last_error = f"{type(exc).__name__}: {exc}" time.sleep(0.25) log_excerpt = "" with suppress(OSError): log_excerpt = server.log_path.read_text(encoding="utf-8", errors="replace")[ -2000: ] raise AssertionError( "Smoke server did not become healthy. " f"last_error={last_error!r} log={redacted(log_excerpt)!r}" ) def _stop_process(process: subprocess.Popen[bytes]) -> None: if process.poll() is not None: return process.terminate() try: process.wait(timeout=8) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5)