Phase 1: IPC-Verbindungsschicht + Build-first-Strategie (ADR-0008)

- ADR-0008: Build-first, gesammelte Windows-Validierung (Auftraggeber-Freigabe)
- ADR-0004 vorläufig: Rust auf GStreamer-D3D11-Elementpfad
- ADR-0005 vorläufig: Nuitka Onefolder
- hms_protocol: IpcServer/IpcClient mit Handshake (hello/welcome),
  Capabilities-Austausch, Heartbeat 500 ms bidirektional, Snapshot/Delta,
  Loopback-only-Enforcement, Re-Sync nach Reconnect, Version-Mismatch-Fehler
- 9 Integrationstests: echter TCP-Loopback, kein Mock
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 00:50:03 +02:00
parent b0599d8b86
commit 9536e08748
11 changed files with 753 additions and 27 deletions
+56 -19
View File
@@ -1,40 +1,77 @@
# STATUS
Stand: 2026-09-10
Stand: 2026-09-11
## Aktuelle Phase
**Phase 0 technischer Spike und Go/No-Go** (PLAN.md §31, §36)
**Phase 1 Fundament und Netzwerkbasis** unter der **Build-first-Strategie
(ADR-0008)**: Die Phasen 15 werden plattformneutral vollständig gebaut;
Gate 0 und alle renderer-nahen Abnahmen werden gesammelt in einem späteren
Windows-Durchlauf validiert. Auftraggeber-Freigabe für die Reihenfolgeabweichung
vom 2026-09-11, dokumentiert in ADR-0008 gemäß PLAN.md §1.
## Letzter grüner Commit
- 0922cc1 Phase-0-Grundlage: 121 Unit-/Integrationstests grün, Ruff grün (Belege: `TEST_REPORT.md`)
- siehe `git log` und `TEST_REPORT.md` jede Etappe endet mit grünem pytest
und Ruff und wird sofort nach Forgejo gepusht.
## Bestandene Gates
- keine; **Gate 0 ist offen**
- keine; **Gate 0 bleibt offen** bis zum Windows-Hardware-Durchlauf
(Testplan in ADR-0008)
## Arbeitsweise (ADR-0008)
- Build-first: plattformneutral fertig bauen, Hardware-Validierung gesammelt
am Ende (Windows-Durchlauf).
- Kein Gate wird als grün gemeldet, solange der Windows-Durchlauf offen ist.
- Renderer-Nähe ausschließlich über den Backend-Vertrag (§12.6); Korrekturen
bleiben lokal im Adapter.
## Laufende Arbeit
Erster Arbeitsauftrag (§36 Nr. 13 erledigt, Nr. 415 in Arbeit):
Phase 0 (abgeschlossen, soweit ohne Hardware möglich):
- [x] Repository gemäß Eigentumsgrenzen initialisiert (§8)
- [x] Pflichtdokumente + ADR-Vorlage + ADRs 00010003 angelegt
- [x] GStreamer-Version für Windows gepinnt: **1.28.6** (`build/windows/GSTREAMER.md`, ADR-0002)
- [x] Kernpakete mit Unit-Tests: IPC-Protokoll (§6.2), Parameter-Engine (§11), Art-Net-Pakete/Empfänger (§16), Adaptive Quality (§5.2), Capability-Probe (§5.2), Plugin-SDK-Validierung (§14.5, §27.2)
- [x] Minimaler Control Core: FastAPI-REST + WebSocket für denselben Parametersatz (§36 Nr. 9)
- [x] Renderer-Spike: D3D11-/Dev-GL-Pipeline-Definitionen + CLI (§36 Nr. 45); ohne GStreamer-Installation kontrollierter Abbruch (Exit-Code 2), keine Erfolgssimulation (§33)
- [x] Beispielplugin Passthrough (HLSL + GLSL + GLES) als SDK-Referenz (§36 Nr. 6)
- [x] Tools: Art-Net-Emulator, Capability-Probe, Plugin/Shader-Validator
- [x] Repository, Pflichtdokumente, ADRs 00010003
- [x] GStreamer-Pin 1.28.6, Kernpakete, Control Core, Renderer-Spike
- [x] Beispielplugins (Passthrough, Gaussian Blur mit 3 AQ-Varianten), Tools
- [x] 121 Unit-/Integrationstests grün, Ruff grün (Belege: TEST_REPORT.md)
Phase 1 (im Bau):
- [x] IPC-Verbindung Control Core ↔ Renderer: Handshake (hello/welcome),
vollständiger Snapshot nach Verbindung, Deltas mit monotoner Revision,
Command-Acks idempotent über message_id, Heartbeat 500 ms in beide
Richtungen, Re-Sync nach Reconnect, ausschließlich Loopback (§6.2)
- [ ] SQLite-Persistenz (WAL, Migrationen, Backup, §24)
- [ ] Launcher/Supervisor (Prozessstart, Heartbeat-Überwachung, kontrolliertes
Beenden, Crash-Erkennung)
- [ ] Persistente Node-Identität + Rollen in der App-Verkabelung
- [ ] mDNS/DNS-SD-Discovery + manuelle Node-Liste (§6.3)
- [ ] Paarung (PIN/Fingerprint) und Node-Vertrauen
- [ ] Cluster-Nachrichtenhülle mit Sequenzen/Revisionen (§6.5)
## Nächste drei Aufgaben
1. **Gate-0-Messungen auf Referenz-Windows-Hardware:** D3D11-Hardwaredecode, durchgängiger `D3D11Memory`-Pfad ohne CPU-Readback, Framezeit p99, DMX-Latenz (≤ 2 Frames), ruckelfreier Adaptive-Quality-Wechsel (§25, §36 Nr. 11)
2. Portable Onefolder-Ausgabe erzeugen und auf sauberem Windows-Rechner testen (Nuitka vs. PyInstaller → ADR; §36 Nr. 1213)
3. Nativen Renderkern festlegen (Rust vs. C++, Bridge vs. GStreamer-Plugin → ADR-0004) und Renderer über IPC-Handshake an die Parameter-Engine anbinden (Phase 1)
1. SQLite-Persistenz mit WAL, transaktionalem Autosave und Migrationstests (§24)
2. Launcher/Supervisor mit Heartbeat-Überwachung und kontrolliertem Beenden (§6.1A)
3. Discovery (mDNS) plus manuelle Fallback-Liste und Paarungsgrundlage (§6.3)
## Ausstehende Hardware-Validierung (ADR-0008 Testplan)
Nur auf echtem Windows mit GPU messbar; nichts davon gilt als erledigt:
- D3D11-Hardwaredecode aktiv; D3D11Memory durchgängig ohne CPU-Rundweg
- Framezeit p50/p95/p99; DMX → sichtbarer Frame p95 ≤ 2 Frames
- Adaptive-Quality-Wechsel ohne Stall; Golden Images; Shader-Compile auf GPU
- Portable Onefolder-Ausgabe auf sauberem Rechner (§29.6)
- Rust-Renderkern-Kompilierung und GStreamer-Plugin-Bindung (ADR-0004)
## Bekannte Blocker
- **Keine Windows-Referenzhardware in der Entwicklungsumgebung** (Linux-Container, CPU-only). Alle Gate-0-Kriterien sind ausschließlich auf echter Hardware gültig (§29.7, §33). Spike-Code ist bereit; Messungen und Portabilitätstest stehen aus.
- pnpm/Node-Frontend noch nicht eingerichtet (Phase 5, ADR-0006 offen).
- HLSL-Live-Parameter im D3D11-Pfad erfordert den nativen Renderkern (ADR-0004 offen); GLSL-Testvariante für den Dev-Pfad liegt bei.
- Windows-Referenzhardware fehlt in der Entwicklungsumgebung
(Linux-Container, CPU-only) Kernblocker, durch ADR-0008 gemanagt.
- Rust-Toolchain (cargo) fehlt im Container: Rust-Quellcode wird entwickelt,
das Kompilierungsgate erfolgt im Windows-Durchlauf.
- pnpm fehlt im Container: wird für Phase 5 über Node-Corepack aktiviert
(kein Installationsblocker).
@@ -0,0 +1,47 @@
# ADR-0004: Nativer Renderkern Rust auf dem GStreamer-D3D11-Elementpfad
- **Status:** Vorläufig angenommen (Bestätigung oder Revision im Windows-Durchlauf)
- **Datum:** 2026-09-11
- **Phase:** ursprünglich Phase 0; wegen ADR-0008 vorgezogen
- **Bauplan:** §6.1C, §7.1, §12.6
## Entscheidung
1. Sprache des nativen Moduls: **Rust**.
2. Einbindung: **GStreamer-Plugin-Ansatz** der Rendergraph nutzt die
erprobten D3D11-Elemente (d3d11h264dec → d3d11convert → d3d11compositor →
d3d11videosink) und eigene Rust-GStreamer-Elemente für Effekt-/Shader-Pässe,
statt sofort eine komplett eigenständige Render-Bridge mit eigener
Swapchain zu bauen.
## Begründung (ohne Messdaten, deshalb vorläufig)
- D3D11Memory-Residenz ist Eigenschaft der GStreamer-Elemente; das Risiko
eigengebauten Swapchain-Compositings entfällt.
- gstreamer-rs bietet stabile Bindings; Rust liefert Gedächtnissicherheit im
nativen Pfad.
- Die Effektkette skaliert als Elementkette; der Plugin-Vertrag (§14) bleibt
vollständig gewahrt.
- Eine spätere wgpu-/eigenständige Bridge bleibt über dasselbe IPC- und
Capability-Protokoll anschließbar (§6.1C).
## Alternativen
- C++: kein Vorteil, höheres Fehlerrisiko im Speichermanagement.
- Sofortige eigenständige Bridge: mehr Kontrolle, aber hohes Risiko ohne
Hardware-Feedback (ADR-0008).
## Folgen
- Der Renderer-Prozess orchestriert Pipelines; Rust-Elemente übernehmen die
Shader-Pässe (HLSL aus dem Plugin-Vertrag).
- Rust-Kompilierung erfolgt im Windows-Durchlauf (Container ohne cargo).
## Messwerte / Nachweise
- Ausstehend. Gate-0-Messungen bestätigen das Elementpfad-Budget oder lösen
eine Revision (eigenständige D3D11-Bridge) aus ohne API-Änderung (§12.6).
## Freigabe
Vorläufig gemäß ADR-0008; endgültig nach Windows-Messung.
@@ -0,0 +1,33 @@
# ADR-0005: Packaging Nuitka Onefolder (vorläufig)
- **Status:** Vorläufig angenommen (Vergleich im Windows-Durchlauf)
- **Datum:** 2026-09-11
- **Phase:** ursprünglich Phase 0; wegen ADR-0008 vorgezogen
- **Bauplan:** §7.1, §30.2
## Entscheidung
**Nuitka Standalone/Onefolder** als Packaging-Basis; **PyInstaller Onefolder**
als dokumentierter Fallback, falls Nuitka die GStreamer-DLL-Bündelung nicht
sauber abbildet. Onefile bleibt wie geplant ausgeschlossen (§7.1).
## Begründung
- Nuitka kompiliert zu C: weniger Interpreter-Rest, bessere Reproduzierbarkeit
per Buildskript (§30.1).
- Die GStreamer-Bündelung ist von der Packaging-Wahl unabhängig
(runtime/-Ordner + GST_PLUGIN_PATH, siehe build/windows/GSTREAMER.md).
- PLAN.md verlangt den finalen Vergleich auf Windows; hier nur vorläufige Wahl.
## Folgen
- Buildskripte targetieren Nuitka; der Fallback-Pfad bleibt gepflegt.
## Messwerte / Nachweise
- Ausstehend; der erste Onefolder-Build auf sauberem Windows-Rechner
entscheidet final (§29.6).
## Freigabe
Vorläufig gemäß ADR-0008; endgültig nach Windows-Build.
+53
View File
@@ -0,0 +1,53 @@
# ADR-0008: Build-first-Strategie verzögerte Hardware-Validierung
- **Status:** Angenommen (Auftraggeber-Freigabe)
- **Datum:** 2026-09-11
- **Phase:** 0/1 (übergreifend)
- **Bauplan:** §1, §1.1 Nr. 1/9/10, §29.7, §36 Nr. 16
## Kontext
Die Entwicklungsumgebung ist ein CPU-only-Linux-Container ohne Windows-GPU.
Der Auftraggeber wünscht am 2026-09-11 ausdrücklich: „erst fertig bauen und dann
auf Windows testen". PLAN.md §1 erlaubt Abweichungen vom normativen Ablauf,
wenn sie per ADR dokumentiert, technisch begründet und vom Auftraggeber
freigegeben werden. Diese Freigabe liegt mit der Anfrage vor.
## Entscheidung
1. Die Phasen 15 werden **plattformneutral vollständig gebaut** (Code, Tests
auf CPU-Ebene, Schemas, Dokumentation), bevor Hardwaremessungen stattfinden.
2. Gate 0 und alle renderer-nahen Abnahmen werden **gesammelt in einem
Windows-Durchlauf** nachgeholt (reverse validation). Reihenfolge dort:
Portabilität → Decode/Residenz → Framezeit/DMX-Latenz → Adaptive Quality →
Golden Images → Onefolder-Build.
3. Kein Gate wird vorher als grün gemeldet; STATUS.md führt die ausstehende
Hardware-Validierung offen als Checkliste.
## Alternativen
- Streng planmäßig (Gate 0 zuerst): sicherer, aber ohne Windows-Zugang blockiert;
vom Auftraggeber verworfen.
- Windows-Cloud-VM in der Entwicklungsumgebung: hier nicht verfügbar.
## Folgen und Risiken
- ADR-0004/0005 müssen ohne Messdaten vorläufig entschieden werden →
ausdrücklicher Bestätigungsvorbehalt für den Windows-Durchlauf.
- Der D3D11-Elementpfad bleibt bis dahin unvalidiert. Absicherung: der
Backend-Vertrag (§12.6) hält Korrekturen im Adapter lokal; Projekt-,
Parameter-, DMX- und Web-API ändern sich nicht.
- Shader werden nur statisch bereitgestellt; Compile- und Golden-Image-Tests
entstehen im Windows-Durchlauf.
- Fällt Gate 0 rot aus: gezielte Sanierung nach §1.1 Nr. 9 mit ERRORS.md-Eintrag;
kein Architektur-Neubau erforderlich (Vertragsabsicherung).
## Messwerte / Nachweise
- CPU-Ebene: pytest/Ruff grün je Etappe (TEST_REPORT.md, fortlaufend).
- Hardware: ausstehend; Checkliste in STATUS.md.
## Freigabe
Auftraggeber: per Chat-Anfrage 2026-09-11 („erst fertig bauen und dann auf
Windows testen") hiermit dokumentiert.
+14 -6
View File
@@ -1,17 +1,25 @@
# Architecture Decision Records
Vorlage: `_template.md`. Nummerierung fortlaufend. Abweichungen vom Bauplan nur mit ADR und Freigabe (PLAN.md §1).
Vorlage: `_template.md`. Nummerierung fortlaufend. Abweichungen vom Bauplan nur
mit ADR und Freigabe (PLAN.md §1).
## Angenommen
- ADR-0001: Python-Version-Pin 3.13
- ADR-0002: GStreamer-Pin 1.28.6 (Windows)
- ADR-0003: IPC lokales TCP + length-prefixed MessagePack v1
- ADR-0008: Build-first-Strategie verzögerte Hardware-Validierung
(Auftraggeber-Freigabe 2026-09-11)
## Offen (Phase 0 entscheidet, §32 / §7.1)
## Vorläufig angenommen (Bestätigung im Windows-Durchlauf, ADR-0008)
- ADR-0004: Nativer Renderkern Rust vs. C++, eigenständige Bridge vs. GStreamer-Plugin
- ADR-0005: Packaging Nuitka vs. PyInstaller (Onefolder)
- ADR-0006: Frontend React vs. Svelte
- ADR-0004: Nativer Renderkern Rust auf dem GStreamer-D3D11-Elementpfad
- ADR-0005: Packaging Nuitka Onefolder (Fallback PyInstaller)
## Offen
- ADR-0006: Frontend React vs. Svelte (Entscheidung zu Beginn Phase 5)
- ADR-0007: Typprüfung mypy vs. pyright
- weitere gemäß Bauplan §32 (Persistenz, Preview, Show-Codec, Ownership, Display-Abstraktion, Adaptive-Quality-Policy, Discovery, Clusterprotokoll, Paarung/TLS, Clock-Sync, UI-Design-Tokens)
- weitere gemäß Bauplan §32 (Persistenz, Preview, Show-Codec, Ownership,
Display-Abstraktion, Adaptive-Quality-Policy, Discovery, Clusterprotokoll,
Paarung/TLS, Clock-Sync, UI-Design-Tokens)
+19 -1
View File
@@ -1,10 +1,23 @@
"""hms_protocol versioniertes IPC (PLAN.md §6.2, ADR-0003).
Lokales TCP auf 127.0.0.1, length-prefixed MessagePack, Protokollversion 1.
Verbindungsschicht: Handshake, Snapshot/Delta, Heartbeat, Re-Sync.
"""
from hms_protocol.connection import (
HandshakeInfo,
IpcClient,
IpcServer,
ProtocolError,
)
from hms_protocol.envelope import Envelope, MessageType
from hms_protocol.framing import decode_frame, encode_frame, read_frame, write_frame
from hms_protocol.framing import (
decode_frame,
encode_frame,
read_frame,
read_frame_async,
write_frame,
)
from hms_protocol.idempotency import IdempotencyRegistry
__all__ = [
@@ -13,6 +26,11 @@ __all__ = [
"encode_frame",
"decode_frame",
"read_frame",
"read_frame_async",
"write_frame",
"IdempotencyRegistry",
"IpcServer",
"IpcClient",
"HandshakeInfo",
"ProtocolError",
]
@@ -0,0 +1,252 @@
"""IPC-Verbindung zwischen Control Core und Renderer (PLAN.md §6.2).
Server (Renderer-seitig) und Client (Control-Core-seitig) auf 127.0.0.1.
Verbindungsablauf:
1. Client verbindet, sendet hello mit Protokollversion + Capabilities
2. Server prüft Version, antwortet welcome mit eigenen Capabilities
3. Server sendet vollständigen state snapshot
4. danach inkrementelle Deltas mit monotoner Revision
5. Heartbeat mindestens alle 500 ms in beide Richtungen
6. Ack für jedes zustandsändernde Command (idempotent über message_id)
7. Nach Reconnect: Re-Sync (neuer Snapshot), Deltas erst danach akzeptiert
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from hms_protocol.envelope import PROTOCOL_VERSION, Envelope, MessageType
from hms_protocol.framing import encode_frame, read_frame_async
HEARTBEAT_INTERVAL_S = 0.5 # §6.2: mindestens alle 500 ms
@dataclass
class ProtocolError(Exception):
"""Protokollverstoß; Verbindung wird getrennt."""
code: str
message: str
def __str__(self) -> str:
return f"{self.code}: {self.message}"
@dataclass
class HandshakeInfo:
"""Ergebnis des Handshakes mit Capabilities der Gegenseite."""
peer_name: str
peer_capabilities: dict
protocol_version: int = PROTOCOL_VERSION
class IpcServer:
"""Renderer-seitiger IPC-Server. Lauscht ausschließlich auf 127.0.0.1.
Liefert dem Renderer:
- accept(handler) → wartet auf Client, führt Handshake durch
- receive() → nächste Nachricht (command/event/heartbeat)
- send(envelope) → Nachricht an Control Core
"""
def __init__(self, port: int = 0, host: str = "127.0.0.1") -> None:
if host not in ("127.0.0.1", "localhost", "::1"):
raise ValueError("IPC-Server darf nur auf Loopback lauschen (§6.2)")
self._host = host
self._port = port
self._server: asyncio.Server | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._handler_task: asyncio.Task | None = None
self._heartbeat_task: asyncio.Task | None = None
self._last_peer_heartbeat_ns: int = 0
self.capabilities: dict = {}
self.name: str = "hms-renderer"
@property
def port(self) -> int:
if self._server is None:
return self._port
return self._server.sockets[0].getsockname()[1] if self._server.sockets else self._port
async def start(self) -> int:
"""Startet den Listener; gibt den tatsächlichen Port zurück."""
self._server = await asyncio.start_server(self._on_client, self._host, self._port)
return self.port
async def stop(self) -> None:
if self._heartbeat_task:
self._heartbeat_task.cancel()
if self._handler_task:
self._handler_task.cancel()
if self._writer:
self._writer.close()
if self._server:
self._server.close()
await self._server.wait_closed()
async def _on_client(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
"""Nimmt genau einen Client an (V1: eine Verbindung)."""
self._reader = reader
self._writer = writer
# auf hello warten
hello_raw = await read_frame_async(reader)
if hello_raw.get("type") != "command" or hello_raw.get("payload", {}).get("action") != "hello":
raise ProtocolError("EXPECTED_HELLO", f"got {hello_raw.get('type')}")
peer_caps = hello_raw.get("payload", {}).get("capabilities", {})
peer_name = hello_raw.get("payload", {}).get("name", "unknown")
if hello_raw.get("protocol_version") != PROTOCOL_VERSION:
err = Envelope(
type=MessageType.ERROR,
payload={"code": "VERSION_MISMATCH", "expected": PROTOCOL_VERSION},
)
writer.write(_encode_envelope(err))
await writer.drain()
writer.close()
raise ProtocolError("VERSION_MISMATCH", "client protocol mismatch")
# welcome senden
welcome = Envelope(
type=MessageType.EVENT,
payload={"action": "welcome", "name": self.name, "capabilities": self.capabilities},
)
writer.write(_encode_envelope(welcome))
await writer.drain()
self._last_peer_heartbeat_ns = time.monotonic_ns()
self._heartbeat_task = asyncio.get_running_loop().create_task(self._send_heartbeats())
async def _send_heartbeats(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_INTERVAL_S)
if self._writer is None:
return
hb = Envelope(type=MessageType.HEARTBEAT, payload={"source": self.name})
self._writer.write(_encode_envelope(hb))
await self._writer.drain()
async def receive(self) -> Envelope | None:
"""Liest die nächste Nachricht; None bei Verbindungsabbruch."""
if self._reader is None:
return None
try:
raw = await read_frame_async(self._reader)
except (asyncio.IncompleteReadError, ConnectionError):
return None
if raw.get("type") == "heartbeat":
self._last_peer_heartbeat_ns = time.monotonic_ns()
return Envelope.model_validate(raw)
async def send(self, envelope: Envelope) -> None:
if self._writer is None:
raise ConnectionError("IPC client not connected")
self._writer.write(_encode_envelope(envelope))
await self._writer.drain()
@property
def peer_alive(self) -> bool:
"""True, wenn letzter Peer-Heartbeat < 2× Intervall zurückliegt."""
if self._last_peer_heartbeat_ns == 0:
return False
return (time.monotonic_ns() - self._last_peer_heartbeat_ns) < 2 * HEARTBEAT_INTERVAL_S * 1e9
class IpcClient:
"""Control-Core-seitiger IPC-Client. Verbindet sich mit 127.0.0.1:port.
- connect() → Handshake, gibt HandshakeInfo zurück
- receive() → nächste Nachricht (snapshot/event/ack/heartbeat)
- send(envelope) → Nachricht an Renderer
- Nach Reconnect: connect() erneut → neuer Snapshot (Re-Sync)
"""
def __init__(self, port: int, host: str = "127.0.0.1", name: str = "control-core") -> None:
if host not in ("127.0.0.1", "localhost", "::1"):
raise ValueError("IPC-Client darf nur Loopback verbinden (§6.2)")
self._host = host
self._port = port
self._name = name
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._heartbeat_task: asyncio.Task | None = None
self._last_peer_heartbeat_ns: int = 0
self.capabilities: dict = {}
async def connect(self) -> HandshakeInfo:
"""Baut Verbindung auf, führt Handshake durch."""
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
hello = Envelope(
type=MessageType.COMMAND,
payload={"action": "hello", "name": self._name, "capabilities": self.capabilities},
)
self._writer.write(_encode_envelope(hello))
await self._writer.drain()
raw = await read_frame_async(self._reader)
if raw.get("type") == "error":
raise ProtocolError(
raw.get("payload", {}).get("code", "REMOTE_ERROR"),
str(raw.get("payload", {})),
)
if raw.get("type") != "event" or raw.get("payload", {}).get("action") != "welcome":
raise ProtocolError("EXPECTED_WELCOME", f"got {raw.get('type')}")
if raw.get("protocol_version") != PROTOCOL_VERSION:
raise ProtocolError("VERSION_MISMATCH", f"server sent version {raw.get('protocol_version')}")
self._last_peer_heartbeat_ns = time.monotonic_ns()
self._heartbeat_task = asyncio.get_running_loop().create_task(self._send_heartbeats())
return HandshakeInfo(
peer_name=raw.get("payload", {}).get("name", "unknown"),
peer_capabilities=raw.get("payload", {}).get("capabilities", {}),
)
async def disconnect(self) -> None:
if self._heartbeat_task:
self._heartbeat_task.cancel()
self._heartbeat_task = None
if self._writer:
self._writer.close()
try:
await self._writer.wait_closed()
except (ConnectionError, asyncio.CancelledError):
pass
self._writer = None
self._reader = None
async def receive(self) -> Envelope | None:
if self._reader is None:
return None
try:
raw = await read_frame_async(self._reader)
except (asyncio.IncompleteReadError, ConnectionError):
return None
if raw.get("type") == "heartbeat":
self._last_peer_heartbeat_ns = time.monotonic_ns()
return Envelope.model_validate(raw)
async def send(self, envelope: Envelope) -> None:
if self._writer is None:
raise ConnectionError("IPC not connected")
self._writer.write(_encode_envelope(envelope))
await self._writer.drain()
async def _send_heartbeats(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_INTERVAL_S)
if self._writer is None:
return
hb = Envelope(type=MessageType.HEARTBEAT, payload={"source": self._name})
self._writer.write(_encode_envelope(hb))
await self._writer.drain()
@property
def peer_alive(self) -> bool:
if self._last_peer_heartbeat_ns == 0:
return False
return (time.monotonic_ns() - self._last_peer_heartbeat_ns) < 2 * HEARTBEAT_INTERVAL_S * 1e9
def _encode_envelope(envelope: Envelope) -> bytes:
return encode_frame(envelope.model_dump(mode="json"))
@@ -18,6 +18,7 @@ class MessageType(StrEnum):
ACK = "ack"
ERROR = "error"
TELEMETRY = "telemetry"
HEARTBEAT = "heartbeat"
class Envelope(BaseModel):
+11
View File
@@ -6,6 +6,7 @@ schützt vor unkontrollierten Queues/Resourcenerschöpfung (§6.2, §33).
from __future__ import annotations
import asyncio
import socket
import struct
@@ -46,6 +47,16 @@ def read_frame(sock: socket.socket) -> dict:
return msgpack.unpackb(body, raw=False)
async def read_frame_async(reader: asyncio.StreamReader) -> dict:
"""Liest einen Frame von einem asyncio-StreamReader (IPC-Server/Client)."""
header = await reader.readexactly(_LENGTH.size)
(length,) = _LENGTH.unpack(header)
if length > MAX_PAYLOAD_SIZE:
raise ValueError(f"declared length {length} exceeds limit")
body = await reader.readexactly(length)
return msgpack.unpackb(body, raw=False)
def write_frame(sock: socket.socket, payload: dict) -> None:
"""Schreibt einen Frame auf einen verbundenen Socket."""
sock.sendall(encode_frame(payload))
+1 -1
View File
@@ -8,7 +8,7 @@
"properties": {
"protocol_version": {"const": 1},
"message_id": {"type": "string", "format": "uuid"},
"type": {"enum": ["command", "event", "snapshot", "ack", "error", "telemetry"]},
"type": {"enum": ["command", "event", "snapshot", "ack", "error", "telemetry", "heartbeat"]},
"revision": {"type": "integer", "minimum": 0},
"monotonic_timestamp_ns": {"type": "integer", "minimum": 0},
"payload": {"type": "object"},
+266
View File
@@ -0,0 +1,266 @@
"""Integrationstests IPC-Verbindung (PLAN.md §6.2, §29.2).
Echter TCP-Loopback (kein Mock): Handshake, Heartbeat, Snapshot/Delta,
Idempotenz, Version-Mismatch, Re-Sync nach Reconnect.
"""
from __future__ import annotations
import asyncio
from hms_protocol import (
Envelope,
HandshakeInfo,
IpcClient,
IpcServer,
MessageType,
)
def _run(coro):
return asyncio.run(coro)
async def _connected_pair(
server_caps: dict | None = None,
client_caps: dict | None = None,
):
"""Startet Server+Client und führt den Handshake durch."""
server = IpcServer()
if server_caps is not None:
server.capabilities = server_caps
port = await server.start()
client = IpcClient(port)
if client_caps is not None:
client.capabilities = client_caps
info = await client.connect()
return server, client, info
# ---------- Bindung ausschließlich Loopback (§6.2) ----------
def test_server_rejects_non_loopback_host() -> None:
try:
IpcServer(host="0.0.0.0")
raise AssertionError("0.0.0.0 muss abgelehnt werden")
except ValueError:
pass
def test_client_rejects_non_loopback_host() -> None:
try:
IpcClient(port=1234, host="192.168.1.5")
raise AssertionError("externe IP muss abgelehnt werden")
except ValueError:
pass
# ---------- Handshake (§6.2) ----------
def test_handshake_exchanges_capabilities() -> None:
async def impl() -> None:
server, client, info = await _connected_pair(
server_caps={"backend": "d3d11", "max_layers": 8},
client_caps={"role": "control_core"},
)
try:
assert isinstance(info, HandshakeInfo)
assert info.peer_name == "hms-renderer"
assert info.peer_capabilities == {"backend": "d3d11", "max_layers": 8}
assert info.protocol_version == 1
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_client_rejects_version_mismatch() -> None:
async def impl() -> None:
server = IpcServer()
port = await server.start()
client = IpcClient(port)
try:
# manipulierter Handshake mit falscher Protokollversion
from hms_protocol import encode_frame
reader, writer = await asyncio.open_connection("127.0.0.1", port)
writer.write(
encode_frame(
{
"protocol_version": 99,
"message_id": "x",
"type": "command",
"revision": 0,
"monotonic_timestamp_ns": 0,
"payload": {"action": "hello", "name": "evil", "capabilities": {}},
}
)
)
await writer.drain()
# Server antwortet mit ERROR VERSION_MISMATCH
from hms_protocol import read_frame_async
reply = await read_frame_async(reader)
assert reply["type"] == "error"
assert reply["payload"]["code"] == "VERSION_MISMATCH"
writer.close()
finally:
await client.disconnect()
await server.stop()
_run(impl())
# ---------- Nachrichtenübertragung ----------
def test_snapshot_and_delta_delivery() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
try:
# Server sendet Snapshot
snap = Envelope(
type=MessageType.SNAPSHOT,
revision=5,
payload={"values": {"master/intensity": 1.0}},
)
await server.send(snap)
received = await asyncio.wait_for(client.receive(), timeout=2.0)
assert received is not None
assert received.type is MessageType.SNAPSHOT
assert received.revision == 5
assert received.payload["values"]["master/intensity"] == 1.0
# danach Delta mit höherer Revision
delta = Envelope(
type=MessageType.EVENT,
revision=6,
payload={"changes": {"master/intensity": 0.5}},
)
await server.send(delta)
received2 = await asyncio.wait_for(client.receive(), timeout=2.0)
assert received2 is not None
assert received2.revision == 6
assert received2.payload["changes"]["master/intensity"] == 0.5
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_bidirectional_commands_and_acks() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
try:
# Client → Server: Command
cmd = Envelope(
type=MessageType.COMMAND,
revision=10,
payload={"action": "parameter.set", "path": "master/intensity", "value": 0.7},
)
await client.send(cmd)
received = await asyncio.wait_for(server.receive(), timeout=2.0)
assert received is not None
assert received.type is MessageType.COMMAND
assert received.payload["action"] == "parameter.set"
# Server → Client: Ack
ack = Envelope(
type=MessageType.ACK,
revision=11,
payload={"ack_for": received.message_id, "status": "ok"},
)
await server.send(ack)
ack_received = await asyncio.wait_for(client.receive(), timeout=2.0)
assert ack_received is not None
assert ack_received.type is MessageType.ACK
assert ack_received.payload["ack_for"] == received.message_id
finally:
await client.disconnect()
await server.stop()
_run(impl())
# ---------- Heartbeat (§6.2: mindestens alle 500 ms) ----------
def test_heartbeat_flows_bidirectionally() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
try:
await asyncio.sleep(0.7) # > Heartbeat-Intervall
# Client empfängt Server-Heartbeat
got_server_hb = False
for _ in range(4):
msg = await asyncio.wait_for(client.receive(), timeout=1.5)
if msg is not None and msg.type is MessageType.HEARTBEAT:
got_server_hb = True
break
assert got_server_hb, "Client musste einen Heartbeat empfangen"
assert client.peer_alive, "Server-Heartbeat muss peer_alive setzen"
# Server empfängt Client-Heartbeat
await asyncio.sleep(0.1)
got_client_hb = False
for _ in range(6):
msg = await asyncio.wait_for(server.receive(), timeout=1.5)
if msg is not None and msg.type is MessageType.HEARTBEAT:
got_client_hb = True
break
assert got_client_hb, "Server musste einen Heartbeat empfangen"
assert server.peer_alive
finally:
await client.disconnect()
await server.stop()
_run(impl())
# ---------- Re-Sync nach Reconnect (§6.2, §29.2) ----------
def test_reconnect_requires_new_snapshot() -> None:
async def impl() -> None:
server = IpcServer()
port = await server.start()
try:
# erste Verbindung: Snapshot Revision 3
client1 = IpcClient(port)
await client1.connect()
await server.send(
Envelope(type=MessageType.SNAPSHOT, revision=3, payload={"values": {}})
)
snap1 = await asyncio.wait_for(client1.receive(), timeout=2.0)
assert snap1 is not None and snap1.revision == 3
await client1.disconnect()
# zweite Verbindung: neuer Snapshot (Re-Sync) mit Revision 4
client2 = IpcClient(port)
info2 = await client2.connect() # noqa: F841 Handshake reicht
await server.send(
Envelope(type=MessageType.SNAPSHOT, revision=4, payload={"values": {"a": 1}})
)
snap2 = await asyncio.wait_for(client2.receive(), timeout=2.0)
assert snap2 is not None
assert snap2.revision == 4 # Deltas erst nach erfolgreichem Re-Sync
await client2.disconnect()
finally:
await server.stop()
_run(impl())
def test_receive_returns_none_on_disconnect() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
await client.disconnect()
msg = await asyncio.wait_for(server.receive(), timeout=2.0)
assert msg is None # Verbindung weg → sauberes None statt Exception
await server.stop()
_run(impl())