Code source de fletchtime.runtime
"""Encapsulates the start/stop lifecycle of both servers (HTTP static +
WebSocket) as one logical unit. Used by both the plain CLI entry point
(``fletchtime.__main__``) and the graphical one (``fletchtime.gui``), so
neither duplicates this logic nor risks drifting out of sync with the
other.
The WebSocket server needs its own asyncio event loop; since a GUI
mainloop (tkinter/customtkinter) must own the *main* thread, that loop
runs in a dedicated background thread here -- exactly the same shape as
the HTTP server, which already ran in a background thread even before
this module existed.
"""
from __future__ import annotations
import asyncio
import threading
from http.server import ThreadingHTTPServer
from fletchtime.server.http_static import start_http_server
from fletchtime.server.match_server import MatchServer
from fletchtime.server.ws_server import run_ws_server
[docs]
class ServerRuntime:
"""Not thread-safe for concurrent ``start()``/``stop()`` calls from
multiple threads at once -- callers (CLI, GUI) are expected to only
ever call these from a single "control" thread (e.g. the GUI's main
thread reacting to button clicks), which is the normal case."""
def __init__(self, app_web_dir: str, assets_dir: str, http_port: int, ws_port: int) -> None:
self.app_web_dir = app_web_dir
self.assets_dir = assets_dir
self.http_port = http_port
self.ws_port = ws_port
# Créé une seule fois ici (pas dans run_ws_server, qui en
# construirait un nouveau à chaque start()) -- partagé avec le
# serveur HTTP (voir start_http_server, paramètre match_server)
# pour exposer l'état du match en lecture via /api/status, sans
# dupliquer d'état entre les deux serveurs.
self.match_server = MatchServer()
self._httpd: ThreadingHTTPServer | None = None
self._http_thread: threading.Thread | None = None
self._ws_thread: threading.Thread | None = None
self._ws_loop: asyncio.AbstractEventLoop | None = None
self._ws_stop_event: asyncio.Event | None = None
self._ws_ready = threading.Event()
@property
def is_running(self) -> bool:
return self._http_thread is not None and self._http_thread.is_alive()
[docs]
def start(self) -> None:
"""No-op if already running -- safe to call defensively (e.g. a
GUI's "Démarrer" button handler doesn't need to separately check
state first)."""
if self.is_running:
return
self._httpd = start_http_server(
self.app_web_dir, self.http_port, self.assets_dir, self.ws_port, self.match_server
)
self._http_thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
self._http_thread.start()
self._ws_ready.clear()
self._ws_thread = threading.Thread(target=self._run_ws_loop, daemon=True)
self._ws_thread.start()
# Attend que la boucle asyncio du thread WS soit prête avant de
# rendre la main -- évite une fenêtre où stop() pourrait être
# appelé avant que self._ws_loop/_ws_stop_event n'existent.
self._ws_ready.wait(timeout=5)
def _run_ws_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._ws_loop = loop
self._ws_stop_event = asyncio.Event()
self._ws_ready.set()
try:
loop.run_until_complete(
run_ws_server(self.ws_port, self._ws_stop_event, self.match_server)
)
finally:
loop.close()
[docs]
def stop(self, timeout: float = 5.0) -> None:
"""No-op if not running. Blocks (briefly) until both server
threads have actually terminated, so a caller can safely assume
the ports are free again once this returns -- important for a
GUI's "Démarrer" button right after "Arrêter": binding to the
same port again would otherwise race with the still-shutting-down
previous server."""
if self._httpd is not None:
self._httpd.shutdown() # débloque serve_forever() -- sûr depuis un autre thread
self._httpd.server_close()
self._httpd = None
if self._ws_loop is not None and self._ws_stop_event is not None:
self._ws_loop.call_soon_threadsafe(self._ws_stop_event.set)
if self._http_thread is not None:
self._http_thread.join(timeout=timeout)
self._http_thread = None
if self._ws_thread is not None:
self._ws_thread.join(timeout=timeout)
self._ws_thread = None
self._ws_loop = None
self._ws_stop_event = None
def restart(self) -> None:
self.stop()
self.start()