145 lines
4.4 KiB
Python
145 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from aiohttp import web
|
|
|
|
from app.providers.gsmtc import run_gsmtc_provider
|
|
from app.state import AppState
|
|
from app.webserver import make_app
|
|
|
|
|
|
def _configure_asyncio() -> None:
|
|
"""
|
|
Windows: avoid noisy Proactor transport errors on abrupt socket closes and
|
|
improve compatibility by using the selector event loop policy.
|
|
"""
|
|
if os.name == "nt":
|
|
try:
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _install_loop_exception_handler(loop: asyncio.AbstractEventLoop) -> None:
|
|
def handler(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
|
|
exc = context.get("exception")
|
|
# Ignore common noisy Windows disconnect error (browser/tab closes etc.)
|
|
if isinstance(exc, ConnectionResetError) and getattr(exc, "winerror", None) == 10054:
|
|
return
|
|
_loop.default_exception_handler(context)
|
|
|
|
loop.set_exception_handler(handler)
|
|
|
|
|
|
async def _run_server(host: str, port: int, state: AppState) -> None:
|
|
app = make_app(state)
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, host, port)
|
|
await site.start()
|
|
|
|
# Start providers
|
|
asyncio.create_task(run_gsmtc_provider(state))
|
|
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
|
|
|
|
def run_forever(host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
"""
|
|
Blocking entrypoint for console usage.
|
|
"""
|
|
_configure_asyncio()
|
|
loop = asyncio.new_event_loop()
|
|
_install_loop_exception_handler(loop)
|
|
asyncio.set_event_loop(loop)
|
|
state = AppState()
|
|
try:
|
|
loop.run_until_complete(_run_server(host, port, state))
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
loop.stop()
|
|
loop.close()
|
|
|
|
|
|
class ServerController:
|
|
"""
|
|
Starts/stops the asyncio server on a background thread (for tray UI).
|
|
"""
|
|
|
|
def __init__(self, host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
self.host = host
|
|
self.port = port
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._stop_evt = threading.Event()
|
|
|
|
def is_running(self) -> bool:
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
def start(self) -> None:
|
|
if self.is_running():
|
|
return
|
|
self._stop_evt.clear()
|
|
|
|
def _thread_main() -> None:
|
|
_configure_asyncio()
|
|
loop = asyncio.new_event_loop()
|
|
_install_loop_exception_handler(loop)
|
|
self._loop = loop
|
|
asyncio.set_event_loop(loop)
|
|
|
|
state = AppState()
|
|
|
|
async def runner() -> None:
|
|
app = make_app(state)
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, self.host, self.port)
|
|
await site.start()
|
|
provider_task = asyncio.create_task(run_gsmtc_provider(state))
|
|
|
|
try:
|
|
while not self._stop_evt.is_set():
|
|
await asyncio.sleep(0.2)
|
|
finally:
|
|
provider_task.cancel()
|
|
# CancelledError may derive from BaseException depending on Python version;
|
|
# suppress it so Stop doesn't spam a traceback.
|
|
with contextlib.suppress(BaseException):
|
|
await provider_task
|
|
await runner.cleanup()
|
|
|
|
import contextlib
|
|
|
|
try:
|
|
loop.run_until_complete(runner())
|
|
except BaseException:
|
|
# Avoid noisy background-thread tracebacks on intentional shutdown.
|
|
pass
|
|
finally:
|
|
try:
|
|
loop.stop()
|
|
finally:
|
|
loop.close()
|
|
|
|
self._thread = threading.Thread(target=_thread_main, name="WidgetServer", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
if not self.is_running():
|
|
return
|
|
self._stop_evt.set()
|
|
if self._thread:
|
|
# Wait for cleanup so the port is released before a subsequent start().
|
|
self._thread.join()
|
|
self._thread = None
|
|
self._loop = None
|
|
|
|
|