Implemented handshake and heartbeat

Implemented handshake in json server and handoff to endpoint

Endpoint handles connector handshake accept/decline then launches both
its IO threads alongside its heartbeat thread which contains simple
timeout logic, timeout calls connection handles to close, subsequently
killing the endpoint
This commit is contained in:
2026-08-04 22:11:53 +01:00
parent 98814b122c
commit f3b1a537f0
17 changed files with 1030 additions and 96 deletions
+43 -2
View File
@@ -2,6 +2,7 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import threading
from typing import Literal
@@ -14,6 +15,10 @@ from dynalab_core.protocols.common import VersionDescriptor
from dynalab_core.protocols.json.server import JsonServer
log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())
class Core:
def __init__(self, config: CoreConfig) -> None:
self._state: Literal["uninitd", "initd", "started", "stopping", "stopped"] = (
@@ -22,24 +27,60 @@ class Core:
self._core_version: VersionDescriptor = CORE_VERSION
self._stop_event: threading.Event = threading.Event()
self._core_config: CoreConfig = config
self._json_server = JsonServer(self._core_config)
self._connector_registry = ConnectorRegistry()
self._json_server = JsonServer(self._core_config, self._connector_registry)
self._state = "initd"
def start(self) -> None:
if self._state != "initd":
log.warning(
"Core start rejected in state %s",
self._state,
extra={
"event": "core.start_rejected",
"core_state": self._state,
"expected_state": "initd",
},
)
raise CoreStateMismatchError(
f'Unable to start DynaLab Core, expected state to be "initd", found {self._state}'
)
log.info(
"Core %s starting on %s",
self._core_version.get_version(),
self._core_config.bind_str(),
extra={
"event": "core.starting",
"core_version": self._core_version.get_version(),
"host": self._core_config.host,
"port": self._core_config.port,
},
)
self._json_server.start()
self._state = "started"
log.info(
"Core started",
extra={"event": "core.started", "core_state": self._state},
)
def wait(self, timeout: float | None = None) -> None:
self._stop_event.wait(timeout)
try:
self._stop_event.wait(timeout)
except KeyboardInterrupt:
raise
def stop(self) -> None:
log.info(
"Core stopping",
extra={"event": "core.stopping", "core_state": self._state},
)
self._json_server.stop()
self._connector_registry.stop()
self._stop_event.set()
self._state = "stopped"
log.info(
"Core stopped",
extra={"event": "core.stopped", "core_state": self._state},
)
+11
View File
@@ -2,7 +2,18 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
from uuid import uuid4
from dynalab_core.protocols.common import VersionDescriptor
from dynalab_core.protocols.constants import PROTOCOL_VERSION
from dynalab_core.protocols.packets.handshake import DynaLabHello
CORE_VERSION = VersionDescriptor(type="alpha", major=0, minor=0, patch=1)
HELLO_PACKET = DynaLabHello(
instance_id=uuid4(),
core_version=CORE_VERSION,
protocol_version=PROTOCOL_VERSION,
heartbeat_interval_ms=1000,
heartbeat_timeout_ms=5000,
)
+300 -47
View File
@@ -2,36 +2,106 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from queue import Empty, Full, Queue
from threading import RLock, Thread
import threading
from threading import RLock, Thread
from time import monotonic, sleep
from uuid import UUID
from dynalab_core.constants import HELLO_PACKET
from dynalab_core.protocols.errors import (
ConnectorEndpointQueueFullError,
ConnectorRegistryAlreadyRegisteredError,
)
from dynalab_core.protocols.packets import ProtocolMessage
from dynalab_core.protocols.packets.handshake import ConnectorHello
from dynalab_core.protocols.packets.handshake import (
ConnectorHello,
HandshakeAccepted,
HandshakeRejected,
)
from dynalab_core.protocols.packets.heartbeat import Heartbeat
log = logging.getLogger(__name__)
class ConnectorEndpoint:
def __init__(self, hello: ConnectorHello) -> None:
def __init__(
self,
hello: ConnectorHello,
timeout_event: threading.Event | None = None,
) -> None:
# external IO queues
self._packet_ingress_queue: Queue[ProtocolMessage] = Queue(524288)
self._packet_egress_queue: Queue[ProtocolMessage] = Queue(524288)
# internal IO queues
self._heartbeat_ingress_queue: Queue[ProtocolMessage] = Queue(524288)
self._heartbeat_egress_queue: Queue[ProtocolMessage] = Queue(524288)
self._connector_hello = hello
self._worker_thread = Thread(
target=self._worker,
name=f"endpoint_worker_{self._connector_hello.connector_uuid}",
self._timed_out_event = timeout_event or threading.Event()
self._stop_event = threading.Event()
self._input_worker_stopped_event = threading.Event()
self._input_worker_thread = Thread(
target=self._input_worker,
name=f"endpoint_input_worker_{self._connector_hello.connector_uuid}",
daemon=True,
)
self._stop_event = threading.Event()
self._stopped_event = threading.Event()
self._output_worker_stopped_event = threading.Event()
self._output_worker_thread = Thread(
target=self._output_worker,
name=f"endpoint_output_worker_{self._connector_hello.connector_uuid}",
daemon=True,
)
self._heartbeat_worker_stopped_event = threading.Event()
self._heartbeat_worker_thread = Thread(
target=self._heartbeat_worker,
name=f"endpoint_heartbeat_worker_{self._connector_hello.connector_uuid}",
daemon=True,
)
self._input_worker_thread.start()
self._output_worker_thread.start()
def stop(self) -> None:
connector_uuid = str(self.uuid())
log.debug(
"Stopping connector endpoint %s",
connector_uuid,
extra={"event": "endpoint.stopping", "connector_uuid": connector_uuid},
)
self._stop_event.set()
if not self._worker_thread.is_alive():
self._stopped_event.set()
self._stopped_event.wait(10)
if not self._input_worker_thread.is_alive():
self._input_worker_stopped_event.set()
if not self._output_worker_thread.is_alive():
self._output_worker_stopped_event.set()
if not self._heartbeat_worker_thread.is_alive():
self._heartbeat_worker_stopped_event.set()
self._input_worker_stopped_event.wait(1)
self._output_worker_stopped_event.wait(1)
self._heartbeat_worker_stopped_event.wait(1)
if (
not self._input_worker_stopped_event.is_set()
or not self._output_worker_stopped_event.is_set()
or not self._heartbeat_worker_stopped_event.is_set()
):
log.error(
"Connector endpoint %s did not stop within 10 seconds",
connector_uuid,
extra={
"event": "endpoint.stop_timeout",
"connector_uuid": connector_uuid,
"timeout_s": 10,
},
)
else:
log.debug(
"Connector endpoint %s stopped",
connector_uuid,
extra={"event": "endpoint.stopped", "connector_uuid": connector_uuid},
)
def uuid(self) -> UUID:
return self._connector_hello.connector_uuid
@@ -40,49 +110,163 @@ class ConnectorEndpoint:
try:
self._packet_ingress_queue.put_nowait(packet)
except Full:
self._log_queue_full("ingress", self._packet_ingress_queue)
raise ConnectorEndpointQueueFullError
def get_egress_packet(self, timeout: float | None) -> ProtocolMessage | None:
try:
packet = self._packet_egress_queue.get(block=True, timeout=timeout)
except Empty:
return None
else:
return packet
def get_egress_packet(self, timeout: float | None) -> ProtocolMessage:
return self._packet_egress_queue.get(block=True, timeout=timeout)
def get_egress_packet_no_wait(self) -> ProtocolMessage | None:
try:
packet = self._packet_egress_queue.get_nowait()
except Empty:
return None
else:
return packet
def get_egress_packet_no_wait(self) -> ProtocolMessage:
return self._packet_egress_queue.get_nowait()
def _get_ingress_packet(self, timeout: float | None) -> ProtocolMessage | None:
try:
packet = self._packet_ingress_queue.get(block=True, timeout=timeout)
except Empty:
return None
else:
return packet
def _get_ingress_packet(self, timeout: float | None) -> ProtocolMessage:
return self._packet_ingress_queue.get(block=True, timeout=timeout)
def _get_ingress_packet_no_wait(self) -> ProtocolMessage | None:
try:
packet = self._packet_ingress_queue.get_nowait()
except Empty:
return None
else:
return packet
def _get_ingress_packet_no_wait(self) -> ProtocolMessage:
return self._packet_ingress_queue.get_nowait()
def _put_egress_packet(self, packet: ProtocolMessage) -> None:
try:
self._packet_egress_queue.put_nowait(packet)
except Full:
self._log_queue_full("egress", self._packet_egress_queue)
raise ConnectorEndpointQueueFullError
def _worker(self) -> None:
# TODO: build endpoint worker thread
pass
def _log_queue_full(self, direction: str, queue: Queue[ProtocolMessage]) -> None:
connector_uuid = str(self.uuid())
log.warning(
"Connector %s %s queue is full (%d/%d)",
connector_uuid,
direction,
queue.qsize(),
queue.maxsize,
extra={
"event": "endpoint.queue_full",
"connector_uuid": connector_uuid,
"queue_direction": direction,
"queue_size": queue.qsize(),
"queue_capacity": queue.maxsize,
},
)
def _heartbeat_worker(self) -> None:
last_send: int = 0
last_recieved: int = 0
first_received: bool = False
ctr: int = 0
try:
while not self._stop_event.is_set():
now = round(monotonic() * 1000)
if now > last_send + HELLO_PACKET.heartbeat_interval_ms:
try:
self._heartbeat_egress_queue.put_nowait(
Heartbeat(sequence=ctr, send_timestamp=now)
)
last_send = now
ctr += 1
log.debug("Sent heartbeat")
except Full:
sleep(0.01)
try:
received = self._heartbeat_ingress_queue.get_nowait()
except Empty:
sleep(0.01)
else:
if isinstance(received, Heartbeat):
first_received = True
if received.return_timestamp is not None:
last_recieved = received.return_timestamp
if not first_received:
if ctr > round(
HELLO_PACKET.heartbeat_timeout_ms
/ HELLO_PACKET.heartbeat_interval_ms
):
self._timed_out_event.set()
return
elif last_send > last_recieved + HELLO_PACKET.heartbeat_timeout_ms:
self._timed_out_event.set()
return
finally:
self._heartbeat_worker_stopped_event.set()
def _input_worker(self) -> None:
connector_uuid = str(self.uuid())
log.debug(
"Connector endpoint input worker %s started",
self._input_worker_thread.name,
extra={
"event": "endpoint.input_worker_started",
"connector_uuid": connector_uuid,
"thread_name": self._input_worker_thread.name,
},
)
while not self._stop_event.is_set():
try:
message = self._get_ingress_packet_no_wait()
except Empty:
self._stop_event.wait(0.01)
continue
else:
if isinstance(message, Heartbeat):
self._heartbeat_ingress_queue.put_nowait(message)
self._input_worker_stopped_event.set()
log.debug(
"Connector endpoint input worker %s stopped",
self._input_worker_thread.name,
extra={
"event": "endpoint.input_worker_stopped",
"connector_uuid": connector_uuid,
"thread_name": self._input_worker_thread.name,
},
)
def _output_worker(self) -> None:
connector_uuid = str(self.uuid())
log.debug(
"Connector endpoint output worker %s started",
self._input_worker_thread.name,
extra={
"event": "endpoint.output_worker_started",
"connector_uuid": connector_uuid,
"thread_name": self._input_worker_thread.name,
},
)
if self._connector_hello.protocol_version == HELLO_PACKET.protocol_version:
self._put_egress_packet(HandshakeAccepted(accepted_signals=[]))
log.debug(f"Accepted handshake for endpoint {self.uuid()}")
else:
self._put_egress_packet(
HandshakeRejected(reason="Protocol versions mismatch")
)
log.debug(f"Rejected handshake for endpoint {self.uuid()}")
self._heartbeat_worker_thread.start()
while not self._stop_event.is_set():
messages: list[ProtocolMessage] = []
try:
messages.append(self._heartbeat_egress_queue.get_nowait())
except Empty:
self._stop_event.wait(0.01)
pass
for message in messages:
self._put_egress_packet(message)
self._output_worker_stopped_event.set()
log.debug(
"Connector endpoint output worker %s stopped",
self._input_worker_thread.name,
extra={
"event": "endpoint.output_worker_stopped",
"connector_uuid": connector_uuid,
"thread_name": self._input_worker_thread.name,
},
)
class ConnectorRegistry:
@@ -90,30 +274,99 @@ class ConnectorRegistry:
self._endpoints: dict[UUID, ConnectorEndpoint] = {}
self._lock = RLock()
def register(self, hello: ConnectorHello) -> None:
endpoint = ConnectorEndpoint(hello)
def register(
self, hello: ConnectorHello, timeout_event: threading.Event
) -> ConnectorEndpoint:
endpoint = ConnectorEndpoint(hello, timeout_event)
with self._lock:
current = self._endpoints.get(hello.connector_uuid)
if current is not None:
log.warning(
"Connector %s is already registered",
hello.connector_uuid,
extra={
"event": "connector.registration_rejected",
"connector_uuid": str(hello.connector_uuid),
"reason": "duplicate_uuid",
},
)
raise ConnectorRegistryAlreadyRegisteredError
self._endpoints[hello.connector_uuid] = endpoint
endpoint_count = len(self._endpoints)
log.info(
"Registered connector %s (%s)",
hello.connector_name,
hello.connector_uuid,
extra={
"event": "connector.registered",
"connector_uuid": str(hello.connector_uuid),
"connector_name": hello.connector_name,
"connector_version": hello.connector_version,
"protocol_version": hello.protocol_version.get_version(),
"endpoint_count": endpoint_count,
},
)
return endpoint
def unregister(self, endpoint: ConnectorEndpoint) -> None:
connector_uuid = endpoint.uuid()
with self._lock:
current = self._endpoints.get(endpoint.uuid())
current = self._endpoints.get(connector_uuid)
if current is endpoint:
del self._endpoints[endpoint.uuid()]
del self._endpoints[connector_uuid]
endpoint_count = len(self._endpoints)
else:
log.debug(
"Connector %s was not registered",
connector_uuid,
extra={
"event": "connector.unregister_noop",
"connector_uuid": str(connector_uuid),
},
)
return
endpoint.stop()
log.info(
"Unregistered connector %s",
connector_uuid,
extra={
"event": "connector.unregistered",
"connector_uuid": str(connector_uuid),
"endpoint_count": endpoint_count,
},
)
def get(self, connector_uuid: UUID) -> ConnectorEndpoint | None:
with self._lock:
return self._endpoints.get(connector_uuid)
def stop(self) -> None:
for endpoint in self._endpoints.values():
with self._lock:
endpoints = tuple(self._endpoints.values())
self._endpoints.clear()
log.debug(
"Stopping connector registry with %d endpoint(s)",
len(endpoints),
extra={
"event": "connector_registry.stopping",
"endpoint_count": len(endpoints),
},
)
for endpoint in endpoints:
endpoint.stop()
log.debug(
"Connector registry stopped",
extra={
"event": "connector_registry.stopped",
"endpoint_count": 0,
},
)
@@ -17,3 +17,10 @@ class JsonServerStartupError(JsonServerError):
def __init__(self, error: Exception) -> None:
self.error = error
super().__init__(f"JSON server failed to start: {error}")
class JsonServerValueError(JsonServerError):
"""JsonServer incorrect value detected"""
def __init__(self, message: str) -> None:
super().__init__(str)
+331 -16
View File
@@ -4,21 +4,31 @@
import asyncio
from asyncio import Server
import logging
from queue import Empty
import threading
from threading import Thread
from time import monotonic
from uuid import uuid4
from dynalab_core.config import CoreConfig
from dynalab_core.constants import HELLO_PACKET
from dynalab_core.protocols.endpoint import ConnectorEndpoint, ConnectorRegistry
from dynalab_core.protocols.errors import ConnectorRegistryAlreadyRegisteredError
from dynalab_core.protocols.json.errors import (
JsonServerStartupError,
JsonServerTimeoutError,
)
from dynalab_core.protocols.json.wire import read_message, write_message
from dynalab_core.protocols.packets.handshake import ConnectorHello
log = logging.getLogger(__name__)
class JsonServer:
def __init__(
self,
config: CoreConfig,
self, config: CoreConfig, connector_registry: ConnectorRegistry
) -> None:
self._thread: Thread | None = None
self._server: Server | None = None
@@ -29,38 +39,102 @@ class JsonServer:
self._timeout: float = 10
self._stop_event = threading.Event()
self._stopped_event = threading.Event()
self._connector_registry = connector_registry
self._handler_tasks: set[asyncio.Task[None]] = set()
def start(self) -> None:
log.info(
"Starting JSON server on %s",
self._config.bind_str(),
extra={
"event": "json_server.starting",
"host": self._config.host,
"port": self._config.port,
"timeout_s": self._timeout,
},
)
self._thread = Thread(
target=self._json_server_thread_main, name="json_server_thread", daemon=True
)
self._thread.start()
res = self._started_event.wait(self._timeout)
print(f"result: {res}")
if not res:
log.error(
"JSON server did not start within %.1f seconds",
self._timeout,
extra={
"event": "json_server.start_timeout",
"host": self._config.host,
"port": self._config.port,
"timeout_s": self._timeout,
},
)
raise JsonServerTimeoutError
if self._startup_error is not None:
raise JsonServerStartupError(self._startup_error)
bound_addresses = [
str(sock.getsockname()) for sock in (self._server.sockets or [])
]
log.info(
"JSON server listening on %s",
", ".join(bound_addresses),
extra={
"event": "json_server.started",
"bound_addresses": bound_addresses,
},
)
def stop(self) -> None:
log.info("Stopping JSON server", extra={"event": "json_server.stopping"})
self._stop_event.set()
self._stopped_event.wait(10)
if not self._stopped_event.wait(10):
log.error(
"JSON server did not stop within 10 seconds",
extra={"event": "json_server.stop_timeout", "timeout_s": 10},
)
else:
log.info("JSON server stopped", extra={"event": "json_server.stopped"})
def _json_server_thread_main(self) -> None:
asyncio.run(self._run_json_server())
log.debug(
"JSON server thread started",
extra={
"event": "json_server.thread_started",
"thread_name": threading.current_thread().name,
},
)
try:
asyncio.run(self._run_json_server())
finally:
log.debug(
"JSON server thread stopped",
extra={
"event": "json_server.thread_stopped",
"thread_name": threading.current_thread().name,
},
)
self._stopped_event.set()
async def _run_json_server(self) -> None:
try:
self._server = await asyncio.start_server(
self._handle_json_connection,
self._start_connection_handler,
host=self._config.host,
port=self._config.port,
)
except OSError as error:
print(
f"Failed to start ingress server on {self._config.bind_str()}: {error}"
log.exception(
"Failed to start JSON server on %s",
self._config.bind_str(),
extra={
"event": "json_server.start_failed",
"host": self._config.host,
"port": self._config.port,
"exception_type": type(error).__name__,
},
)
self._startup_error = error
if not self._debug_timeout_test:
@@ -69,21 +143,262 @@ class JsonServer:
if not self._debug_timeout_test:
self._started_event.set()
if self._startup_error is not None:
self._stopped_event.set()
return
async with self._server:
await asyncio.to_thread(self._stop_event.wait)
self._stopped_event.set()
await self._wait_for_thread_event(self._stop_event)
tasks = tuple(self._handler_tasks)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
log.debug("All handlers stopped")
async def _wait_for_thread_event(self, event: threading.Event) -> None:
while not event.is_set():
await asyncio.sleep(0.01)
async def _input_task(
self,
reader: asyncio.StreamReader,
endpoint: ConnectorEndpoint,
) -> None:
log.debug(f"Started input task for {endpoint.uuid()}")
while not self._stop_event.is_set():
message = await read_message(reader)
endpoint.put_ingress_packet(message)
async def _output_task(
self,
writer: asyncio.StreamWriter,
endpoint: ConnectorEndpoint,
) -> None:
log.debug(f"Started output task for {endpoint.uuid()}")
while not self._stop_event.is_set():
try:
message = endpoint.get_egress_packet_no_wait()
except Empty:
await asyncio.sleep(0.01)
continue
await write_message(writer, message)
def _start_connection_handler(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
task = asyncio.create_task(self._handle_json_connection(reader, writer))
self._handler_tasks.add(task)
task.add_done_callback(self._handler_finished)
def _handler_finished(self, task: asyncio.Task[None]) -> None:
self._handler_tasks.discard(task)
if task.cancelled():
return
error = task.exception()
if error is not None:
log.error(
"Connection handler terminated with an exception",
exc_info=(type(error), error, error.__traceback__),
)
async def _handle_json_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
print(f"Connected to: {writer.get_extra_info('peername')}")
connection_id = uuid4().hex[:12]
peer_address = str(writer.get_extra_info("peername"))
started_at = monotonic()
reason = "handshake_incomplete"
connector_endpoint: ConnectorEndpoint | None = None
connection_tasks: list[asyncio.Task[None]] = []
context = {
"connection_id": connection_id,
"peer_address": peer_address,
}
log.debug(
"Accepted connection %s from %s",
connection_id,
peer_address,
extra={"event": "json_connection.accepted", **context},
)
# TODO: Build out handler to handshake, register peer and pipe packets into connector queues
try:
await reader.readline()
try:
await asyncio.wait_for(write_message(writer, HELLO_PACKET), 5.0)
except TimeoutError:
reason = "hello_write_timeout"
log.debug(
"Connection %s timed out while sending server hello",
connection_id,
extra={
"event": "connector.handshake_timed_out",
"phase": "write_server_hello",
"timeout_s": 5.0,
**context,
},
)
return
try:
message = await asyncio.wait_for(read_message(reader), timeout=5.0)
except TimeoutError:
reason = "connector_hello_timeout"
log.debug(
"Connection %s timed out waiting for connector hello",
connection_id,
extra={
"event": "connector.handshake_timed_out",
"phase": "read_connector_hello",
"timeout_s": 5.0,
**context,
},
)
return
if not isinstance(message, ConnectorHello):
reason = "unexpected_message"
log.warning(
"Connection %s sent %s instead of connector_hello",
connection_id,
message.type,
extra={
"event": "connector.handshake_rejected",
"reason": reason,
"message_type": message.type,
**context,
},
)
return
connector_hello = message
timeout_event = threading.Event()
connector_endpoint = self._connector_registry.register(
connector_hello, timeout_event
)
reason = "handler_completed"
log.info(
"Connector %s connected from %s",
connector_hello.connector_uuid,
peer_address,
extra={
"event": "connector.connected",
"connector_uuid": str(connector_hello.connector_uuid),
"connector_name": connector_hello.connector_name,
"connector_version": connector_hello.connector_version,
**context,
},
)
input_task = asyncio.create_task(
self._input_task(reader, connector_endpoint)
)
output_task = asyncio.create_task(
self._output_task(writer, connector_endpoint)
)
server_stop_task = asyncio.create_task(
self._wait_for_thread_event(self._stop_event)
)
timeout_task = asyncio.create_task(
self._wait_for_thread_event(timeout_event)
)
connection_tasks = [
input_task,
output_task,
server_stop_task,
timeout_task,
]
done, _ = await asyncio.wait(
connection_tasks, return_when=asyncio.FIRST_COMPLETED
)
if timeout_task in done:
reason = "endpoint_timeout"
elif server_stop_task in done:
reason = "server_shutdown"
for task in done:
if task is input_task or task is output_task:
task.result()
except asyncio.CancelledError:
reason = "server_shutdown"
log.debug(
"Connection %s cancelled during server shutdown",
connection_id,
extra={"event": "json_connection.cancelled", **context},
)
raise
except ConnectionError as error:
reason = "peer_disconnected"
log.debug(
"Connection %s disconnected: %s",
connection_id,
error,
extra={
"event": "connector.handshake_disconnected",
"exception_type": type(error).__name__,
**context,
},
)
except ValueError as error:
reason = "invalid_frame"
log.warning(
"Connection %s sent an invalid handshake frame: %s",
connection_id,
error,
extra={
"event": "connector.handshake_rejected",
"reason": reason,
**context,
},
)
except ConnectorRegistryAlreadyRegisteredError:
reason = "duplicate_connector"
log.debug(
"Connection %s rejected because its connector is already registered",
connection_id,
extra={
"event": "json_connection.rejected",
"reason": reason,
**context,
},
)
except Exception:
reason = "handler_failed"
log.exception(
"Connection %s handler failed",
connection_id,
extra={"event": "json_connection.failed", **context},
)
raise
finally:
for task in connection_tasks:
task.cancel()
if connection_tasks:
await asyncio.gather(*connection_tasks, return_exceptions=True)
writer.close()
await writer.wait_closed()
print("Disconnected")
try:
await writer.wait_closed()
except ConnectionError:
pass
if connector_endpoint is not None:
await asyncio.to_thread(
self._connector_registry.unregister, connector_endpoint
)
duration_ms = round((monotonic() - started_at) * 1000)
log.debug(
"Closed connection %s after %d ms (%s)",
connection_id,
duration_ms,
reason,
extra={
"event": "json_connection.closed",
"reason": reason,
"duration_ms": duration_ms,
**context,
},
)
+32
View File
@@ -0,0 +1,32 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: MIT
from asyncio import StreamReader, StreamWriter
from pydantic import TypeAdapter
from dynalab_core.protocols.packets import ProtocolMessage
_MESSAGE_ADAPTER = TypeAdapter(ProtocolMessage)
async def write_message(writer: StreamWriter, message: ProtocolMessage) -> None:
writer.write(message.model_dump_json().encode("utf-8") + b"\n")
await writer.drain()
async def read_message(
reader: StreamReader, *, max_frame_bytes: int = 65_536
) -> ProtocolMessage:
line = await reader.readline()
if not line:
raise ConnectionError("Peer disconnected")
if len(line) > max_frame_bytes:
raise ValueError("Incoming protocol frame exceeds maximum size")
if not line.endswith(b"\n"):
raise ValueError("Protocol frame is missing newline delimiter")
return _MESSAGE_ADAPTER.validate_json(line)
@@ -8,9 +8,10 @@ from dynalab_core.protocols.packets.handshake import (
HandshakeAccepted,
HandshakeRejected,
)
from dynalab_core.protocols.packets.heartbeat import Heartbeat
ProtocolMessage = Annotated[
DynaLabHello | ConnectorHello | HandshakeAccepted | HandshakeRejected,
DynaLabHello | ConnectorHello | HandshakeAccepted | HandshakeRejected | Heartbeat,
Field(discriminator="type"),
]
@@ -7,7 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, Field
from dynalab_core.protocols.common import VersionDescriptor
from dynalab_core.protocols.json.constants import PROTOCOL_VERSION
from dynalab_core.protocols.constants import PROTOCOL_VERSION
class DynaLabHello(BaseModel):
@@ -0,0 +1,14 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Literal
from pydantic import BaseModel
class Heartbeat(BaseModel):
type: Literal["heartbeat"] = "heartbeat"
sequence: int
send_timestamp: int
return_timestamp: int | None = None