from __future__ import annotations import asyncio from threading import Thread from fastapi import FastAPI from uvicorn import Config, Server class UvicornThreadRunner: def __init__(self, host: str, port: int, timeout: int, *, thread_name: str = "plba-http-control") -> None: self._host = host self._port = port self._timeout = timeout self._thread_name = thread_name self._server: Server | None = None self._thread: Thread | None = None self._error: BaseException | None = None async def start(self, app: FastAPI) -> None: if self._thread is not None and self._thread.is_alive(): return self._error = None config = Config(app=app, host=self._host, port=self._port, log_level="warning") self._server = Server(config) self._thread = Thread(target=self._serve, name=self._thread_name, daemon=True) self._thread.start() await self._wait_until_started() async def stop(self) -> None: if self._server is None or self._thread is None: return self._server.should_exit = True await asyncio.to_thread(self._thread.join, self._timeout) self._server = None self._thread = None @property def port(self) -> int: if self._server is None or not getattr(self._server, "servers", None): return self._port socket = self._server.servers[0].sockets[0] return int(socket.getsockname()[1]) async def _wait_until_started(self) -> None: if self._server is None: raise RuntimeError("Server is not initialized") deadline = asyncio.get_running_loop().time() + max(float(self._timeout), 1.0) while not self._server.started: if self._error is not None: raise RuntimeError("HTTP control server failed to start") from self._error if asyncio.get_running_loop().time() >= deadline: raise TimeoutError("HTTP control server startup timed out") await asyncio.sleep(0.05) def _serve(self) -> None: if self._server is None: return try: asyncio.run(self._server.serve()) except BaseException as exc: # noqa: BLE001 self._error = exc