Sync from GitHub main #1
@@ -0,0 +1,293 @@
|
||||
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
|
||||
Reference in New Issue
Block a user