Files
Commonwealth-Online-Server/server/lan_discovery.py
T
andrew 881aa33eef
Linux Compatibility / Ubuntu dedicated server (push) Has been cancelled
Linux Compatibility / Arch Linux container (push) Has been cancelled
Harden Linux server runtime and packaging
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.
2026-08-01 21:39:23 +12:00

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