Source code for exlab_wizard.tray.server_runner

"""Programmatic uvicorn launcher with atomic ``server.json`` writes. Backend Spec §4.3.2.

The tray process owns the FastAPI server in-process. :class:`ServerRunner`
encapsulates the lifecycle:

1. Pick a free localhost port from the OS at start time (Backend §15.3.1).
2. Launch ``uvicorn.Server.run`` on a dedicated worker thread so the
   pystray main-thread event loop is unaffected.
3. Atomically write ``<state_dir>/server.json`` with ``{port, pid,
   started_at}`` so :mod:`exlab_wizard.window` (a separate process) can
   discover the live server (Backend §4.2 -- "Window<->server discovery").
4. On stop, signal uvicorn to exit and delete the state file.

The atomic write follows the §4.4.5 idiom (write tmp, fsync, replace) so
a crash during the write never leaves a half-written state file behind
that the window subprocess could try to parse.
"""

from __future__ import annotations

import contextlib
import json
import socket
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any

from exlab_wizard.constants import SERVER_STATE_FILE
from exlab_wizard.io import atomic_write_bytes
from exlab_wizard.logging import get_logger
from exlab_wizard.utils.time import utc_now_iso

if TYPE_CHECKING:
    from fastapi import FastAPI

__all__ = ["SERVER_STATE_FILE", "ServerRunner", "pick_free_port"]

_log = get_logger(__name__)


[docs] def pick_free_port() -> int: """Return a free localhost port from the OS. Binds a SOCK_STREAM socket to ``("127.0.0.1", 0)`` and reads the OS-assigned port back, then closes the socket. Subject to the usual tiny race between close and re-bind by uvicorn -- acceptable in practice; the alternative is leaking the socket which uvicorn cannot accept ownership of. """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1]
[docs] class ServerRunner: """Starts uvicorn on a free localhost port and tracks its state file. Backend Spec §4.3.2 + §15.3.1. """ def __init__(self, *, app: FastAPI, state_dir: Path) -> None: self._app = app self._state_dir = Path(state_dir) self._port: int | None = None # uvicorn.Server is typed as ``Any`` here so call sites can use # ``.run`` / ``.should_exit`` without per-attribute mypy ignores. self._server: Any = None self._thread: threading.Thread | None = None self._state_file: Path = self._state_dir / SERVER_STATE_FILE # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def start(self) -> int: """Pick a port, launch uvicorn in a worker thread, write server.json. Returns the chosen port. Idempotent in the sense that calling :meth:`start` a second time before :meth:`stop` raises ``RuntimeError`` -- the runner manages exactly one server. """ if self._server is not None: msg = "ServerRunner.start called while a server is already running" raise RuntimeError(msg) port = pick_free_port() _config, server = self._build_uvicorn(port) thread = threading.Thread( target=server.run, name="exlab-uvicorn", daemon=True, ) thread.start() self._port = port self._server = server self._thread = thread self._write_state_file(port) _log.info("server started on 127.0.0.1:%d", port) return port
[docs] def stop(self) -> None: """Signal uvicorn to exit, join the worker thread, delete server.json. Idempotent: a second call is a no-op. """ if self._server is None: return # uvicorn.Server has a ``should_exit`` attribute that the run loop # polls; setting it triggers a clean shutdown that runs the # FastAPI lifespan teardown. with contextlib.suppress(AttributeError): self._server.should_exit = True if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=10.0) self._delete_state_file() self._server = None self._thread = None self._port = None _log.info("server stopped")
@property def port(self) -> int: """Return the port the server is bound to. Raises ``RuntimeError`` when called before :meth:`start`. """ if self._port is None: msg = "ServerRunner.port read before start()" raise RuntimeError(msg) return self._port @property def is_running(self) -> bool: """Return ``True`` while the worker thread is alive.""" if self._thread is None: return False return self._thread.is_alive() @property def state_file(self) -> Path: """Return the absolute path of the ``server.json`` state file.""" return self._state_file # ------------------------------------------------------------------ # Internals (split out so tests can monkeypatch around real uvicorn) # ------------------------------------------------------------------ def _build_uvicorn(self, port: int) -> tuple[Any, Any]: """Return ``(uvicorn.Config, uvicorn.Server)`` for the given port. Split out so unit tests can monkeypatch the import without wrestling with the real uvicorn dependency in CI. Typed as ``Any`` so the callers can use ``.run`` / ``.should_exit`` on the returned objects without further mypy noise. """ import uvicorn config = uvicorn.Config( self._app, host="127.0.0.1", port=port, log_config=None, access_log=False, ) server = uvicorn.Server(config) return config, server def _write_state_file(self, port: int) -> None: """Atomically write ``server.json``. Backend Spec §4.4.5 idiom.""" import os self._state_dir.mkdir(parents=True, exist_ok=True) payload = { "port": port, "pid": os.getpid(), "started_at": utc_now_iso(), } data = json.dumps(payload).encode("utf-8") atomic_write_bytes(self._state_file, data) def _delete_state_file(self) -> None: """Best-effort delete; missing file is fine.""" with contextlib.suppress(FileNotFoundError): self._state_file.unlink()