Initial json server implementation

Completed the initial implementation of the JsonServer class, handling
the worker thread that runs the async task, as well as the connection
handler callback

The current handler awaits readline to cleanly exit when the connection
is closed, no data ingress is happening yet

Next steps include gating the start procedure to wait for everything to
start proprerly in order to avoid having start exit early and async
tasks crashing later
This commit is contained in:
2026-08-03 16:06:28 +01:00
parent 85d25e8381
commit 321ccb178e
9 changed files with 190 additions and 1 deletions
+89
View File
@@ -2,4 +2,93 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import asyncio
from asyncio import Server
import threading
from threading import Thread
from typing import Literal
from dynalab_core.config import CoreConfig
from dynalab_core.constants import CORE_VERSION
from dynalab_core.errors import StateError
from dynalab_core.protocols.json.common import VersionDescriptor
class JsonServer:
_thread: Thread | None = None
_server: Server | None = None
_config: CoreConfig
_stop_event: threading.Event
def __init__(
self,
config: CoreConfig,
stop_event: threading.Event,
) -> None:
self._config = config
self._stop_event = stop_event
def start(self) -> None:
self._thread = Thread(
target=self._json_server_thread_main, name="json_server_thread", daemon=True
)
self._thread.start()
def _json_server_thread_main(self) -> None:
asyncio.run(self._run_json_server())
async def _run_json_server(self) -> None:
try:
self._server = await asyncio.start_server(
self._handle_json_connection,
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}"
)
async with self._server:
await asyncio.to_thread(self._stop_event.wait)
async def _handle_json_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
print(f"Connected to: {writer.get_extra_info('peername')}")
try:
await reader.readline()
finally:
writer.close()
await writer.wait_closed()
print("Disconnected")
class Core:
_state: Literal["uninitd", "initd", "started", "stopping", "stopped"] = "unintid"
_core_version: VersionDescriptor = CORE_VERSION
_core_config: CoreConfig = CoreConfig()
_stop_event: threading.Event = threading.Event()
_json_server: JsonServer
def __init__(self, config: CoreConfig) -> None:
self._core_config = config
self._state = "initd"
self._json_server = JsonServer(self._core_config, self._stop_event)
pass
def start(self) -> None:
if self._state != "initd":
raise StateError(
f'Unable to start DynaLab Core, expected state to be "initd", found {self._state}'
)
self._json_server.start()
self._state = "started"
def wait(self, timeout: float | None = None) -> None:
self._stop_event.wait(timeout)
def stop(self) -> None:
self._stop_event.set()
self._state = "stopped"
+13
View File
@@ -0,0 +1,13 @@
# 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 pydantic import BaseModel, Field
class CoreConfig(BaseModel):
host: str = "127.0.0.1"
port: int = Field(default=58763, ge=0, le=65535)
def bind_str(self) -> str:
return f"{self.host}:{self.port}"
+8
View File
@@ -0,0 +1,8 @@
# 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 dynalab_core.protocols.json.common import VersionDescriptor
CORE_VERSION = VersionDescriptor(type="alpha", major=0, minor=0, patch=1)
+7
View File
@@ -0,0 +1,7 @@
# 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
class StateError(Exception):
"""DynaLab Core state error."""
@@ -0,0 +1,3 @@
# 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
+18
View File
@@ -0,0 +1,18 @@
# 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 uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class VersionDescriptor(BaseModel):
model_config = ConfigDict(frozen=True, populate_by_name=True, extra="forbid")
type: Literal["alpha", "beta", "release"] = Field(alias="type")
major: int = Field(ge=0)
minor: int = Field(ge=0)
patch: int = Field(ge=0)
def get_version(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}-{self.type}"
@@ -0,0 +1,7 @@
# 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 dynalab_core.protocols.json.common import VersionDescriptor
PROTOCOL_VERSION = VersionDescriptor(type="alpha", major=0, minor=0, patch=1)
@@ -0,0 +1,42 @@
# 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 uuid import UUID
from pydantic import BaseModel, Field
from dynalab_core.protocols.json.common import VersionDescriptor
from dynalab_core.protocols.json.constants import PROTOCOL_VERSION
class DynaLabHello(BaseModel):
type: Literal["dynalab_hello"] = "dynalab_hello"
instance_id: UUID
core_version: VersionDescriptor
protocol_version: VersionDescriptor = PROTOCOL_VERSION
heartbeat_interval_ms: int = Field(default_factory=1000, ge=0)
heartbeat_timeout_ms: int = Field(default_factory=5000, ge=0)
class ConnectorHello(BaseModel):
type: Literal["connector_hello"] = "connector_hello"
connector_uuid: UUID
protocol_version: VersionDescriptor = PROTOCOL_VERSION
connector_name: str
connector_version: str
# TODO: implement SignalDescriptor
# signals: list[SignalDescriptor]
class HandshakeAccepted(BaseModel):
type: Literal["handshake_accepted"] = "handshake_accepted"
accepted_signals: list[UUID]
class HandshakeRejected(BaseModel):
type: Literal["handshake_rejected"] = "handshake_rejected"
reason: str