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
+15 -4
View File
@@ -1,17 +1,28 @@
from time import sleep
import logging
from rich.logging import RichHandler
from dynalab_core import Core
from dynalab_core.config import CoreConfig
logging.basicConfig(
level=logging.DEBUG,
format="%(name)s %(message)s",
datefmt="%H:%M:%S",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
force=True,
)
log = logging.getLogger(__name__)
config = CoreConfig(port=8765)
print(config)
log.info("Configured manual core on %s", config.bind_str())
dl_core = Core(config)
dl_core.start()
print("started")
try:
while True:
dl_core.wait(1)
print("waiting")
except KeyboardInterrupt:
log.info("Received keyboard interrupt")
dl_core.stop()
+52 -3
View File
@@ -1,4 +1,25 @@
import asyncio
import logging
from uuid import uuid4
from dynalab_core.protocols.packets import ProtocolMessage
from rich.logging import RichHandler
from dynalab_core.protocols.common import VersionDescriptor
from dynalab_core.protocols.constants import PROTOCOL_VERSION
from dynalab_core.protocols.json.wire import read_message, write_message
from dynalab_core.protocols.packets.handshake import ConnectorHello, DynaLabHello
CONNECTOR_VERSION = VersionDescriptor(type="alpha", major=0, minor=0, patch=1)
logging.basicConfig(
level=logging.DEBUG,
format="%(name)s %(message)s",
datefmt="%H:%M:%S",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
force=True,
)
log = logging.getLogger(__name__)
async def main() -> None:
@@ -7,14 +28,42 @@ async def main() -> None:
port=8765,
)
print("Connected")
log.info("Connected to DynaLab core")
try:
await asyncio.sleep(10)
try:
message = await asyncio.wait_for(read_message(reader), timeout=30.0)
except TimeoutError:
log.warning("Timed out waiting for server hello")
return
if not isinstance(message, DynaLabHello):
log.warning("Expected server hello, received %s", message.type)
return
dynalab_hello = message
log.debug("Received hello from core instance %s", dynalab_hello.instance_id)
connector_hello = ConnectorHello(
connector_uuid=uuid4(),
protocol_version=PROTOCOL_VERSION,
connector_name="Test connector",
connector_version=CONNECTOR_VERSION.get_version(),
)
try:
await asyncio.wait_for(write_message(writer, connector_hello), timeout=5.0)
except TimeoutError:
log.warning("Timed out sending connector hello")
return
log.info("Connector hello sent for %s", connector_hello.connector_uuid)
try:
while True:
message = await read_message(reader)
log.info(message)
except KeyboardInterrupt:
return
finally:
writer.close()
await writer.wait_closed()
print("Disconnected")
log.info("Disconnected from DynaLab core")
asyncio.run(main())
+23 -7
View File
@@ -1,3 +1,5 @@
import logging
import pytest
from dynalab_core import Core
from dynalab_core.config import CoreConfig
@@ -5,14 +7,28 @@ from dynalab_core.errors import CoreStateMismatchError
from test.common import find_available_port
def test_core_cannot_start_twice() -> None:
def test_core_cannot_start_twice(caplog: pytest.LogCaptureFixture) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
core.start()
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with pytest.raises(CoreStateMismatchError):
core.start()
finally:
core.stop()
try:
with pytest.raises(CoreStateMismatchError):
core.start()
finally:
core.stop()
events = [getattr(record, "event", None) for record in caplog.records]
assert events.count("core.starting") == 1
assert "core.start_rejected" in events
assert "core.stopped" in events
rejection = next(
record
for record in caplog.records
if getattr(record, "event", None) == "core.start_rejected"
)
assert rejection.levelno == logging.WARNING
assert rejection.core_state == "started"
+41
View File
@@ -0,0 +1,41 @@
import logging
from queue import Queue
from uuid import uuid4
import pytest
from dynalab_core.protocols.constants import PROTOCOL_VERSION
from dynalab_core.protocols.endpoint import ConnectorEndpoint
from dynalab_core.protocols.errors import ConnectorEndpointQueueFullError
from dynalab_core.protocols.packets import ProtocolMessage
from dynalab_core.protocols.packets.handshake import ConnectorHello
def test_full_endpoint_queue_is_logged(caplog: pytest.LogCaptureFixture) -> None:
hello = ConnectorHello(
connector_uuid=uuid4(),
protocol_version=PROTOCOL_VERSION,
connector_name="Test connector",
connector_version="0.1.0-test",
)
endpoint = ConnectorEndpoint(hello)
endpoint._packet_ingress_queue = Queue[ProtocolMessage](maxsize=1)
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
try:
endpoint.put_ingress_packet(hello)
with pytest.raises(ConnectorEndpointQueueFullError):
endpoint.put_ingress_packet(hello)
finally:
endpoint.stop()
queue_record = next(
record
for record in caplog.records
if getattr(record, "event", None) == "endpoint.queue_full"
)
assert queue_record.levelno == logging.WARNING
assert queue_record.connector_uuid == str(hello.connector_uuid)
assert queue_record.queue_direction == "ingress"
assert queue_record.queue_size == 1
assert queue_record.queue_capacity == 1
+118 -14
View File
@@ -1,37 +1,141 @@
import logging
import socket
import time
from uuid import uuid4
import pytest
from dynalab_core import Core
from dynalab_core.config import CoreConfig
from dynalab_core.protocols.constants import PROTOCOL_VERSION
from dynalab_core.protocols.json.errors import (
JsonServerStartupError,
JsonServerTimeoutError,
)
from dynalab_core.protocols.packets.handshake import ConnectorHello
from test.common import find_available_port
def test_json_server_raises_timeout_error() -> None:
def _wait_for_event(
caplog: pytest.LogCaptureFixture, event: str, timeout: float = 1.0
) -> logging.LogRecord:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for record in caplog.records:
if getattr(record, "event", None) == event:
return record
time.sleep(0.01)
raise AssertionError(f"Log event {event!r} was not emitted")
def test_json_server_raises_timeout_error(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
core._json_server._debug_timeout_test = True
core._json_server._timeout = 0.01
try:
with pytest.raises(JsonServerTimeoutError):
core.start()
finally:
core.stop()
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
try:
with pytest.raises(JsonServerTimeoutError):
core.start()
finally:
core.stop()
timeout_record = next(
record
for record in caplog.records
if getattr(record, "event", None) == "json_server.start_timeout"
)
assert timeout_record.levelno == logging.ERROR
assert timeout_record.port == port
def test_json_server_raises_startup_error() -> None:
def test_json_server_raises_startup_error(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core1 = Core(CoreConfig(port=port))
core2 = Core(CoreConfig(port=port))
core1.start()
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core1.start()
try:
with pytest.raises(JsonServerStartupError):
core2.start()
finally:
core1.stop()
core2.stop()
try:
with pytest.raises(JsonServerStartupError):
core2.start()
finally:
core1.stop()
core2.stop()
failure_record = next(
record
for record in caplog.records
if getattr(record, "event", None) == "json_server.start_failed"
)
assert failure_record.levelno == logging.ERROR
assert failure_record.port == port
assert failure_record.exc_info is not None
def test_json_server_logs_invalid_handshake(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with socket.create_connection(("127.0.0.1", port), timeout=1) as peer:
server_hello = peer.makefile("rb").readline()
assert server_hello
peer.sendall(b"not-json\n")
rejection = _wait_for_event(
caplog, "connector.handshake_rejected"
)
finally:
core.stop()
assert rejection.levelno == logging.WARNING
assert rejection.reason == "invalid_frame"
assert rejection.connection_id
assert rejection.peer_address
def test_json_server_waits_for_connection_handlers_on_stop(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
connector_uuid = uuid4()
connector_hello = ConnectorHello(
connector_uuid=connector_uuid,
protocol_version=PROTOCOL_VERSION,
connector_name="Test connector",
connector_version="0.1.0-test",
)
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with socket.create_connection(("127.0.0.1", port), timeout=1) as peer:
peer_file = peer.makefile("rb")
assert peer_file.readline()
peer.sendall(
connector_hello.model_dump_json().encode("utf-8") + b"\n"
)
assert b'"type":"handshake_accepted"' in peer_file.readline()
core.stop()
finally:
if core._state != "stopped":
core.stop()
events = [getattr(record, "event", None) for record in caplog.records]
assert "json_server.stop_timeout" not in events
assert core._json_server._stopped_event.is_set()
assert not core._json_server._handler_tasks
assert core._connector_registry.get(connector_uuid) is None