Adds end-to-end Linux compatibility work for the dedicated server: new CI workflow (Ubuntu + Arch), line-ending/executable safeguards, and a local venv-first startup flow with split dependency files for server vs optional host GUI tooling. Improves runtime resilience with better bind error messages, stricter config validation, writable-state checks, cleaner socket/thread shutdown behavior, and SIGTERM-aware graceful stop handling for headless/systemd use. Updates deployment/startup docs and adds portability/runtime integration tests to lock in these behaviors.
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
import threading
|
|
from typing import Any
|
|
|
|
|
|
DISCOVERY_PORT = 7778
|
|
PROTOCOL_NAME = "commonwealth-online"
|
|
DEFAULT_SERVER_NAME = "Commonwealth Online Server"
|
|
DEFAULT_MAX_PLAYERS = 16
|
|
|
|
|
|
class LanDiscoveryResponder:
|
|
"""UDP responder so LAN clients can find running Commonwealth Online servers."""
|
|
|
|
def __init__(self, server: Any, discovery_port: int = DISCOVERY_PORT) -> None:
|
|
self._server = server
|
|
self._discovery_port = discovery_port
|
|
self._socket: socket.socket | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._running = False
|
|
self._lock = threading.RLock()
|
|
|
|
def start(self) -> None:
|
|
with self._lock:
|
|
if self._running:
|
|
return
|
|
|
|
discovery_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
|
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
else:
|
|
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
discovery_socket.bind(("0.0.0.0", self._discovery_port))
|
|
discovery_socket.settimeout(0.5)
|
|
except OSError as error:
|
|
discovery_socket.close()
|
|
raise OSError(
|
|
f"Could not bind LAN discovery to 0.0.0.0:{self._discovery_port}. {error}"
|
|
) from error
|
|
|
|
self._socket = discovery_socket
|
|
self._running = True
|
|
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
with self._lock:
|
|
self._running = False
|
|
discovery_socket = self._socket
|
|
self._socket = None
|
|
thread = self._thread
|
|
self._thread = None
|
|
|
|
if discovery_socket is not None:
|
|
try:
|
|
discovery_socket.close()
|
|
except OSError:
|
|
pass
|
|
|
|
if thread is not None and thread is not threading.current_thread():
|
|
thread.join(timeout=1.0)
|
|
|
|
def _listen_loop(self) -> None:
|
|
while True:
|
|
with self._lock:
|
|
if not self._running:
|
|
break
|
|
discovery_socket = self._socket
|
|
|
|
if discovery_socket is None:
|
|
break
|
|
|
|
try:
|
|
data, address = discovery_socket.recvfrom(2048)
|
|
except socket.timeout:
|
|
continue
|
|
except OSError:
|
|
with self._lock:
|
|
if self._running:
|
|
break
|
|
continue
|
|
|
|
response = self._build_response(data)
|
|
if response is None:
|
|
continue
|
|
|
|
self._server._log(
|
|
f"LAN discovery probe from {address[0]}:{address[1]} — "
|
|
f"replying with game port {response['port']}"
|
|
)
|
|
|
|
try:
|
|
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
|
|
discovery_socket.sendto(encoded, address)
|
|
except OSError:
|
|
pass
|
|
|
|
def _build_response(self, data: bytes) -> dict[str, Any] | None:
|
|
try:
|
|
packet = json.loads(data.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return None
|
|
|
|
if not isinstance(packet, dict):
|
|
return None
|
|
if packet.get("type") != "discover":
|
|
return None
|
|
if packet.get("protocol") != PROTOCOL_NAME:
|
|
return None
|
|
|
|
stats = self._server.get_stats()
|
|
connected_clients = int(stats.get("connectedClients", 0))
|
|
game_port = int(stats.get("port", 7777))
|
|
server_name = str(stats.get("serverName") or DEFAULT_SERVER_NAME)
|
|
server_description = str(stats.get("serverDescription") or "")
|
|
max_players = int(stats.get("maxPlayers", DEFAULT_MAX_PLAYERS))
|
|
|
|
response: dict[str, Any] = {
|
|
"type": "discoverResponse",
|
|
"protocol": PROTOCOL_NAME,
|
|
"version": 1,
|
|
"name": server_name,
|
|
"description": server_description,
|
|
"port": game_port,
|
|
"players": connected_clients,
|
|
"maxPlayers": max_players,
|
|
}
|
|
return response
|