from __future__ import annotations import ctypes import os import sys from dataclasses import dataclass from enum import IntEnum from pathlib import Path from typing import Any from packet_codec import EncodedPacket, MAX_MESSAGE_BYTES, encode_packet from transport_policy import Delivery class GnsTransportError(RuntimeError): pass class EventType(IntEnum): CONNECTED = 1 DISCONNECTED = 2 MESSAGE = 3 OVERSIZE_MESSAGE = 4 class SendResult(IntEnum): ERROR = -1 SENT = 0 DROPPED = 1 BACKPRESSURE = 2 NOT_CONNECTED = 3 TOO_LARGE = 4 _NATIVE_DELIVERY = { Delivery.UNRELIABLE_SEQUENCED: 0, Delivery.RELIABLE_ORDERED: 1, } class _CEvent(ctypes.Structure): _fields_ = [ ("type", ctypes.c_uint32), ("connection_id", ctypes.c_uint32), ("reason", ctypes.c_int32), ("payload_size", ctypes.c_uint32), ("debug", ctypes.c_char * 128), ] @dataclass(frozen=True) class GnsEvent: type: EventType connection_id: int payload: bytes = b"" reason: int = 0 debug: str = "" def _default_library_names() -> tuple[str, ...]: if sys.platform == "win32": return ("commonwealth_online_gns_bridge.dll",) if sys.platform == "darwin": return ("libcommonwealth_online_gns_bridge.dylib",) return ("libcommonwealth_online_gns_bridge.so",) def _candidate_library_paths() -> list[Path]: candidates: list[Path] = [] configured = os.environ.get("COMMONWEALTH_ONLINE_GNS_BRIDGE", "").strip() if configured: candidates.append(Path(configured).expanduser()) root = Path(__file__).resolve().parent for name in _default_library_names(): candidates.extend( ( root / name, root / "native_transport" / name, root.parent / name, ) ) return candidates def find_native_bridge() -> Path: for candidate in _candidate_library_paths(): if candidate.is_file(): return candidate searched = ", ".join(str(path) for path in _candidate_library_paths()) raise GnsTransportError( "Commonwealth Online GNS native bridge was not found. " f"Set COMMONWEALTH_ONLINE_GNS_BRIDGE or install it beside the server. Searched: {searched}" ) class _NativeApi: def __init__(self, library: Any) -> None: self.library = library self.create = library.co_gns_server_create self.destroy = library.co_gns_server_destroy self.local_port = library.co_gns_server_local_port self.connection_count = library.co_gns_server_connection_count self.poll = library.co_gns_server_poll self.send = library.co_gns_server_send self.disconnect = library.co_gns_server_disconnect self.create.argtypes = [ ctypes.c_char_p, ctypes.c_uint16, ctypes.POINTER(ctypes.c_void_p), ctypes.c_char_p, ctypes.c_size_t, ] self.create.restype = ctypes.c_int self.destroy.argtypes = [ctypes.c_void_p] self.destroy.restype = None self.local_port.argtypes = [ctypes.c_void_p] self.local_port.restype = ctypes.c_uint16 self.connection_count.argtypes = [ctypes.c_void_p] self.connection_count.restype = ctypes.c_uint32 self.poll.argtypes = [ ctypes.c_void_p, ctypes.POINTER(_CEvent), ctypes.c_void_p, ctypes.c_uint32, ] self.poll.restype = ctypes.c_int self.send.argtypes = [ ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ] self.send.restype = ctypes.c_int self.disconnect.argtypes = [ ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int32, ctypes.c_char_p, ] self.disconnect.restype = ctypes.c_int class GnsServerTransport: def __init__( self, bind_host: str, port: int, *, library_path: str | os.PathLike[str] | None = None, native_library: Any | None = None, ) -> None: if not isinstance(bind_host, str) or not bind_host: raise ValueError("bind_host must be a non-empty string") if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535: raise ValueError("port must be between 0 and 65535") if native_library is None: path = Path(library_path).expanduser() if library_path is not None else find_native_bridge() try: native_library = ctypes.CDLL(str(path)) except OSError as error: raise GnsTransportError(f"Could not load GNS native bridge at {path}: {error}") from error self._api = _NativeApi(native_library) self._handle = ctypes.c_void_p() error_buffer = ctypes.create_string_buffer(512) created = self._api.create( bind_host.encode("utf-8"), port, ctypes.byref(self._handle), error_buffer, len(error_buffer), ) if created != 1 or not self._handle.value: message = error_buffer.value.decode("utf-8", errors="replace").strip() raise GnsTransportError(message or "GNS native bridge failed to start") self._closed = False self._payload_buffer = ctypes.create_string_buffer(MAX_MESSAGE_BYTES) @property def local_port(self) -> int: self._require_open() return int(self._api.local_port(self._handle)) @property def connection_count(self) -> int: self._require_open() return int(self._api.connection_count(self._handle)) @property def is_closed(self) -> bool: return self._closed def _require_open(self) -> None: if self._closed or not self._handle.value: raise GnsTransportError("GNS transport is closed") def poll(self) -> GnsEvent | None: self._require_open() event = _CEvent() result = int( self._api.poll( self._handle, ctypes.byref(event), self._payload_buffer, MAX_MESSAGE_BYTES, ) ) if result == 0: return None if result < 0: raise GnsTransportError(f"GNS native poll failed with result {result}") try: event_type = EventType(event.type) except ValueError as error: raise GnsTransportError(f"GNS native bridge returned unknown event type {event.type}") from error if event.payload_size > MAX_MESSAGE_BYTES and event_type is not EventType.OVERSIZE_MESSAGE: raise GnsTransportError("GNS native bridge returned an oversized message payload") payload = b"" if event_type is EventType.MESSAGE and event.payload_size: payload = self._payload_buffer.raw[: event.payload_size] debug = bytes(event.debug).split(b"\0", 1)[0].decode("utf-8", errors="replace") return GnsEvent( type=event_type, connection_id=int(event.connection_id), payload=payload, reason=int(event.reason), debug=debug, ) def send_encoded(self, connection_id: int, encoded: EncodedPacket) -> SendResult: self._require_open() if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0: raise ValueError("connection_id must be a positive integer") payload = encoded.payload if len(payload) > MAX_MESSAGE_BYTES: return SendResult.TOO_LARGE buffer = ctypes.create_string_buffer(payload, len(payload)) raw_result = int( self._api.send( self._handle, connection_id, buffer, len(payload), _NATIVE_DELIVERY[encoded.delivery], ) ) try: return SendResult(raw_result) except ValueError as error: raise GnsTransportError(f"GNS native send returned unknown result {raw_result}") from error def send_packet(self, connection_id: int, packet: dict[str, Any]) -> SendResult: return self.send_encoded(connection_id, encode_packet(packet)) def disconnect(self, connection_id: int, *, reason: int = 0, debug: str = "") -> bool: self._require_open() if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0: raise ValueError("connection_id must be a positive integer") if not isinstance(reason, int) or isinstance(reason, bool): raise ValueError("reason must be an integer") clean_debug = str(debug or "")[:127] return bool( self._api.disconnect( self._handle, connection_id, reason, clean_debug.encode("utf-8", errors="replace"), ) ) def close(self) -> None: if self._closed: return self._closed = True if self._handle.value: self._api.destroy(self._handle) self._handle = ctypes.c_void_p() def __enter__(self) -> "GnsServerTransport": self._require_open() return self def __exit__(self, exc_type, exc, traceback) -> None: self.close() def __del__(self) -> None: try: self.close() except Exception: pass