From d69ba86caac24392503f04c42ff45829fa6815b0 Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Thu, 2 Apr 2026 15:57:50 +0200 Subject: [PATCH 01/12] Added async components for WebBridgeController prototype --- .../core/async_experimental/__init__.py | 0 .../async_experimental/async_heartbeat.py | 179 +++++++++++ .../core/async_experimental/async_pools.py | 93 ++++++ .../core/async_experimental/bridge.py | 301 ++++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 python/constellation/core/async_experimental/__init__.py create mode 100644 python/constellation/core/async_experimental/async_heartbeat.py create mode 100644 python/constellation/core/async_experimental/async_pools.py create mode 100644 python/constellation/core/async_experimental/bridge.py diff --git a/python/constellation/core/async_experimental/__init__.py b/python/constellation/core/async_experimental/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/constellation/core/async_experimental/async_heartbeat.py b/python/constellation/core/async_experimental/async_heartbeat.py new file mode 100644 index 000000000..64f5aced7 --- /dev/null +++ b/python/constellation/core/async_experimental/async_heartbeat.py @@ -0,0 +1,179 @@ +import asyncio +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable +from uuid import UUID + +import zmq +import zmq.asyncio + +from constellation.core.chp import CHPMessageFlags, CHPRole, chp_decode_message +from constellation.core.protocol.cscp1 import SatelliteState + + +@dataclass +class HeartbeatState: + """Tracked state for a single satellite.""" + host: UUID + name: str + state: SatelliteState = SatelliteState.DEAD + last_refresh: float = field(default_factory=lambda: asyncio.get_event_loop().time()) + last_statechange: datetime = field(default_factory=datetime.now) + interval_ms: int = 2000 + lives: int = 3 + role: CHPRole = CHPRole.DYNAMIC + + def refresh(self) -> None: + self.last_refresh = asyncio.get_event_loop().time() + + def seconds_since_refresh(self) -> float: + return asyncio.get_event_loop().time() - self.last_refresh + + +class AsyncHeartbeatReceiver: + """Async heartbeat receiver with lives/stale connection tracking. Use call_soon_threadsafe() for add_satellite from threads.""" + + INIT_LIVES = 3 + INIT_INTERVAL = 2000 + + def __init__( + self, + ctx: zmq.asyncio.Context, + on_state_change: Callable[[str, SatelliteState, SatelliteState], None] | None = None, + on_satellite_dead: Callable[[str], None] | None = None, + ): + self._ctx = ctx + self._on_state_change = on_state_change + self._on_satellite_dead = on_satellite_dead + + self._sockets: dict[UUID, zmq.asyncio.Socket] = {} + self._states: dict[UUID, HeartbeatState] = {} + self._poller = zmq.asyncio.Poller() + + def add_satellite(self, uuid: UUID, address: str, port: int, name: str) -> None: + """Add a satellite to track. Called from CHIRP discovery.""" + if uuid in self._sockets: + return + + sock = self._ctx.socket(zmq.SUB) + sock.connect(f"tcp://{address}:{port}") + sock.setsockopt_string(zmq.SUBSCRIBE, "") + sock.setsockopt(zmq.RCVTIMEO, 5000) + sock.setsockopt(zmq.LINGER, 2000) + + self._poller.register(sock, zmq.POLLIN) + self._sockets[uuid] = sock + self._states[uuid] = HeartbeatState( + host=uuid, + name=name, + lives=self.INIT_LIVES, + interval_ms=self.INIT_INTERVAL, + ) + + def remove_satellite(self, uuid: UUID) -> None: + """Remove a satellite from tracking.""" + sock = self._sockets.pop(uuid, None) + if sock is not None: + self._poller.unregister(sock) + sock.close() + self._states.pop(uuid, None) + + @property + def states(self) -> dict[str, SatelliteState]: + """Get current states keyed by lowercase name.""" + return {hb.name.lower(): hb.state for hb in self._states.values()} + + @property + def state_changes(self) -> dict[str, datetime]: + """Get last state change times keyed by name.""" + return {hb.name: hb.last_statechange for hb in self._states.values()} + + async def run(self, stop: asyncio.Event) -> None: + """Main polling loop. Runs until stop is set.""" + last_check = asyncio.get_event_loop().time() + + while not stop.is_set(): + # Poll for heartbeats + if self._sockets: + events = dict(await self._poller.poll(timeout=50)) + for uuid, sock in list(self._sockets.items()): + if sock in events: + try: + msg = await sock.recv_multipart() + self._process_heartbeat(uuid, msg) + except zmq.Again: + pass + else: + # No sockets yet (short sleep) + await asyncio.sleep(0.05) + + # Periodic stale connection check + now = asyncio.get_event_loop().time() + if (now - last_check) > 0.3: + self._check_stale_connections() + last_check = now + + def _process_heartbeat(self, uuid: UUID, msg: list[bytes]) -> None: + """Process a received heartbeat message.""" + try: + name, timestamp, state_val, flags, interval, status = chp_decode_message(msg) + state = SatelliteState(state_val) + + hb = self._states.get(uuid) + if hb is None: + return + + # Update name if it was unknown + if hb.name != name: + hb.name = name + + # Detect state change + if state != hb.state: + old_state = hb.state + hb.state = state + hb.last_statechange = datetime.now() + if self._on_state_change: + self._on_state_change(name, old_state, state) + + # Update tracking + hb.refresh() + hb.interval_ms = interval + hb.role = CHPRole.from_flags(flags) + + # Refresh lives + if hb.lives != self.INIT_LIVES: + hb.lives = self.INIT_LIVES + + except Exception as e: + # Malformed heartbeat (ignore) + pass + + def _check_stale_connections(self) -> None: + """Check for satellites that have missed heartbeats.""" + for uuid, hb in list(self._states.items()): + if hb.lives <= 0: + continue + + expected_interval = (hb.interval_ms / 1000) * 1.5 + if hb.seconds_since_refresh() > expected_interval: + hb.lives -= 1 + + if hb.lives == 0: + # Satellite is dead + if self._on_satellite_dead: + self._on_satellite_dead(hb.name) + hb.state = SatelliteState.DEAD + else: + # Refresh and try again + hb.refresh() + + def close(self) -> None: + """Close all sockets.""" + for sock in self._sockets.values(): + try: + self._poller.unregister(sock) + except Exception: + pass + sock.close() + self._sockets.clear() + self._states.clear() diff --git a/python/constellation/core/async_experimental/async_pools.py b/python/constellation/core/async_experimental/async_pools.py new file mode 100644 index 000000000..a591777ce --- /dev/null +++ b/python/constellation/core/async_experimental/async_pools.py @@ -0,0 +1,93 @@ +import asyncio +from collections.abc import Callable +from uuid import UUID + +import zmq +import zmq.asyncio + + +class AsyncSubscriberPool: + """Async ZMQ subscriber pool. All methods must be called from the event loop; use call_soon_threadsafe() from threads.""" + def __init__(self, ctx: zmq.asyncio.Context): + self._ctx = ctx + self._sockets: dict[UUID, zmq.asyncio.Socket] = {} + self._poller = zmq.asyncio.Poller() + self._topics: list[str] = [] + + def add_socket(self, uuid: UUID, address: str, port: int) -> None: + """Add a subscriber socket for a discovered satellite.""" + if uuid in self._sockets: + return + sock = self._ctx.socket(zmq.SUB) + sock.connect(f"tcp://{address}:{port}") + for topic in self._topics: + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + self._poller.register(sock, zmq.POLLIN) + self._sockets[uuid] = sock + + def remove_socket(self, uuid: UUID) -> None: + """Remove a subscriber socket for a departing satellite.""" + sock = self._sockets.pop(uuid, None) + if sock is not None: + self._poller.unregister(sock) + sock.close() + + def subscribe(self, topic: str, host: UUID | None = None) -> None: + """Subscribe to a topic on one or all sockets.""" + if host is not None: + sock = self._sockets.get(host) + if sock is not None: + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + else: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + if topic not in self._topics: + self._topics.append(topic) + + def unsubscribe(self, topic: str, host: UUID | None = None) -> None: + """Unsubscribe from a topic on one or all sockets.""" + if host is not None: + sock = self._sockets.get(host) + if sock is not None: + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + else: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + if topic in self._topics: + self._topics.remove(topic) + + def set_topics(self, new_topics: list[str]) -> None: + """Replace all topic subscriptions.""" + old_set = set(self._topics) + new_set = set(new_topics) + for topic in old_set - new_set: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + for topic in new_set - old_set: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + self._topics = list(new_topics) + + async def run(self, callback: Callable[[list[bytes]], None], stop: asyncio.Event) -> None: + """Poll loop. Runs until stop is set.""" + while not stop.is_set(): + if not self._sockets: + try: + await asyncio.wait_for(stop.wait(), timeout=0.1) + break + except asyncio.TimeoutError: + continue + events = dict(await self._poller.poll(timeout=200)) + for sock in events: + msg = await sock.recv_multipart() + callback(msg) + + def close(self) -> None: + """Close all sockets.""" + for sock in self._sockets.values(): + try: + self._poller.unregister(sock) + except Exception: + pass + sock.close() + self._sockets.clear() diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py new file mode 100644 index 000000000..4f4491734 --- /dev/null +++ b/python/constellation/core/async_experimental/bridge.py @@ -0,0 +1,301 @@ +import asyncio +import threading +import time +from uuid import UUID + +import zmq +import zmq.asyncio + +from constellation.core.chirp import ( + CHIRPBeaconTransmitter, + CHIRPMessageType, + CHIRPServiceIdentifier, +) +from constellation.core.cscp import CommandTransmitter +from constellation.core.message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage +from constellation.core.protocol.cscp1 import SatelliteState +from constellation.core.network import get_interface_addresses + +from .async_pools import AsyncSubscriberPool +from .async_heartbeat import AsyncHeartbeatReceiver + + +class CombinedBridge: + """Combined async bridge - CHIRP discovery (thread), heartbeat tracking, CMDP receiving, and CSCP commands (asyncio).""" + + def __init__(self, group: str, loop: asyncio.AbstractEventLoop): + self.group = group + self._loop = loop + + # Async ZMQ context + self._ctx = zmq.asyncio.Context() + # Sync ZMQ context for CSCP (REQ/REP is blocking) + self._sync_ctx = zmq.Context() + + self._stop = asyncio.Event() + self._stop_thread = threading.Event() + + # Async components + self._heartbeat = AsyncHeartbeatReceiver( + self._ctx, + on_state_change=self._on_state_change, + on_satellite_dead=self._on_satellite_dead, + ) + self._cmdp_pool = AsyncSubscriberPool(self._ctx) + + # CSCP transmitters (for commands, keyed by canonical name) + self._transmitters: dict[str, CommandTransmitter] = {} + self._transmitter_lock = threading.Lock() + + # Track discovered services + self._discovered: dict[UUID, dict] = {} + + # Subscribe to logs at INFO+ and all stats + self._cmdp_pool.set_topics([ + "LOG/INFO", + "LOG/WARNING", + "LOG/STATUS", + "LOG/CRITICAL", + "STAT/", + ]) + + # Stats + self.stats = { + "state_changes": 0, + "logs": 0, + "metrics": 0, + "commands_sent": 0, + } + + def start(self) -> None: + """Start the CHIRP discovery thread.""" + self._chirp_thread = threading.Thread( + target=self._chirp_loop, + daemon=True, + ) + self._chirp_thread.start() + + async def run(self) -> None: + """Run all async components concurrently.""" + await asyncio.gather( + self._heartbeat.run(self._stop), + self._cmdp_pool.run(self._on_cmdp_message, self._stop), + ) + + async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str: + """Send a CSCP command via asyncio.to_thread.""" + def _send(): + with self._transmitter_lock: + ct = self._transmitters.get(canonical_name) + if ct is None: + return f"ERROR: No transmitter for {canonical_name}" + try: + msg = ct.request_get_response(command=cmd, payload=payload) + return f"{msg.verb}: {msg.verb_msg}" + except Exception as e: + return f"ERROR: {e}" + + self.stats["commands_sent"] += 1 + result = await asyncio.to_thread(_send) + return result + + def shutdown(self) -> None: + """Stop everything.""" + self._stop.set() + self._stop_thread.set() + if hasattr(self, '_chirp_thread'): + self._chirp_thread.join(timeout=2) + self._heartbeat.close() + self._cmdp_pool.close() + # Close CSCP transmitters + with self._transmitter_lock: + for ct in self._transmitters.values(): + ct.close() + self._ctx.term() + self._sync_ctx.term() + + def _on_state_change(self, name, old_state, new_state): + self.stats["state_changes"] += 1 + print(f" [STATE] {name}: {old_state.name} -> {new_state.name}") + + def _on_satellite_dead(self, name): + print(f" [DEAD] {name} stopped sending heartbeats") + + def _on_cmdp_message(self, frames: list[bytes]): + try: + msg = CMDP1Message.disassemble(frames) + if msg.is_log_message(): + log_msg = CMDP1LogMessage.from_cmdp_message(msg) + record = log_msg.to_log_record() + level = record.levelname + sender = getattr(record, 'sender', record.name) + message = record.getMessage() + self.stats["logs"] += 1 + print(f" [LOG] [{level:8s}] {sender}: {message}") + elif msg.is_stat_message(): + stat_msg = CMDP1StatMessage.from_cmdp_message(msg) + self.stats["metrics"] += 1 + print(f" [STAT] {stat_msg.metric.name} = {stat_msg.value}{stat_msg.metric.unit}") + except Exception as e: + pass # Silently discard decode errors + + def _chirp_loop(self): + """Discover HEARTBEAT, MONITORING, and CONTROL services.""" + from constellation.core.chirp import get_uuid + + beacon = CHIRPBeaconTransmitter( + "CombinedBridge.Bridge", + self.group, + get_interface_addresses(None), + ) + + # Request all service types + beacon.emit(CHIRPServiceIdentifier.HEARTBEAT, CHIRPMessageType.REQUEST) + time.sleep(0.1) + beacon.emit(CHIRPServiceIdentifier.MONITORING, CHIRPMessageType.REQUEST) + time.sleep(0.1) + beacon.emit(CHIRPServiceIdentifier.CONTROL, CHIRPMessageType.REQUEST) + + while not self._stop_thread.is_set(): + msg = beacon.listen() + if msg is None: + continue + + if msg.msgtype == CHIRPMessageType.REQUEST: + continue + + if msg.msgtype != CHIRPMessageType.OFFER: + continue + + uuid = msg.host_uuid + address = msg.from_address + port = msg.port + service = msg.serviceid + + # Track what we've discovered per host + if uuid not in self._discovered: + self._discovered[uuid] = {} + + if service in self._discovered[uuid]: + continue # Already discovered + + self._discovered[uuid][service] = (address, port) + + if service == CHIRPServiceIdentifier.HEARTBEAT: + print(f" [CHIRP] HEARTBEAT at {address}:{port}") + self._loop.call_soon_threadsafe( + self._heartbeat.add_satellite, + uuid, address, port, + f"Unknown-{str(uuid)[:8]}", + ) + + elif service == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] MONITORING at {address}:{port}") + self._loop.call_soon_threadsafe( + self._cmdp_pool.add_socket, + uuid, address, port, + ) + + elif service == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] CONTROL at {address}:{port}") + self._setup_transmitter(uuid, address, port) + + beacon.close() + + def _setup_transmitter(self, uuid: UUID, address: str, port: int): + """Set up CSCP transmitter for a satellite (runs in CHIRP thread).""" + try: + socket = self._sync_ctx.socket(zmq.REQ) + socket.connect(f"tcp://{address}:{port}") + socket.setsockopt(zmq.LINGER, 2000) + socket.setsockopt(zmq.RCVTIMEO, 5000) + + ct = CommandTransmitter("CombinedBridge.Bridge", socket) + + # Get canonical name + msg = ct.request_get_response("get_commands") + canonical_name = msg.sender + + with self._transmitter_lock: + self._transmitters[canonical_name] = ct + + print(f" [CSCP] Connected to {canonical_name}") + except Exception as e: + print(f" [CSCP] Failed to connect: {e}") + + +async def main(): + group = "test" + + loop = asyncio.get_running_loop() + bridge = CombinedBridge(group, loop) + + print(f"Combined Bridge for group '{group}'") + print(f"Architecture: 1 thread (CHIRP) + asyncio event loop") + print() + + bridge.start() + + # Wait for discovery + await asyncio.sleep(2) + + # Run async components in background + run_task = asyncio.create_task(bridge.run()) + + # Wait for a satellite to be discovered + print("Waiting for satellite discovery...") + for _ in range(20): + if bridge._transmitters: + break + await asyncio.sleep(0.5) + + if not bridge._transmitters: + print("No satellites found. Make sure PyRandomTransmitter is running.") + bridge.shutdown() + return + + sat_name = list(bridge._transmitters.keys())[0] + print(f"\nFound: {sat_name}") + print(f"Current states: {bridge._heartbeat.states}") + print() + + # Send commands through the bridge + print("--- Sending commands ---") + + result = await bridge.send_command(sat_name, "initialize", {}) + print(f" initialize -> {result}") + await asyncio.sleep(2) + print(f" States: {bridge._heartbeat.states}") + + result = await bridge.send_command(sat_name, "launch") + print(f" launch -> {result}") + await asyncio.sleep(2) + print(f" States: {bridge._heartbeat.states}") + + result = await bridge.send_command(sat_name, "land") + print(f" land -> {result}") + await asyncio.sleep(2) + print(f" States: {bridge._heartbeat.states}") + + # Let it run a bit more to collect remaining messages + await asyncio.sleep(3) + + # Shutdown + bridge.shutdown() + run_task.cancel() + try: + await run_task + except asyncio.CancelledError: + pass + + print() + print("--- Results ---") + print(f" State changes detected: {bridge.stats['state_changes']}") + print(f" Log messages received: {bridge.stats['logs']}") + print(f" Metrics received: {bridge.stats['metrics']}") + print(f" Commands sent: {bridge.stats['commands_sent']}") + print(f" Architecture: 1 thread (CHIRP) + asyncio event loop") + + +if __name__ == "__main__": + asyncio.run(main()) From 1960f246b6bba2842e2ec02325c14e1cfcedea7c Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Fri, 10 Apr 2026 21:01:48 +0200 Subject: [PATCH 02/12] Hot loop fix, callback as member, DEPART handling, socket leak fix --- .../async_experimental/async_heartbeat.py | 40 ++-- .../core/async_experimental/async_pools.py | 40 ++-- .../core/async_experimental/bridge.py | 171 ++++++++++-------- 3 files changed, 125 insertions(+), 126 deletions(-) diff --git a/python/constellation/core/async_experimental/async_heartbeat.py b/python/constellation/core/async_experimental/async_heartbeat.py index 64f5aced7..b0b6d8cd0 100644 --- a/python/constellation/core/async_experimental/async_heartbeat.py +++ b/python/constellation/core/async_experimental/async_heartbeat.py @@ -1,13 +1,14 @@ import asyncio +import time from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Callable +from typing import Callable from uuid import UUID import zmq import zmq.asyncio -from constellation.core.chp import CHPMessageFlags, CHPRole, chp_decode_message +from constellation.core.chp import CHPRole, chp_decode_message from constellation.core.protocol.cscp1 import SatelliteState @@ -17,21 +18,21 @@ class HeartbeatState: host: UUID name: str state: SatelliteState = SatelliteState.DEAD - last_refresh: float = field(default_factory=lambda: asyncio.get_event_loop().time()) + last_refresh: float = field(default_factory=time.monotonic) last_statechange: datetime = field(default_factory=datetime.now) interval_ms: int = 2000 lives: int = 3 role: CHPRole = CHPRole.DYNAMIC def refresh(self) -> None: - self.last_refresh = asyncio.get_event_loop().time() + self.last_refresh = time.monotonic() def seconds_since_refresh(self) -> float: - return asyncio.get_event_loop().time() - self.last_refresh + return time.monotonic() - self.last_refresh class AsyncHeartbeatReceiver: - """Async heartbeat receiver with lives/stale connection tracking. Use call_soon_threadsafe() for add_satellite from threads.""" + """Async heartbeat receiver with lives/stale connection tracking. Call add_satellite() via call_soon_threadsafe() from threads.""" INIT_LIVES = 3 INIT_INTERVAL = 2000 @@ -58,8 +59,7 @@ def add_satellite(self, uuid: UUID, address: str, port: int, name: str) -> None: sock = self._ctx.socket(zmq.SUB) sock.connect(f"tcp://{address}:{port}") sock.setsockopt_string(zmq.SUBSCRIBE, "") - sock.setsockopt(zmq.RCVTIMEO, 5000) - sock.setsockopt(zmq.LINGER, 2000) + sock.setsockopt(zmq.LINGER, 0) self._poller.register(sock, zmq.POLLIN) self._sockets[uuid] = sock @@ -90,10 +90,9 @@ def state_changes(self) -> dict[str, datetime]: async def run(self, stop: asyncio.Event) -> None: """Main polling loop. Runs until stop is set.""" - last_check = asyncio.get_event_loop().time() + last_check = time.monotonic() while not stop.is_set(): - # Poll for heartbeats if self._sockets: events = dict(await self._poller.poll(timeout=50)) for uuid, sock in list(self._sockets.items()): @@ -103,16 +102,14 @@ async def run(self, stop: asyncio.Event) -> None: self._process_heartbeat(uuid, msg) except zmq.Again: pass + + now = time.monotonic() + if (now - last_check) > 0.3: + self._check_stale_connections() + last_check = now else: - # No sockets yet (short sleep) await asyncio.sleep(0.05) - # Periodic stale connection check - now = asyncio.get_event_loop().time() - if (now - last_check) > 0.3: - self._check_stale_connections() - last_check = now - def _process_heartbeat(self, uuid: UUID, msg: list[bytes]) -> None: """Process a received heartbeat message.""" try: @@ -123,11 +120,9 @@ def _process_heartbeat(self, uuid: UUID, msg: list[bytes]) -> None: if hb is None: return - # Update name if it was unknown if hb.name != name: hb.name = name - # Detect state change if state != hb.state: old_state = hb.state hb.state = state @@ -135,17 +130,14 @@ def _process_heartbeat(self, uuid: UUID, msg: list[bytes]) -> None: if self._on_state_change: self._on_state_change(name, old_state, state) - # Update tracking hb.refresh() hb.interval_ms = interval hb.role = CHPRole.from_flags(flags) - # Refresh lives if hb.lives != self.INIT_LIVES: hb.lives = self.INIT_LIVES - except Exception as e: - # Malformed heartbeat (ignore) + except Exception: pass def _check_stale_connections(self) -> None: @@ -159,12 +151,10 @@ def _check_stale_connections(self) -> None: hb.lives -= 1 if hb.lives == 0: - # Satellite is dead if self._on_satellite_dead: self._on_satellite_dead(hb.name) hb.state = SatelliteState.DEAD else: - # Refresh and try again hb.refresh() def close(self) -> None: diff --git a/python/constellation/core/async_experimental/async_pools.py b/python/constellation/core/async_experimental/async_pools.py index a591777ce..a4c54dfb0 100644 --- a/python/constellation/core/async_experimental/async_pools.py +++ b/python/constellation/core/async_experimental/async_pools.py @@ -7,9 +7,15 @@ class AsyncSubscriberPool: - """Async ZMQ subscriber pool. All methods must be called from the event loop; use call_soon_threadsafe() from threads.""" - def __init__(self, ctx: zmq.asyncio.Context): + """Async ZMQ subscriber pool. Call all methods from the event loop; use call_soon_threadsafe() from threads.""" + + def __init__( + self, + ctx: zmq.asyncio.Context, + callback: Callable[[list[bytes]], None], + ) -> None: self._ctx = ctx + self._callback = callback self._sockets: dict[UUID, zmq.asyncio.Socket] = {} self._poller = zmq.asyncio.Poller() self._topics: list[str] = [] @@ -32,32 +38,8 @@ def remove_socket(self, uuid: UUID) -> None: self._poller.unregister(sock) sock.close() - def subscribe(self, topic: str, host: UUID | None = None) -> None: - """Subscribe to a topic on one or all sockets.""" - if host is not None: - sock = self._sockets.get(host) - if sock is not None: - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - else: - for sock in self._sockets.values(): - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - if topic not in self._topics: - self._topics.append(topic) - - def unsubscribe(self, topic: str, host: UUID | None = None) -> None: - """Unsubscribe from a topic on one or all sockets.""" - if host is not None: - sock = self._sockets.get(host) - if sock is not None: - sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) - else: - for sock in self._sockets.values(): - sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) - if topic in self._topics: - self._topics.remove(topic) - def set_topics(self, new_topics: list[str]) -> None: - """Replace all topic subscriptions.""" + """Replace all topic subscriptions across all sockets.""" old_set = set(self._topics) new_set = set(new_topics) for topic in old_set - new_set: @@ -68,7 +50,7 @@ def set_topics(self, new_topics: list[str]) -> None: sock.setsockopt_string(zmq.SUBSCRIBE, topic) self._topics = list(new_topics) - async def run(self, callback: Callable[[list[bytes]], None], stop: asyncio.Event) -> None: + async def run(self, stop: asyncio.Event) -> None: """Poll loop. Runs until stop is set.""" while not stop.is_set(): if not self._sockets: @@ -80,7 +62,7 @@ async def run(self, callback: Callable[[list[bytes]], None], stop: asyncio.Event events = dict(await self._poller.poll(timeout=200)) for sock in events: msg = await sock.recv_multipart() - callback(msg) + self._callback(msg) def close(self) -> None: """Close all sockets.""" diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py index 4f4491734..81298438d 100644 --- a/python/constellation/core/async_experimental/bridge.py +++ b/python/constellation/core/async_experimental/bridge.py @@ -21,36 +21,39 @@ class CombinedBridge: - """Combined async bridge - CHIRP discovery (thread), heartbeat tracking, CMDP receiving, and CSCP commands (asyncio).""" + """Combined async bridge - CHIRP discovery (thread), everything else in the asyncio event loop via asyncio.gather.""" def __init__(self, group: str, loop: asyncio.AbstractEventLoop): self.group = group self._loop = loop - # Async ZMQ context + # Async ZMQ context for heartbeat/CMDP (SUB sockets, async polling) self._ctx = zmq.asyncio.Context() - # Sync ZMQ context for CSCP (REQ/REP is blocking) + # Sync ZMQ context for CSCP (REQ/REP is blocking, used via to_thread) self._sync_ctx = zmq.Context() self._stop = asyncio.Event() self._stop_thread = threading.Event() - # Async components self._heartbeat = AsyncHeartbeatReceiver( self._ctx, on_state_change=self._on_state_change, on_satellite_dead=self._on_satellite_dead, ) - self._cmdp_pool = AsyncSubscriberPool(self._ctx) + self._cmdp_pool = AsyncSubscriberPool( + self._ctx, + callback=self._on_cmdp_message, + ) - # CSCP transmitters (for commands, keyed by canonical name) + # CSCP transmitters keyed by canonical name self._transmitters: dict[str, CommandTransmitter] = {} + # Maps UUID -> canonical name for DEPART handling + self._transmitter_uuids: dict[UUID, str] = {} self._transmitter_lock = threading.Lock() - # Track discovered services + # Track discovered services per UUID to avoid duplicate setup self._discovered: dict[UUID, dict] = {} - # Subscribe to logs at INFO+ and all stats self._cmdp_pool.set_topics([ "LOG/INFO", "LOG/WARNING", @@ -59,7 +62,6 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop): "STAT/", ]) - # Stats self.stats = { "state_changes": 0, "logs": 0, @@ -76,14 +78,14 @@ def start(self) -> None: self._chirp_thread.start() async def run(self) -> None: - """Run all async components concurrently.""" + """Run heartbeat and CMDP components concurrently.""" await asyncio.gather( self._heartbeat.run(self._stop), - self._cmdp_pool.run(self._on_cmdp_message, self._stop), + self._cmdp_pool.run(self._stop), ) async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str: - """Send a CSCP command via asyncio.to_thread.""" + """Send a CSCP command via asyncio.to_thread. The transmitter lock ensures the REQ socket is never accessed from two threads simultaneously.""" def _send(): with self._transmitter_lock: ct = self._transmitters.get(canonical_name) @@ -96,39 +98,38 @@ def _send(): return f"ERROR: {e}" self.stats["commands_sent"] += 1 - result = await asyncio.to_thread(_send) - return result + return await asyncio.to_thread(_send) def shutdown(self) -> None: - """Stop everything.""" + """Stop all components. Cancel and await run() before calling this to avoid terminating contexts while sockets are still open.""" self._stop.set() self._stop_thread.set() - if hasattr(self, '_chirp_thread'): + if hasattr(self, "_chirp_thread"): self._chirp_thread.join(timeout=2) self._heartbeat.close() self._cmdp_pool.close() - # Close CSCP transmitters with self._transmitter_lock: for ct in self._transmitters.values(): ct.close() self._ctx.term() self._sync_ctx.term() - def _on_state_change(self, name, old_state, new_state): + def _on_state_change(self, name: str, old_state: SatelliteState, + new_state: SatelliteState) -> None: self.stats["state_changes"] += 1 print(f" [STATE] {name}: {old_state.name} -> {new_state.name}") - def _on_satellite_dead(self, name): + def _on_satellite_dead(self, name: str) -> None: print(f" [DEAD] {name} stopped sending heartbeats") - def _on_cmdp_message(self, frames: list[bytes]): + def _on_cmdp_message(self, frames: list[bytes]) -> None: try: msg = CMDP1Message.disassemble(frames) if msg.is_log_message(): log_msg = CMDP1LogMessage.from_cmdp_message(msg) record = log_msg.to_log_record() level = record.levelname - sender = getattr(record, 'sender', record.name) + sender = getattr(record, "sender", record.name) message = record.getMessage() self.stats["logs"] += 1 print(f" [LOG] [{level:8s}] {sender}: {message}") @@ -136,11 +137,11 @@ def _on_cmdp_message(self, frames: list[bytes]): stat_msg = CMDP1StatMessage.from_cmdp_message(msg) self.stats["metrics"] += 1 print(f" [STAT] {stat_msg.metric.name} = {stat_msg.value}{stat_msg.metric.unit}") - except Exception as e: - pass # Silently discard decode errors + except Exception: + pass - def _chirp_loop(self): - """Discover HEARTBEAT, MONITORING, and CONTROL services.""" + def _chirp_loop(self) -> None: + """Discover and track satellites via CHIRP. Runs in a dedicated thread.""" from constellation.core.chirp import get_uuid beacon = CHIRPBeaconTransmitter( @@ -149,7 +150,6 @@ def _chirp_loop(self): get_interface_addresses(None), ) - # Request all service types beacon.emit(CHIRPServiceIdentifier.HEARTBEAT, CHIRPMessageType.REQUEST) time.sleep(0.1) beacon.emit(CHIRPServiceIdentifier.MONITORING, CHIRPMessageType.REQUEST) @@ -164,69 +164,94 @@ def _chirp_loop(self): if msg.msgtype == CHIRPMessageType.REQUEST: continue - if msg.msgtype != CHIRPMessageType.OFFER: - continue - uuid = msg.host_uuid address = msg.from_address port = msg.port service = msg.serviceid - # Track what we've discovered per host - if uuid not in self._discovered: - self._discovered[uuid] = {} + if msg.msgtype == CHIRPMessageType.OFFER: + if uuid not in self._discovered: + self._discovered[uuid] = {} + + if service in self._discovered[uuid]: + continue - if service in self._discovered[uuid]: - continue # Already discovered + self._discovered[uuid][service] = (address, port) - self._discovered[uuid][service] = (address, port) + if service == CHIRPServiceIdentifier.HEARTBEAT: + print(f" [CHIRP] HEARTBEAT from {address}:{port}") + self._loop.call_soon_threadsafe( + self._heartbeat.add_satellite, + uuid, address, port, + f"Unknown-{str(uuid)[:8]}", + ) - if service == CHIRPServiceIdentifier.HEARTBEAT: - print(f" [CHIRP] HEARTBEAT at {address}:{port}") - self._loop.call_soon_threadsafe( - self._heartbeat.add_satellite, - uuid, address, port, - f"Unknown-{str(uuid)[:8]}", - ) + elif service == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] MONITORING from {address}:{port}") + self._loop.call_soon_threadsafe( + self._cmdp_pool.add_socket, + uuid, address, port, + ) - elif service == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] MONITORING at {address}:{port}") - self._loop.call_soon_threadsafe( - self._cmdp_pool.add_socket, - uuid, address, port, - ) + elif service == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] CONTROL from {address}:{port}") + self._setup_transmitter(uuid, address, port) - elif service == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] CONTROL at {address}:{port}") - self._setup_transmitter(uuid, address, port) + elif msg.msgtype == CHIRPMessageType.DEPART: + if uuid not in self._discovered: + continue + + self._discovered[uuid].pop(service, None) + + if service == CHIRPServiceIdentifier.HEARTBEAT: + print(f" [CHIRP] DEPART HEARTBEAT {uuid}") + self._loop.call_soon_threadsafe( + self._heartbeat.remove_satellite, uuid + ) + + elif service == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] DEPART MONITORING {uuid}") + self._loop.call_soon_threadsafe( + self._cmdp_pool.remove_socket, uuid + ) + + elif service == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] DEPART CONTROL {uuid}") + self._cleanup_transmitter(uuid) beacon.close() - def _setup_transmitter(self, uuid: UUID, address: str, port: int): - """Set up CSCP transmitter for a satellite (runs in CHIRP thread).""" + def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: + """Set up a CSCP transmitter for a satellite. Runs in the CHIRP thread.""" + socket = self._sync_ctx.socket(zmq.REQ) + socket.connect(f"tcp://{address}:{port}") + socket.setsockopt(zmq.LINGER, 2000) + socket.setsockopt(zmq.RCVTIMEO, 5000) try: - socket = self._sync_ctx.socket(zmq.REQ) - socket.connect(f"tcp://{address}:{port}") - socket.setsockopt(zmq.LINGER, 2000) - socket.setsockopt(zmq.RCVTIMEO, 5000) - ct = CommandTransmitter("CombinedBridge.Bridge", socket) - - # Get canonical name msg = ct.request_get_response("get_commands") canonical_name = msg.sender - with self._transmitter_lock: self._transmitters[canonical_name] = ct - + self._transmitter_uuids[uuid] = canonical_name print(f" [CSCP] Connected to {canonical_name}") except Exception as e: - print(f" [CSCP] Failed to connect: {e}") + socket.close() + print(f" [CSCP] Failed to connect to {address}:{port}: {e}") + + def _cleanup_transmitter(self, uuid: UUID) -> None: + """Remove and close a CSCP transmitter on satellite departure.""" + with self._transmitter_lock: + canonical_name = self._transmitter_uuids.pop(uuid, None) + if canonical_name is None: + return + ct = self._transmitters.pop(canonical_name, None) + if ct is not None: + ct.close() async def main(): group = "test" - loop = asyncio.get_running_loop() bridge = CombinedBridge(group, loop) @@ -235,14 +260,10 @@ async def main(): print() bridge.start() - - # Wait for discovery await asyncio.sleep(2) - # Run async components in background run_task = asyncio.create_task(bridge.run()) - # Wait for a satellite to be discovered print("Waiting for satellite discovery...") for _ in range(20): if bridge._transmitters: @@ -251,6 +272,12 @@ async def main(): if not bridge._transmitters: print("No satellites found. Make sure PyRandomTransmitter is running.") + bridge._stop.set() + run_task.cancel() + try: + await run_task + except asyncio.CancelledError: + pass bridge.shutdown() return @@ -259,7 +286,6 @@ async def main(): print(f"Current states: {bridge._heartbeat.states}") print() - # Send commands through the bridge print("--- Sending commands ---") result = await bridge.send_command(sat_name, "initialize", {}) @@ -277,17 +303,18 @@ async def main(): await asyncio.sleep(2) print(f" States: {bridge._heartbeat.states}") - # Let it run a bit more to collect remaining messages await asyncio.sleep(3) - # Shutdown - bridge.shutdown() + # Cancel run task before shutdown to avoid context termination + # while async components are still polling run_task.cancel() try: await run_task except asyncio.CancelledError: pass + bridge.shutdown() + print() print("--- Results ---") print(f" State changes detected: {bridge.stats['state_changes']}") From 5683bdee4dda1e95cb0c50f44cfda1fe3db86084 Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Thu, 30 Apr 2026 04:33:28 +0200 Subject: [PATCH 03/12] Refactor async components: pool backed heartbeat receiver, fix subscribe semantics and CSCP thread safety --- .../async_experimental/async_heartbeat.py | 151 ++++++++---------- .../core/async_experimental/async_pools.py | 76 +++++++-- .../core/async_experimental/bridge.py | 85 +++++----- 3 files changed, 176 insertions(+), 136 deletions(-) diff --git a/python/constellation/core/async_experimental/async_heartbeat.py b/python/constellation/core/async_experimental/async_heartbeat.py index b0b6d8cd0..1fb38c8c0 100644 --- a/python/constellation/core/async_experimental/async_heartbeat.py +++ b/python/constellation/core/async_experimental/async_heartbeat.py @@ -1,20 +1,22 @@ import asyncio import time +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime -from typing import Callable from uuid import UUID -import zmq import zmq.asyncio from constellation.core.chp import CHPRole, chp_decode_message from constellation.core.protocol.cscp1 import SatelliteState +from .async_pools import AsyncSubscriberPool + @dataclass class HeartbeatState: """Tracked state for a single satellite.""" + host: UUID name: str state: SatelliteState = SatelliteState.DEAD @@ -32,7 +34,15 @@ def seconds_since_refresh(self) -> float: class AsyncHeartbeatReceiver: - """Async heartbeat receiver with lives/stale connection tracking. Call add_satellite() via call_soon_threadsafe() from threads.""" + """Async heartbeat receiver backed by AsyncSubscriberPool. + + Socket management is delegated to an internal AsyncSubscriberPool. + CHP has no topic filtering so the pool subscribes to "" for all sockets, + applied automatically to every socket added via add_satellite(). + + Call add_satellite() and remove_satellite() via + loop.call_soon_threadsafe() from the CHIRP thread. + """ INIT_LIVES = 3 INIT_INTERVAL = 2000 @@ -42,114 +52,100 @@ def __init__( ctx: zmq.asyncio.Context, on_state_change: Callable[[str, SatelliteState, SatelliteState], None] | None = None, on_satellite_dead: Callable[[str], None] | None = None, - ): - self._ctx = ctx + ) -> None: self._on_state_change = on_state_change self._on_satellite_dead = on_satellite_dead - self._sockets: dict[UUID, zmq.asyncio.Socket] = {} self._states: dict[UUID, HeartbeatState] = {} - self._poller = zmq.asyncio.Poller() + self._name_to_uuid: dict[str, UUID] = {} + + self._pool = AsyncSubscriberPool(ctx, self._on_frames) + self._pool.set_topics([""]) def add_satellite(self, uuid: UUID, address: str, port: int, name: str) -> None: - """Add a satellite to track. Called from CHIRP discovery.""" - if uuid in self._sockets: + """Register a satellite for heartbeat tracking.""" + if uuid in self._states: return - - sock = self._ctx.socket(zmq.SUB) - sock.connect(f"tcp://{address}:{port}") - sock.setsockopt_string(zmq.SUBSCRIBE, "") - sock.setsockopt(zmq.LINGER, 0) - - self._poller.register(sock, zmq.POLLIN) - self._sockets[uuid] = sock + self._pool.add_socket(uuid, address, port) self._states[uuid] = HeartbeatState( host=uuid, name=name, lives=self.INIT_LIVES, interval_ms=self.INIT_INTERVAL, ) + self._name_to_uuid[name] = uuid def remove_satellite(self, uuid: UUID) -> None: - """Remove a satellite from tracking.""" - sock = self._sockets.pop(uuid, None) - if sock is not None: - self._poller.unregister(sock) - sock.close() - self._states.pop(uuid, None) + """Deregister a satellite and close its socket.""" + self._pool.remove_socket(uuid) + hb = self._states.pop(uuid, None) + if hb is not None: + self._name_to_uuid.pop(hb.name, None) @property def states(self) -> dict[str, SatelliteState]: - """Get current states keyed by lowercase name.""" + """Current states keyed by lowercase canonical name.""" return {hb.name.lower(): hb.state for hb in self._states.values()} @property def state_changes(self) -> dict[str, datetime]: - """Get last state change times keyed by name.""" + """Last state change timestamps keyed by canonical name.""" return {hb.name: hb.last_statechange for hb in self._states.values()} async def run(self, stop: asyncio.Event) -> None: - """Main polling loop. Runs until stop is set.""" - last_check = time.monotonic() + """Run pool polling and stale check loop concurrently until stop is set.""" + await asyncio.gather( + self._pool.run(stop), + self._stale_check_loop(stop), + ) + async def _stale_check_loop(self, stop: asyncio.Event) -> None: + """Check for missed heartbeats every 300ms.""" while not stop.is_set(): - if self._sockets: - events = dict(await self._poller.poll(timeout=50)) - for uuid, sock in list(self._sockets.items()): - if sock in events: - try: - msg = await sock.recv_multipart() - self._process_heartbeat(uuid, msg) - except zmq.Again: - pass - - now = time.monotonic() - if (now - last_check) > 0.3: - self._check_stale_connections() - last_check = now - else: - await asyncio.sleep(0.05) - - def _process_heartbeat(self, uuid: UUID, msg: list[bytes]) -> None: - """Process a received heartbeat message.""" + await asyncio.sleep(0.3) + self._check_stale_connections() + + def _on_frames(self, uuid: UUID, frames: list[bytes]) -> None: + """Process a CHP frame delivered by the pool.""" try: - name, timestamp, state_val, flags, interval, status = chp_decode_message(msg) - state = SatelliteState(state_val) + name, _timestamp, state_val, flags, interval, _status = chp_decode_message(frames) + except Exception: + return - hb = self._states.get(uuid) - if hb is None: - return + hb = self._states.get(uuid) + if hb is None: + return - if hb.name != name: - hb.name = name + # Update name if it was unknown + if hb.name != name: + self._name_to_uuid.pop(hb.name, None) + hb.name = name + self._name_to_uuid[name] = uuid - if state != hb.state: - old_state = hb.state - hb.state = state - hb.last_statechange = datetime.now() - if self._on_state_change: - self._on_state_change(name, old_state, state) + state = SatelliteState(state_val) - hb.refresh() - hb.interval_ms = interval - hb.role = CHPRole.from_flags(flags) + if state != hb.state: + old_state = hb.state + hb.state = state + hb.last_statechange = datetime.now() + if self._on_state_change: + self._on_state_change(name, old_state, state) - if hb.lives != self.INIT_LIVES: - hb.lives = self.INIT_LIVES + hb.refresh() + hb.interval_ms = interval + hb.role = CHPRole.from_flags(flags) - except Exception: - pass + if hb.lives != self.INIT_LIVES: + hb.lives = self.INIT_LIVES def _check_stale_connections(self) -> None: - """Check for satellites that have missed heartbeats.""" - for uuid, hb in list(self._states.items()): + """Decrement lives for satellites that have missed heartbeats.""" + for _uuid, hb in list(self._states.items()): if hb.lives <= 0: continue - - expected_interval = (hb.interval_ms / 1000) * 1.5 - if hb.seconds_since_refresh() > expected_interval: + expected = (hb.interval_ms / 1000) * 1.5 + if hb.seconds_since_refresh() > expected: hb.lives -= 1 - if hb.lives == 0: if self._on_satellite_dead: self._on_satellite_dead(hb.name) @@ -158,12 +154,7 @@ def _check_stale_connections(self) -> None: hb.refresh() def close(self) -> None: - """Close all sockets.""" - for sock in self._sockets.values(): - try: - self._poller.unregister(sock) - except Exception: - pass - sock.close() - self._sockets.clear() + """Close all sockets and clear state.""" + self._pool.close() self._states.clear() + self._name_to_uuid.clear() diff --git a/python/constellation/core/async_experimental/async_pools.py b/python/constellation/core/async_experimental/async_pools.py index a4c54dfb0..c0a1e2a95 100644 --- a/python/constellation/core/async_experimental/async_pools.py +++ b/python/constellation/core/async_experimental/async_pools.py @@ -7,39 +7,80 @@ class AsyncSubscriberPool: - """Async ZMQ subscriber pool. Call all methods from the event loop; use call_soon_threadsafe() from threads.""" + """Async ZMQ SUB socket pool using zmq.asyncio. + + All methods must be called from the event loop. Use + loop.call_soon_threadsafe() when calling from other threads. + """ def __init__( self, ctx: zmq.asyncio.Context, - callback: Callable[[list[bytes]], None], + callback: Callable[[UUID, list[bytes]], None], ) -> None: self._ctx = ctx self._callback = callback self._sockets: dict[UUID, zmq.asyncio.Socket] = {} + self._socket_to_uuid: dict[zmq.asyncio.Socket, UUID] = {} self._poller = zmq.asyncio.Poller() self._topics: list[str] = [] def add_socket(self, uuid: UUID, address: str, port: int) -> None: - """Add a subscriber socket for a discovered satellite.""" + """Add a subscriber socket and apply all current global topics.""" if uuid in self._sockets: return sock = self._ctx.socket(zmq.SUB) sock.connect(f"tcp://{address}:{port}") + sock.setsockopt(zmq.LINGER, 0) for topic in self._topics: sock.setsockopt_string(zmq.SUBSCRIBE, topic) self._poller.register(sock, zmq.POLLIN) self._sockets[uuid] = sock + self._socket_to_uuid[sock] = uuid def remove_socket(self, uuid: UUID) -> None: - """Remove a subscriber socket for a departing satellite.""" + """Remove and close the subscriber socket for a departing satellite.""" sock = self._sockets.pop(uuid, None) if sock is not None: + self._socket_to_uuid.pop(sock, None) self._poller.unregister(sock) sock.close() + def subscribe(self, topic: str, uuid: UUID | None = None) -> None: + """Subscribe to a topic on one or all sockets. + + When uuid is None, topic is added to _topics so future sockets added + via add_socket() are automatically subscribed. When uuid is given, + only that socket is subscribed and _topics is not modified. + """ + if uuid is None: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + if topic not in self._topics: + self._topics.append(topic) + else: + sock = self._sockets.get(uuid) + if sock is not None: + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + + def unsubscribe(self, topic: str, uuid: UUID | None = None) -> None: + """Unsubscribe from a topic on one or all sockets. + + When uuid is None, topic is removed from _topics. When uuid is given, + only that socket is unsubscribed and _topics is not modified. + """ + if uuid is None: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + if topic in self._topics: + self._topics.remove(topic) + else: + sock = self._sockets.get(uuid) + if sock is not None: + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + def set_topics(self, new_topics: list[str]) -> None: - """Replace all topic subscriptions across all sockets.""" + """Replace all global subscriptions across all sockets.""" old_set = set(self._topics) new_set = set(new_topics) for topic in old_set - new_set: @@ -51,18 +92,24 @@ def set_topics(self, new_topics: list[str]) -> None: self._topics = list(new_topics) async def run(self, stop: asyncio.Event) -> None: - """Poll loop. Runs until stop is set.""" + """Poll all sockets until stop is set.""" while not stop.is_set(): if not self._sockets: - try: - await asyncio.wait_for(stop.wait(), timeout=0.1) - break - except asyncio.TimeoutError: - continue - events = dict(await self._poller.poll(timeout=200)) + await asyncio.sleep(0.05) + continue + events = dict(await self._poller.poll(timeout=50)) for sock in events: - msg = await sock.recv_multipart() - self._callback(msg) + uuid = self._socket_to_uuid.get(sock) + if uuid is None: + continue + try: + msg = await sock.recv_multipart() + self._callback(uuid, msg) + while sock.getsockopt(zmq.EVENTS) & zmq.POLLIN: + msg = await sock.recv_multipart() + self._callback(uuid, msg) + except zmq.ZMQError: + pass def close(self) -> None: """Close all sockets.""" @@ -73,3 +120,4 @@ def close(self) -> None: pass sock.close() self._sockets.clear() + self._socket_to_uuid.clear() diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py index 81298438d..1aa49a6ce 100644 --- a/python/constellation/core/async_experimental/bridge.py +++ b/python/constellation/core/async_experimental/bridge.py @@ -1,6 +1,7 @@ import asyncio import threading import time +from concurrent.futures import ThreadPoolExecutor from uuid import UUID import zmq @@ -13,23 +14,25 @@ ) from constellation.core.cscp import CommandTransmitter from constellation.core.message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage -from constellation.core.protocol.cscp1 import SatelliteState from constellation.core.network import get_interface_addresses -from .async_pools import AsyncSubscriberPool from .async_heartbeat import AsyncHeartbeatReceiver +from .async_pools import AsyncSubscriberPool class CombinedBridge: - """Combined async bridge - CHIRP discovery (thread), everything else in the asyncio event loop via asyncio.gather.""" + """Combined async bridge. + + CHIRP discovery runs in a dedicated thread since it uses raw UDP multicast. + Heartbeat tracking, CMDP receiving, and CSCP commands all run in the + asyncio event loop. + """ - def __init__(self, group: str, loop: asyncio.AbstractEventLoop): + def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: self.group = group self._loop = loop - # Async ZMQ context for heartbeat/CMDP (SUB sockets, async polling) self._ctx = zmq.asyncio.Context() - # Sync ZMQ context for CSCP (REQ/REP is blocking, used via to_thread) self._sync_ctx = zmq.Context() self._stop = asyncio.Event() @@ -44,16 +47,6 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop): self._ctx, callback=self._on_cmdp_message, ) - - # CSCP transmitters keyed by canonical name - self._transmitters: dict[str, CommandTransmitter] = {} - # Maps UUID -> canonical name for DEPART handling - self._transmitter_uuids: dict[UUID, str] = {} - self._transmitter_lock = threading.Lock() - - # Track discovered services per UUID to avoid duplicate setup - self._discovered: dict[UUID, dict] = {} - self._cmdp_pool.set_topics([ "LOG/INFO", "LOG/WARNING", @@ -62,6 +55,13 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop): "STAT/", ]) + self._transmitters: dict[str, CommandTransmitter] = {} + self._transmitter_uuids: dict[UUID, str] = {} + self._transmitter_lock = threading.Lock() + self._cscp_executor = ThreadPoolExecutor(max_workers=1) + + self._discovered: dict[UUID, dict] = {} + self.stats = { "state_changes": 0, "logs": 0, @@ -71,10 +71,7 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop): def start(self) -> None: """Start the CHIRP discovery thread.""" - self._chirp_thread = threading.Thread( - target=self._chirp_loop, - daemon=True, - ) + self._chirp_thread = threading.Thread(target=self._chirp_loop, daemon=True) self._chirp_thread.start() async def run(self) -> None: @@ -85,27 +82,38 @@ async def run(self) -> None: ) async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str: - """Send a CSCP command via asyncio.to_thread. The transmitter lock ensures the REQ socket is never accessed from two threads simultaneously.""" - def _send(): + """Send a CSCP command via a dedicated single-thread executor. + + The executor serializes all REQ socket operations so concurrent + send_command calls never interleave on the same socket. + """ + def _send() -> str: + # Lock protects _transmitters dict which is modified from CHIRP thread with self._transmitter_lock: ct = self._transmitters.get(canonical_name) if ct is None: return f"ERROR: No transmitter for {canonical_name}" try: msg = ct.request_get_response(command=cmd, payload=payload) - return f"{msg.verb}: {msg.verb_msg}" + return f"{msg.verb_msg}" except Exception as e: return f"ERROR: {e}" self.stats["commands_sent"] += 1 - return await asyncio.to_thread(_send) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(self._cscp_executor, _send) def shutdown(self) -> None: - """Stop all components. Cancel and await run() before calling this to avoid terminating contexts while sockets are still open.""" + """Stop all components. + + Cancel and await run() before calling this to avoid terminating + contexts while sockets are still open. + """ self._stop.set() self._stop_thread.set() if hasattr(self, "_chirp_thread"): self._chirp_thread.join(timeout=2) + self._cscp_executor.shutdown(wait=True) self._heartbeat.close() self._cmdp_pool.close() with self._transmitter_lock: @@ -114,25 +122,22 @@ def shutdown(self) -> None: self._ctx.term() self._sync_ctx.term() - def _on_state_change(self, name: str, old_state: SatelliteState, - new_state: SatelliteState) -> None: + def _on_state_change(self, name: str, old_state, new_state) -> None: self.stats["state_changes"] += 1 print(f" [STATE] {name}: {old_state.name} -> {new_state.name}") def _on_satellite_dead(self, name: str) -> None: print(f" [DEAD] {name} stopped sending heartbeats") - def _on_cmdp_message(self, frames: list[bytes]) -> None: + def _on_cmdp_message(self, uuid: UUID, frames: list[bytes]) -> None: try: msg = CMDP1Message.disassemble(frames) if msg.is_log_message(): log_msg = CMDP1LogMessage.from_cmdp_message(msg) record = log_msg.to_log_record() - level = record.levelname sender = getattr(record, "sender", record.name) - message = record.getMessage() self.stats["logs"] += 1 - print(f" [LOG] [{level:8s}] {sender}: {message}") + print(f" [LOG] [{record.levelname:8s}] {sender}: {record.getMessage()}") elif msg.is_stat_message(): stat_msg = CMDP1StatMessage.from_cmdp_message(msg) self.stats["metrics"] += 1 @@ -141,9 +146,7 @@ def _on_cmdp_message(self, frames: list[bytes]) -> None: pass def _chirp_loop(self) -> None: - """Discover and track satellites via CHIRP. Runs in a dedicated thread.""" - from constellation.core.chirp import get_uuid - + """Discover and track satellites via CHIRP.""" beacon = CHIRPBeaconTransmitter( "CombinedBridge.Bridge", self.group, @@ -179,11 +182,11 @@ def _chirp_loop(self) -> None: self._discovered[uuid][service] = (address, port) if service == CHIRPServiceIdentifier.HEARTBEAT: + name = self._transmitter_uuids.get(uuid, f"Unknown-{str(uuid)[:8]}") print(f" [CHIRP] HEARTBEAT from {address}:{port}") self._loop.call_soon_threadsafe( self._heartbeat.add_satellite, - uuid, address, port, - f"Unknown-{str(uuid)[:8]}", + uuid, address, port, name, ) elif service == CHIRPServiceIdentifier.MONITORING: @@ -206,13 +209,13 @@ def _chirp_loop(self) -> None: if service == CHIRPServiceIdentifier.HEARTBEAT: print(f" [CHIRP] DEPART HEARTBEAT {uuid}") self._loop.call_soon_threadsafe( - self._heartbeat.remove_satellite, uuid + self._heartbeat.remove_satellite, uuid, ) elif service == CHIRPServiceIdentifier.MONITORING: print(f" [CHIRP] DEPART MONITORING {uuid}") self._loop.call_soon_threadsafe( - self._cmdp_pool.remove_socket, uuid + self._cmdp_pool.remove_socket, uuid, ) elif service == CHIRPServiceIdentifier.CONTROL: @@ -222,7 +225,7 @@ def _chirp_loop(self) -> None: beacon.close() def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: - """Set up a CSCP transmitter for a satellite. Runs in the CHIRP thread.""" + """Set up a CSCP transmitter for a satellite (runs in CHIRP thread).""" socket = self._sync_ctx.socket(zmq.REQ) socket.connect(f"tcp://{address}:{port}") socket.setsockopt(zmq.LINGER, 2000) @@ -250,7 +253,7 @@ def _cleanup_transmitter(self, uuid: UUID) -> None: ct.close() -async def main(): +async def main() -> None: group = "test" loop = asyncio.get_running_loop() bridge = CombinedBridge(group, loop) @@ -305,8 +308,6 @@ async def main(): await asyncio.sleep(3) - # Cancel run task before shutdown to avoid context termination - # while async components are still polling run_task.cancel() try: await run_task From 0eecc3f97e513e244c23d81849015066fa90c87d Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Fri, 1 May 2026 01:37:46 +0200 Subject: [PATCH 04/12] Add async CHIRP using DatagramProtocol, eliminating the dedicated discovery thread --- .../core/async_experimental/async_chirp.py | 115 ++++ .../core/async_experimental/bridge.py | 642 +++++++++--------- 2 files changed, 428 insertions(+), 329 deletions(-) create mode 100644 python/constellation/core/async_experimental/async_chirp.py diff --git a/python/constellation/core/async_experimental/async_chirp.py b/python/constellation/core/async_experimental/async_chirp.py new file mode 100644 index 000000000..ff7bd332d --- /dev/null +++ b/python/constellation/core/async_experimental/async_chirp.py @@ -0,0 +1,115 @@ +import asyncio +import socket +import sys +from collections.abc import Callable +from uuid import UUID + +from constellation.core.chirp import ( + CHIRP_MULTICAST_ADDRESS, + CHIRP_PORT, + CHIRPMessage, + CHIRPMessageType, + CHIRPServiceIdentifier, + get_uuid, +) +from constellation.core.multicast import MULTICAST_TTL + + +class AsyncCHIRPProtocol(asyncio.DatagramProtocol): + """Async CHIRP discovery using asyncio.DatagramProtocol. + + The receive socket is handed to the event loop via create_datagram_endpoint + so datagram_received runs on the event loop. Send sockets use synchronous + sendto, which is acceptable for UDP since it writes to the kernel buffer + without blocking. + + on_offer and on_depart are called directly from datagram_received and must + not block. Schedule any blocking work as a task from the caller. + """ + + def __init__( + self, + name: str, + group: str, + interface_addresses: list[str], + on_offer: Callable[[UUID, str, int, CHIRPServiceIdentifier], None], + on_depart: Callable[[UUID, CHIRPServiceIdentifier], None], + ) -> None: + self._host_uuid = get_uuid(name) + self._group_uuid = get_uuid(group) + self._on_offer = on_offer + self._on_depart = on_depart + self._multicast_endpoint = (CHIRP_MULTICAST_ADDRESS, CHIRP_PORT) + self._send_sockets = self._create_send_sockets(interface_addresses) + self._transport: asyncio.DatagramTransport | None = None + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + self._transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + msg = CHIRPMessage() + try: + msg.unpack(data) + except Exception: + return + + if msg.host_uuid == self._host_uuid: + return + if msg.group_uuid != self._group_uuid: + return + + msg.from_address = addr[0] + + if msg.msgtype == CHIRPMessageType.REQUEST: + return + + if msg.msgtype == CHIRPMessageType.OFFER: + self._on_offer(msg.host_uuid, msg.from_address, msg.port, msg.serviceid) + elif msg.msgtype == CHIRPMessageType.DEPART and msg.port != 0: + self._on_depart(msg.host_uuid, msg.serviceid) + + def emit(self, serviceid: CHIRPServiceIdentifier, msgtype: CHIRPMessageType, port: int = 0) -> None: + """Send a CHIRP message on all interfaces.""" + msg = CHIRPMessage(msgtype, self._group_uuid, self._host_uuid, serviceid, port) + packed = msg.pack() + for sock in self._send_sockets: + sock.sendto(packed, self._multicast_endpoint) + + def close(self) -> None: + """Close send sockets and the transport.""" + for sock in self._send_sockets: + sock.close() + if self._transport is not None: + self._transport.close() + + @staticmethod + def create_recv_socket(interface_addresses: list[str]) -> socket.socket: + """Create and configure the multicast receive socket for asyncio.""" + recv_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if sys.platform == "darwin": + recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL) + recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) + recv_socket.bind(("0.0.0.0", CHIRP_PORT)) + for interface_address in interface_addresses: + ip_mreq = socket.inet_aton(CHIRP_MULTICAST_ADDRESS) + socket.inet_aton(interface_address) + recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, ip_mreq) + recv_socket.setblocking(False) + return recv_socket + + @staticmethod + def _create_send_sockets(interface_addresses: list[str]) -> list[socket.socket]: + send_sockets = [] + for interface_address in interface_addresses: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL) + sock.setsockopt( + socket.IPPROTO_IP, + socket.IP_MULTICAST_LOOP, + 0 if interface_address != "127.0.0.1" else 1, + ) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface_address)) + send_sockets.append(sock) + return send_sockets diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py index 1aa49a6ce..887c3706e 100644 --- a/python/constellation/core/async_experimental/bridge.py +++ b/python/constellation/core/async_experimental/bridge.py @@ -1,329 +1,313 @@ -import asyncio -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from uuid import UUID - -import zmq -import zmq.asyncio - -from constellation.core.chirp import ( - CHIRPBeaconTransmitter, - CHIRPMessageType, - CHIRPServiceIdentifier, -) -from constellation.core.cscp import CommandTransmitter -from constellation.core.message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage -from constellation.core.network import get_interface_addresses - -from .async_heartbeat import AsyncHeartbeatReceiver -from .async_pools import AsyncSubscriberPool - - -class CombinedBridge: - """Combined async bridge. - - CHIRP discovery runs in a dedicated thread since it uses raw UDP multicast. - Heartbeat tracking, CMDP receiving, and CSCP commands all run in the - asyncio event loop. - """ - - def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: - self.group = group - self._loop = loop - - self._ctx = zmq.asyncio.Context() - self._sync_ctx = zmq.Context() - - self._stop = asyncio.Event() - self._stop_thread = threading.Event() - - self._heartbeat = AsyncHeartbeatReceiver( - self._ctx, - on_state_change=self._on_state_change, - on_satellite_dead=self._on_satellite_dead, - ) - self._cmdp_pool = AsyncSubscriberPool( - self._ctx, - callback=self._on_cmdp_message, - ) - self._cmdp_pool.set_topics([ - "LOG/INFO", - "LOG/WARNING", - "LOG/STATUS", - "LOG/CRITICAL", - "STAT/", - ]) - - self._transmitters: dict[str, CommandTransmitter] = {} - self._transmitter_uuids: dict[UUID, str] = {} - self._transmitter_lock = threading.Lock() - self._cscp_executor = ThreadPoolExecutor(max_workers=1) - - self._discovered: dict[UUID, dict] = {} - - self.stats = { - "state_changes": 0, - "logs": 0, - "metrics": 0, - "commands_sent": 0, - } - - def start(self) -> None: - """Start the CHIRP discovery thread.""" - self._chirp_thread = threading.Thread(target=self._chirp_loop, daemon=True) - self._chirp_thread.start() - - async def run(self) -> None: - """Run heartbeat and CMDP components concurrently.""" - await asyncio.gather( - self._heartbeat.run(self._stop), - self._cmdp_pool.run(self._stop), - ) - - async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str: - """Send a CSCP command via a dedicated single-thread executor. - - The executor serializes all REQ socket operations so concurrent - send_command calls never interleave on the same socket. - """ - def _send() -> str: - # Lock protects _transmitters dict which is modified from CHIRP thread - with self._transmitter_lock: - ct = self._transmitters.get(canonical_name) - if ct is None: - return f"ERROR: No transmitter for {canonical_name}" - try: - msg = ct.request_get_response(command=cmd, payload=payload) - return f"{msg.verb_msg}" - except Exception as e: - return f"ERROR: {e}" - - self.stats["commands_sent"] += 1 - loop = asyncio.get_running_loop() - return await loop.run_in_executor(self._cscp_executor, _send) - - def shutdown(self) -> None: - """Stop all components. - - Cancel and await run() before calling this to avoid terminating - contexts while sockets are still open. - """ - self._stop.set() - self._stop_thread.set() - if hasattr(self, "_chirp_thread"): - self._chirp_thread.join(timeout=2) - self._cscp_executor.shutdown(wait=True) - self._heartbeat.close() - self._cmdp_pool.close() - with self._transmitter_lock: - for ct in self._transmitters.values(): - ct.close() - self._ctx.term() - self._sync_ctx.term() - - def _on_state_change(self, name: str, old_state, new_state) -> None: - self.stats["state_changes"] += 1 - print(f" [STATE] {name}: {old_state.name} -> {new_state.name}") - - def _on_satellite_dead(self, name: str) -> None: - print(f" [DEAD] {name} stopped sending heartbeats") - - def _on_cmdp_message(self, uuid: UUID, frames: list[bytes]) -> None: - try: - msg = CMDP1Message.disassemble(frames) - if msg.is_log_message(): - log_msg = CMDP1LogMessage.from_cmdp_message(msg) - record = log_msg.to_log_record() - sender = getattr(record, "sender", record.name) - self.stats["logs"] += 1 - print(f" [LOG] [{record.levelname:8s}] {sender}: {record.getMessage()}") - elif msg.is_stat_message(): - stat_msg = CMDP1StatMessage.from_cmdp_message(msg) - self.stats["metrics"] += 1 - print(f" [STAT] {stat_msg.metric.name} = {stat_msg.value}{stat_msg.metric.unit}") - except Exception: - pass - - def _chirp_loop(self) -> None: - """Discover and track satellites via CHIRP.""" - beacon = CHIRPBeaconTransmitter( - "CombinedBridge.Bridge", - self.group, - get_interface_addresses(None), - ) - - beacon.emit(CHIRPServiceIdentifier.HEARTBEAT, CHIRPMessageType.REQUEST) - time.sleep(0.1) - beacon.emit(CHIRPServiceIdentifier.MONITORING, CHIRPMessageType.REQUEST) - time.sleep(0.1) - beacon.emit(CHIRPServiceIdentifier.CONTROL, CHIRPMessageType.REQUEST) - - while not self._stop_thread.is_set(): - msg = beacon.listen() - if msg is None: - continue - - if msg.msgtype == CHIRPMessageType.REQUEST: - continue - - uuid = msg.host_uuid - address = msg.from_address - port = msg.port - service = msg.serviceid - - if msg.msgtype == CHIRPMessageType.OFFER: - if uuid not in self._discovered: - self._discovered[uuid] = {} - - if service in self._discovered[uuid]: - continue - - self._discovered[uuid][service] = (address, port) - - if service == CHIRPServiceIdentifier.HEARTBEAT: - name = self._transmitter_uuids.get(uuid, f"Unknown-{str(uuid)[:8]}") - print(f" [CHIRP] HEARTBEAT from {address}:{port}") - self._loop.call_soon_threadsafe( - self._heartbeat.add_satellite, - uuid, address, port, name, - ) - - elif service == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] MONITORING from {address}:{port}") - self._loop.call_soon_threadsafe( - self._cmdp_pool.add_socket, - uuid, address, port, - ) - - elif service == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] CONTROL from {address}:{port}") - self._setup_transmitter(uuid, address, port) - - elif msg.msgtype == CHIRPMessageType.DEPART: - if uuid not in self._discovered: - continue - - self._discovered[uuid].pop(service, None) - - if service == CHIRPServiceIdentifier.HEARTBEAT: - print(f" [CHIRP] DEPART HEARTBEAT {uuid}") - self._loop.call_soon_threadsafe( - self._heartbeat.remove_satellite, uuid, - ) - - elif service == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] DEPART MONITORING {uuid}") - self._loop.call_soon_threadsafe( - self._cmdp_pool.remove_socket, uuid, - ) - - elif service == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] DEPART CONTROL {uuid}") - self._cleanup_transmitter(uuid) - - beacon.close() - - def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: - """Set up a CSCP transmitter for a satellite (runs in CHIRP thread).""" - socket = self._sync_ctx.socket(zmq.REQ) - socket.connect(f"tcp://{address}:{port}") - socket.setsockopt(zmq.LINGER, 2000) - socket.setsockopt(zmq.RCVTIMEO, 5000) - try: - ct = CommandTransmitter("CombinedBridge.Bridge", socket) - msg = ct.request_get_response("get_commands") - canonical_name = msg.sender - with self._transmitter_lock: - self._transmitters[canonical_name] = ct - self._transmitter_uuids[uuid] = canonical_name - print(f" [CSCP] Connected to {canonical_name}") - except Exception as e: - socket.close() - print(f" [CSCP] Failed to connect to {address}:{port}: {e}") - - def _cleanup_transmitter(self, uuid: UUID) -> None: - """Remove and close a CSCP transmitter on satellite departure.""" - with self._transmitter_lock: - canonical_name = self._transmitter_uuids.pop(uuid, None) - if canonical_name is None: - return - ct = self._transmitters.pop(canonical_name, None) - if ct is not None: - ct.close() - - -async def main() -> None: - group = "test" - loop = asyncio.get_running_loop() - bridge = CombinedBridge(group, loop) - - print(f"Combined Bridge for group '{group}'") - print(f"Architecture: 1 thread (CHIRP) + asyncio event loop") - print() - - bridge.start() - await asyncio.sleep(2) - - run_task = asyncio.create_task(bridge.run()) - - print("Waiting for satellite discovery...") - for _ in range(20): - if bridge._transmitters: - break - await asyncio.sleep(0.5) - - if not bridge._transmitters: - print("No satellites found. Make sure PyRandomTransmitter is running.") - bridge._stop.set() - run_task.cancel() - try: - await run_task - except asyncio.CancelledError: - pass - bridge.shutdown() - return - - sat_name = list(bridge._transmitters.keys())[0] - print(f"\nFound: {sat_name}") - print(f"Current states: {bridge._heartbeat.states}") - print() - - print("--- Sending commands ---") - - result = await bridge.send_command(sat_name, "initialize", {}) - print(f" initialize -> {result}") - await asyncio.sleep(2) - print(f" States: {bridge._heartbeat.states}") - - result = await bridge.send_command(sat_name, "launch") - print(f" launch -> {result}") - await asyncio.sleep(2) - print(f" States: {bridge._heartbeat.states}") - - result = await bridge.send_command(sat_name, "land") - print(f" land -> {result}") - await asyncio.sleep(2) - print(f" States: {bridge._heartbeat.states}") - - await asyncio.sleep(3) - - run_task.cancel() - try: - await run_task - except asyncio.CancelledError: - pass - - bridge.shutdown() - - print() - print("--- Results ---") - print(f" State changes detected: {bridge.stats['state_changes']}") - print(f" Log messages received: {bridge.stats['logs']}") - print(f" Metrics received: {bridge.stats['metrics']}") - print(f" Commands sent: {bridge.stats['commands_sent']}") - print(f" Architecture: 1 thread (CHIRP) + asyncio event loop") - - -if __name__ == "__main__": - asyncio.run(main()) +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from uuid import UUID + +import zmq +import zmq.asyncio + +from constellation.core.chirp import CHIRPMessageType, CHIRPServiceIdentifier +from constellation.core.cscp import CommandTransmitter +from constellation.core.message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage +from constellation.core.network import get_interface_addresses + +from .async_chirp import AsyncCHIRPProtocol +from .async_heartbeat import AsyncHeartbeatReceiver +from .async_pools import AsyncSubscriberPool + + +class CombinedBridge: + """Combined async bridge. + + All components run in the asyncio event loop. CHIRP uses + asyncio.DatagramProtocol so no dedicated thread is needed for discovery. + CSCP commands run in a single-thread executor to keep blocking REQ socket + operations off the event loop without risking concurrent socket access. + """ + + def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: + self.group = group + self._loop = loop + + self._ctx = zmq.asyncio.Context() + self._sync_ctx = zmq.Context() + + self._stop = asyncio.Event() + + self._heartbeat = AsyncHeartbeatReceiver( + self._ctx, + on_state_change=self._on_state_change, + on_satellite_dead=self._on_satellite_dead, + ) + self._cmdp_pool = AsyncSubscriberPool( + self._ctx, + callback=self._on_cmdp_message, + ) + self._cmdp_pool.set_topics([ + "LOG/INFO", + "LOG/WARNING", + "LOG/STATUS", + "LOG/CRITICAL", + "STAT/", + ]) + + self._transmitters: dict[str, CommandTransmitter] = {} + self._transmitter_uuids: dict[UUID, str] = {} + self._transmitter_lock = threading.Lock() + self._cscp_executor = ThreadPoolExecutor(max_workers=1) + + self._discovered: dict[UUID, dict] = {} + self._chirp_protocol: AsyncCHIRPProtocol | None = None + + self.stats = { + "state_changes": 0, + "logs": 0, + "metrics": 0, + "commands_sent": 0, + } + + async def start(self) -> None: + """Set up async CHIRP and emit initial service requests.""" + interface_addresses = get_interface_addresses(None) + recv_socket = AsyncCHIRPProtocol.create_recv_socket(interface_addresses) + + self._chirp_protocol = AsyncCHIRPProtocol( + name="CombinedBridge.Bridge", + group=self.group, + interface_addresses=interface_addresses, + on_offer=self._on_chirp_offer, + on_depart=self._on_chirp_depart, + ) + + await self._loop.create_datagram_endpoint( + lambda: self._chirp_protocol, + sock=recv_socket, + ) + + self._chirp_protocol.emit(CHIRPServiceIdentifier.HEARTBEAT, CHIRPMessageType.REQUEST) + await asyncio.sleep(0.1) + self._chirp_protocol.emit(CHIRPServiceIdentifier.MONITORING, CHIRPMessageType.REQUEST) + await asyncio.sleep(0.1) + self._chirp_protocol.emit(CHIRPServiceIdentifier.CONTROL, CHIRPMessageType.REQUEST) + + async def run(self) -> None: + """Run heartbeat and CMDP components concurrently.""" + await asyncio.gather( + self._heartbeat.run(self._stop), + self._cmdp_pool.run(self._stop), + ) + + async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str: + """Send a CSCP command via a dedicated single-thread executor. + + The executor serializes all REQ socket operations so concurrent + send_command calls never interleave on the same socket. + """ + def _send() -> str: + with self._transmitter_lock: + ct = self._transmitters.get(canonical_name) + if ct is None: + return f"ERROR: No transmitter for {canonical_name}" + try: + msg = ct.request_get_response(command=cmd, payload=payload) + return f"{msg.verb_msg}" + except Exception as e: + return f"ERROR: {e}" + + self.stats["commands_sent"] += 1 + return await self._loop.run_in_executor(self._cscp_executor, _send) + + def shutdown(self) -> None: + """Stop all components. + + Cancel and await run() before calling this to avoid terminating + contexts while sockets are still open. + """ + self._stop.set() + if self._chirp_protocol is not None: + self._chirp_protocol.close() + self._cscp_executor.shutdown(wait=True) + self._heartbeat.close() + self._cmdp_pool.close() + with self._transmitter_lock: + for ct in self._transmitters.values(): + ct.close() + self._ctx.term() + self._sync_ctx.term() + + def _on_chirp_offer(self, uuid: UUID, address: str, port: int, service: CHIRPServiceIdentifier) -> None: + """Handle a CHIRP OFFER. Called from datagram_received on the event loop.""" + if uuid not in self._discovered: + self._discovered[uuid] = {} + + if service in self._discovered[uuid]: + return + + self._discovered[uuid][service] = (address, port) + + if service == CHIRPServiceIdentifier.HEARTBEAT: + name = self._transmitter_uuids.get(uuid, f"Unknown-{str(uuid)[:8]}") + print(f" [CHIRP] HEARTBEAT from {address}:{port}") + self._heartbeat.add_satellite(uuid, address, port, name) + + elif service == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] MONITORING from {address}:{port}") + self._cmdp_pool.add_socket(uuid, address, port) + + elif service == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] CONTROL from {address}:{port}") + self._loop.create_task(self._setup_transmitter(uuid, address, port)) + + def _on_chirp_depart(self, uuid: UUID, service: CHIRPServiceIdentifier) -> None: + """Handle a CHIRP DEPART. Called from datagram_received on the event loop.""" + if uuid not in self._discovered: + return + + self._discovered[uuid].pop(service, None) + + if service == CHIRPServiceIdentifier.HEARTBEAT: + print(f" [CHIRP] DEPART HEARTBEAT {uuid}") + self._heartbeat.remove_satellite(uuid) + + elif service == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] DEPART MONITORING {uuid}") + self._cmdp_pool.remove_socket(uuid) + + elif service == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] DEPART CONTROL {uuid}") + self._cleanup_transmitter(uuid) + + def _on_state_change(self, name: str, old_state, new_state) -> None: + self.stats["state_changes"] += 1 + print(f" [STATE] {name}: {old_state.name} -> {new_state.name}") + + def _on_satellite_dead(self, name: str) -> None: + print(f" [DEAD] {name} stopped sending heartbeats") + + def _on_cmdp_message(self, uuid: UUID, frames: list[bytes]) -> None: + try: + msg = CMDP1Message.disassemble(frames) + if msg.is_log_message(): + log_msg = CMDP1LogMessage.from_cmdp_message(msg) + record = log_msg.to_log_record() + sender = getattr(record, "sender", record.name) + self.stats["logs"] += 1 + print(f" [LOG] [{record.levelname:8s}] {sender}: {record.getMessage()}") + elif msg.is_stat_message(): + stat_msg = CMDP1StatMessage.from_cmdp_message(msg) + self.stats["metrics"] += 1 + print(f" [STAT] {stat_msg.metric.name} = {stat_msg.value}{stat_msg.metric.unit}") + except Exception: + pass + + async def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: + """Connect to a satellite's CSCP port (runs blocking call in executor).""" + def _connect() -> tuple[str, CommandTransmitter] | None: + sock = self._sync_ctx.socket(zmq.REQ) + sock.connect(f"tcp://{address}:{port}") + sock.setsockopt(zmq.LINGER, 2000) + sock.setsockopt(zmq.RCVTIMEO, 5000) + try: + ct = CommandTransmitter("CombinedBridge.Bridge", sock) + msg = ct.request_get_response("get_commands") + return msg.sender, ct + except Exception as e: + sock.close() + print(f" [CSCP] Failed to connect to {address}:{port}: {e}") + return None + + result = await self._loop.run_in_executor(self._cscp_executor, _connect) + if result is None: + return + + canonical_name, ct = result + with self._transmitter_lock: + self._transmitters[canonical_name] = ct + self._transmitter_uuids[uuid] = canonical_name + print(f" [CSCP] Connected to {canonical_name}") + + def _cleanup_transmitter(self, uuid: UUID) -> None: + """Remove and close a CSCP transmitter on satellite departure.""" + with self._transmitter_lock: + canonical_name = self._transmitter_uuids.pop(uuid, None) + if canonical_name is None: + return + ct = self._transmitters.pop(canonical_name, None) + if ct is not None: + ct.close() + + +async def main() -> None: + group = "test" + loop = asyncio.get_running_loop() + bridge = CombinedBridge(group, loop) + + print(f"Combined Bridge for group '{group}'") + print(f"Architecture: asyncio event loop only (no dedicated threads)") + print() + + await bridge.start() + await asyncio.sleep(2) + + run_task = asyncio.create_task(bridge.run()) + + print("Waiting for satellite discovery...") + for _ in range(20): + if bridge._transmitters: + break + await asyncio.sleep(0.5) + + if not bridge._transmitters: + print("No satellites found. Make sure PyRandomTransmitter is running.") + bridge._stop.set() + run_task.cancel() + try: + await run_task + except asyncio.CancelledError: + pass + bridge.shutdown() + return + + sat_name = list(bridge._transmitters.keys())[0] + print(f"\nFound: {sat_name}") + print(f"Current states: {bridge._heartbeat.states}") + print() + + print("--- Sending commands ---") + + result = await bridge.send_command(sat_name, "initialize", {}) + print(f" initialize -> {result}") + await asyncio.sleep(2) + print(f" States: {bridge._heartbeat.states}") + + result = await bridge.send_command(sat_name, "launch") + print(f" launch -> {result}") + await asyncio.sleep(2) + print(f" States: {bridge._heartbeat.states}") + + result = await bridge.send_command(sat_name, "land") + print(f" land -> {result}") + await asyncio.sleep(2) + print(f" States: {bridge._heartbeat.states}") + + await asyncio.sleep(3) + + run_task.cancel() + try: + await run_task + except asyncio.CancelledError: + pass + + bridge.shutdown() + + print() + print("--- Results ---") + print(f" State changes detected: {bridge.stats['state_changes']}") + print(f" Log messages received: {bridge.stats['logs']}") + print(f" Metrics received: {bridge.stats['metrics']}") + print(f" Commands sent: {bridge.stats['commands_sent']}") + print(f" Architecture: asyncio event loop only (no dedicated threads)") + + +if __name__ == "__main__": + asyncio.run(main()) From 1e838e99cce5efbc019c1bc20718701c8510fa29 Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Wed, 20 May 2026 01:41:37 +0200 Subject: [PATCH 05/12] Restructure async CHIRP and apply MR 1131 casing fixes --- .../core/async_experimental/async_chirp.py | 276 ++++++++++++--- .../async_experimental/async_heartbeat.py | 329 +++++++++--------- .../core/async_experimental/bridge.py | 95 +++-- 3 files changed, 437 insertions(+), 263 deletions(-) diff --git a/python/constellation/core/async_experimental/async_chirp.py b/python/constellation/core/async_experimental/async_chirp.py index ff7bd332d..496627b3e 100644 --- a/python/constellation/core/async_experimental/async_chirp.py +++ b/python/constellation/core/async_experimental/async_chirp.py @@ -2,6 +2,8 @@ import socket import sys from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum, auto from uuid import UUID from constellation.core.chirp import ( @@ -15,16 +17,98 @@ from constellation.core.multicast import MULTICAST_TTL -class AsyncCHIRPProtocol(asyncio.DatagramProtocol): - """Async CHIRP discovery using asyncio.DatagramProtocol. +class CHIRPEvent(Enum): + """Event type for CHIRP service callbacks.""" - The receive socket is handed to the event loop via create_datagram_endpoint - so datagram_received runs on the event loop. Send sockets use synchronous - sendto, which is acceptable for UDP since it writes to the kernel buffer - without blocking. + SERVICE_CONNECTED = auto() + SERVICE_DISCONNECTED = auto() - on_offer and on_depart are called directly from datagram_received and must - not block. Schedule any blocking work as a task from the caller. + +@dataclass +class DiscoveredService: + """A service discovered via CHIRP.""" + + group_id: UUID + host_id: UUID + service_id: CHIRPServiceIdentifier + port: int + address: str + + +class AsyncMulticastSocket: + """Async-compatible multicast socket. + + Mirrors MulticastSocket but sets the receive socket non-blocking + so it can be handed to asyncio via create_datagram_endpoint. + Send sockets remain synchronous since UDP sendto writes to the + kernel buffer without blocking. + """ + + def __init__( + self, + interface_addresses: list[str], + multicast_address: str, + multicast_port: int, + ) -> None: + self._multicast_endpoint = (multicast_address, multicast_port) + + self._send_sockets: list[socket.socket] = [] + for interface_address in interface_addresses: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL) + sock.setsockopt( + socket.IPPROTO_IP, + socket.IP_MULTICAST_LOOP, + 0 if interface_address != "127.0.0.1" else 1, + ) + sock.setsockopt( + socket.IPPROTO_IP, + socket.IP_MULTICAST_IF, + socket.inet_aton(interface_address), + ) + self._send_sockets.append(sock) + + self._recv_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self._recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if sys.platform == "darwin": + self._recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + self._recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL) + self._recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) + self._recv_socket.bind(("0.0.0.0", multicast_port)) + for interface_address in interface_addresses: + ip_mreq = ( + socket.inet_aton(multicast_address) + + socket.inet_aton(interface_address) + ) + self._recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, ip_mreq) + self._recv_socket.setblocking(False) + + def send(self, data: bytes) -> None: + """Send data on all interfaces.""" + for sock in self._send_sockets: + sock.sendto(data, self._multicast_endpoint) + + @property + def recv_socket(self) -> socket.socket: + """The configured non-blocking receive socket.""" + return self._recv_socket + + def close(self) -> None: + """Close all sockets.""" + for sock in self._send_sockets: + sock.close() + self._recv_socket.close() + + +class AsyncCHIRPListener(asyncio.DatagramProtocol): + """Async CHIRP listener using asyncio.DatagramProtocol. + + Owns an AsyncMulticastSocket, decodes incoming CHIRP messages, + and dispatches to registered callbacks. + + Callbacks receive (CHIRPEvent, DiscoveredService). + REQUEST messages are forwarded to request_callback if set. """ def __init__( @@ -32,17 +116,46 @@ def __init__( name: str, group: str, interface_addresses: list[str], - on_offer: Callable[[UUID, str, int, CHIRPServiceIdentifier], None], - on_depart: Callable[[UUID, CHIRPServiceIdentifier], None], ) -> None: self._host_uuid = get_uuid(name) self._group_uuid = get_uuid(group) - self._on_offer = on_offer - self._on_depart = on_depart - self._multicast_endpoint = (CHIRP_MULTICAST_ADDRESS, CHIRP_PORT) - self._send_sockets = self._create_send_sockets(interface_addresses) + self._socket = AsyncMulticastSocket( + interface_addresses, + CHIRP_MULTICAST_ADDRESS, + CHIRP_PORT, + ) + self._callbacks: dict[str, Callable[[CHIRPEvent, DiscoveredService], None]] = {} + self._request_callback: Callable[[CHIRPServiceIdentifier], None] | None = None self._transport: asyncio.DatagramTransport | None = None + def register_callback( + self, + callback_id: str, + callback_func: Callable[[CHIRPEvent, DiscoveredService], None], + ) -> None: + """Register a callback for CHIRP service events.""" + self._callbacks[callback_id] = callback_func + + def unregister_callback(self, callback_id: str) -> None: + """Remove a previously registered callback.""" + self._callbacks.pop(callback_id, None) + + def send_chirp_message(self, message: CHIRPMessage) -> None: + """Send a CHIRP message on all interfaces.""" + self._socket.send(message.pack()) + + @property + def request_callback(self) -> Callable[[CHIRPServiceIdentifier], None] | None: + """Callback invoked when a CHIRP REQUEST is received.""" + return self._request_callback + + @request_callback.setter + def request_callback( + self, + callback: Callable[[CHIRPServiceIdentifier], None] | None, + ) -> None: + self._request_callback = callback + def connection_made(self, transport: asyncio.DatagramTransport) -> None: self._transport = transport @@ -61,55 +174,108 @@ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: msg.from_address = addr[0] if msg.msgtype == CHIRPMessageType.REQUEST: + if self._request_callback is not None: + self._request_callback(msg.serviceid) return + service = DiscoveredService( + group_id=msg.group_uuid, + host_id=msg.host_uuid, + service_id=msg.serviceid, + port=msg.port, + address=addr[0], + ) + if msg.msgtype == CHIRPMessageType.OFFER: - self._on_offer(msg.host_uuid, msg.from_address, msg.port, msg.serviceid) + event = CHIRPEvent.SERVICE_CONNECTED elif msg.msgtype == CHIRPMessageType.DEPART and msg.port != 0: - self._on_depart(msg.host_uuid, msg.serviceid) + event = CHIRPEvent.SERVICE_DISCONNECTED + else: + return - def emit(self, serviceid: CHIRPServiceIdentifier, msgtype: CHIRPMessageType, port: int = 0) -> None: - """Send a CHIRP message on all interfaces.""" - msg = CHIRPMessage(msgtype, self._group_uuid, self._host_uuid, serviceid, port) - packed = msg.pack() - for sock in self._send_sockets: - sock.sendto(packed, self._multicast_endpoint) + for callback in self._callbacks.values(): + callback(event, service) + + async def start(self, loop: asyncio.AbstractEventLoop) -> None: + """Hand the receive socket to the event loop.""" + await loop.create_datagram_endpoint( + lambda: self, + sock=self._socket.recv_socket, + ) def close(self) -> None: - """Close send sockets and the transport.""" - for sock in self._send_sockets: - sock.close() + """Close the socket and transport.""" + self._socket.close() if self._transport is not None: self._transport.close() - @staticmethod - def create_recv_socket(interface_addresses: list[str]) -> socket.socket: - """Create and configure the multicast receive socket for asyncio.""" - recv_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if sys.platform == "darwin": - recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) - recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL) - recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) - recv_socket.bind(("0.0.0.0", CHIRP_PORT)) - for interface_address in interface_addresses: - ip_mreq = socket.inet_aton(CHIRP_MULTICAST_ADDRESS) + socket.inet_aton(interface_address) - recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, ip_mreq) - recv_socket.setblocking(False) - return recv_socket - - @staticmethod - def _create_send_sockets(interface_addresses: list[str]) -> list[socket.socket]: - send_sockets = [] - for interface_address in interface_addresses: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL) - sock.setsockopt( - socket.IPPROTO_IP, - socket.IP_MULTICAST_LOOP, - 0 if interface_address != "127.0.0.1" else 1, + +class AsyncCHIRPManager(AsyncCHIRPListener): + """Async CHIRP manager with service registry. + + Inherits AsyncCHIRPListener and adds the ability to register + services to offer and to send service requests. + """ + + def __init__( + self, + name: str, + group: str, + interface_addresses: list[str], + ) -> None: + super().__init__(name, group, interface_addresses) + self._registered_services: dict[CHIRPServiceIdentifier, int] = {} + self.request_callback = self._handle_request + + def request(self, service_id: CHIRPServiceIdentifier) -> None: + """Send a CHIRP REQUEST for a specific service type.""" + msg = CHIRPMessage( + CHIRPMessageType.REQUEST, + self._group_uuid, + self._host_uuid, + service_id, + 0, + ) + self.send_chirp_message(msg) + + def register_service(self, service_id: CHIRPServiceIdentifier, port: int) -> None: + """Register a service to offer via CHIRP.""" + self._registered_services[service_id] = port + + def unregister_service(self, service_id: CHIRPServiceIdentifier) -> None: + """Unregister a service and send DEPART.""" + port = self._registered_services.pop(service_id, None) + if port is not None: + msg = CHIRPMessage( + CHIRPMessageType.DEPART, + self._group_uuid, + self._host_uuid, + service_id, + port, + ) + self.send_chirp_message(msg) + + def emit_offers(self) -> None: + """Send OFFER for all registered services.""" + for service_id, port in self._registered_services.items(): + msg = CHIRPMessage( + CHIRPMessageType.OFFER, + self._group_uuid, + self._host_uuid, + service_id, + port, + ) + self.send_chirp_message(msg) + + def _handle_request(self, service_id: CHIRPServiceIdentifier) -> None: + """Respond to incoming CHIRP requests with matching offers.""" + if service_id in self._registered_services: + port = self._registered_services[service_id] + msg = CHIRPMessage( + CHIRPMessageType.OFFER, + self._group_uuid, + self._host_uuid, + service_id, + port, ) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface_address)) - send_sockets.append(sock) - return send_sockets + self.send_chirp_message(msg) diff --git a/python/constellation/core/async_experimental/async_heartbeat.py b/python/constellation/core/async_experimental/async_heartbeat.py index 1fb38c8c0..739cf421f 100644 --- a/python/constellation/core/async_experimental/async_heartbeat.py +++ b/python/constellation/core/async_experimental/async_heartbeat.py @@ -1,160 +1,169 @@ -import asyncio -import time -from collections.abc import Callable -from dataclasses import dataclass, field -from datetime import datetime -from uuid import UUID - -import zmq.asyncio - -from constellation.core.chp import CHPRole, chp_decode_message -from constellation.core.protocol.cscp1 import SatelliteState - -from .async_pools import AsyncSubscriberPool - - -@dataclass -class HeartbeatState: - """Tracked state for a single satellite.""" - - host: UUID - name: str - state: SatelliteState = SatelliteState.DEAD - last_refresh: float = field(default_factory=time.monotonic) - last_statechange: datetime = field(default_factory=datetime.now) - interval_ms: int = 2000 - lives: int = 3 - role: CHPRole = CHPRole.DYNAMIC - - def refresh(self) -> None: - self.last_refresh = time.monotonic() - - def seconds_since_refresh(self) -> float: - return time.monotonic() - self.last_refresh - - -class AsyncHeartbeatReceiver: - """Async heartbeat receiver backed by AsyncSubscriberPool. - - Socket management is delegated to an internal AsyncSubscriberPool. - CHP has no topic filtering so the pool subscribes to "" for all sockets, - applied automatically to every socket added via add_satellite(). - - Call add_satellite() and remove_satellite() via - loop.call_soon_threadsafe() from the CHIRP thread. - """ - - INIT_LIVES = 3 - INIT_INTERVAL = 2000 - - def __init__( - self, - ctx: zmq.asyncio.Context, - on_state_change: Callable[[str, SatelliteState, SatelliteState], None] | None = None, - on_satellite_dead: Callable[[str], None] | None = None, - ) -> None: - self._on_state_change = on_state_change - self._on_satellite_dead = on_satellite_dead - - self._states: dict[UUID, HeartbeatState] = {} - self._name_to_uuid: dict[str, UUID] = {} - - self._pool = AsyncSubscriberPool(ctx, self._on_frames) - self._pool.set_topics([""]) - - def add_satellite(self, uuid: UUID, address: str, port: int, name: str) -> None: - """Register a satellite for heartbeat tracking.""" - if uuid in self._states: - return - self._pool.add_socket(uuid, address, port) - self._states[uuid] = HeartbeatState( - host=uuid, - name=name, - lives=self.INIT_LIVES, - interval_ms=self.INIT_INTERVAL, - ) - self._name_to_uuid[name] = uuid - - def remove_satellite(self, uuid: UUID) -> None: - """Deregister a satellite and close its socket.""" - self._pool.remove_socket(uuid) - hb = self._states.pop(uuid, None) - if hb is not None: - self._name_to_uuid.pop(hb.name, None) - - @property - def states(self) -> dict[str, SatelliteState]: - """Current states keyed by lowercase canonical name.""" - return {hb.name.lower(): hb.state for hb in self._states.values()} - - @property - def state_changes(self) -> dict[str, datetime]: - """Last state change timestamps keyed by canonical name.""" - return {hb.name: hb.last_statechange for hb in self._states.values()} - - async def run(self, stop: asyncio.Event) -> None: - """Run pool polling and stale check loop concurrently until stop is set.""" - await asyncio.gather( - self._pool.run(stop), - self._stale_check_loop(stop), - ) - - async def _stale_check_loop(self, stop: asyncio.Event) -> None: - """Check for missed heartbeats every 300ms.""" - while not stop.is_set(): - await asyncio.sleep(0.3) - self._check_stale_connections() - - def _on_frames(self, uuid: UUID, frames: list[bytes]) -> None: - """Process a CHP frame delivered by the pool.""" - try: - name, _timestamp, state_val, flags, interval, _status = chp_decode_message(frames) - except Exception: - return - - hb = self._states.get(uuid) - if hb is None: - return - - # Update name if it was unknown - if hb.name != name: - self._name_to_uuid.pop(hb.name, None) - hb.name = name - self._name_to_uuid[name] = uuid - - state = SatelliteState(state_val) - - if state != hb.state: - old_state = hb.state - hb.state = state - hb.last_statechange = datetime.now() - if self._on_state_change: - self._on_state_change(name, old_state, state) - - hb.refresh() - hb.interval_ms = interval - hb.role = CHPRole.from_flags(flags) - - if hb.lives != self.INIT_LIVES: - hb.lives = self.INIT_LIVES - - def _check_stale_connections(self) -> None: - """Decrement lives for satellites that have missed heartbeats.""" - for _uuid, hb in list(self._states.items()): - if hb.lives <= 0: - continue - expected = (hb.interval_ms / 1000) * 1.5 - if hb.seconds_since_refresh() > expected: - hb.lives -= 1 - if hb.lives == 0: - if self._on_satellite_dead: - self._on_satellite_dead(hb.name) - hb.state = SatelliteState.DEAD - else: - hb.refresh() - - def close(self) -> None: - """Close all sockets and clear state.""" - self._pool.close() - self._states.clear() - self._name_to_uuid.clear() +import asyncio +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from uuid import UUID + +import zmq.asyncio + +from constellation.core.chp import CHPRole, chp_decode_message +from constellation.core.protocol.cscp1 import SatelliteState +from constellation.core.util import case_insensitive_dict + +from .async_pools import AsyncSubscriberPool + + +@dataclass +class HeartbeatState: + """Tracked state for a single satellite.""" + + host: UUID + name: str + state: SatelliteState = SatelliteState.DEAD + last_refresh: float = field(default_factory=time.monotonic) + last_statechange: datetime = field(default_factory=datetime.now) + interval_ms: int = 2000 + lives: int = 3 + role: CHPRole = CHPRole.DYNAMIC + status: str = "" + + def refresh(self) -> None: + self.last_refresh = time.monotonic() + + def seconds_since_refresh(self) -> float: + return time.monotonic() - self.last_refresh + + +class AsyncHeartbeatReceiver: + """Async heartbeat receiver backed by AsyncSubscriberPool. + + Socket management is delegated to an internal AsyncSubscriberPool. + CHP has no topic filtering so the pool subscribes to "" for all sockets, + applied automatically to every socket added via add_satellite(). + + Call add_satellite() and remove_satellite() from the event loop. + When calling from another thread use loop.call_soon_threadsafe(). + """ + + INIT_LIVES = 3 + INIT_INTERVAL = 2000 + + def __init__( + self, + ctx: zmq.asyncio.Context, + on_state_change: Callable[[str, SatelliteState, SatelliteState], None] | None = None, + on_satellite_dead: Callable[[str], None] | None = None, + ) -> None: + self._on_state_change = on_state_change + self._on_satellite_dead = on_satellite_dead + + self._states: dict[UUID, HeartbeatState] = {} + self._name_to_uuid: case_insensitive_dict[UUID] = case_insensitive_dict() + + self._pool = AsyncSubscriberPool(ctx, self._on_frames) + self._pool.set_topics([""]) + + def add_satellite(self, uuid: UUID, address: str, port: int, name: str) -> None: + """Register a satellite for heartbeat tracking.""" + if uuid in self._states: + return + self._pool.add_socket(uuid, address, port) + self._states[uuid] = HeartbeatState( + host=uuid, + name=name, + lives=self.INIT_LIVES, + interval_ms=self.INIT_INTERVAL, + ) + self._name_to_uuid[name] = uuid + + def remove_satellite(self, uuid: UUID) -> None: + """Deregister a satellite and close its socket.""" + self._pool.remove_socket(uuid) + hb = self._states.pop(uuid, None) + if hb is not None: + self._name_to_uuid.pop(hb.name, None) + + @property + def states(self) -> case_insensitive_dict[SatelliteState]: + """Current states keyed by canonical name.""" + return case_insensitive_dict({hb.name: hb.state for hb in self._states.values()}) + + @property + def state_changes(self) -> case_insensitive_dict[datetime]: + """Last state change timestamps keyed by canonical name.""" + return case_insensitive_dict({hb.name: hb.last_statechange for hb in self._states.values()}) + + @property + def statuses(self) -> case_insensitive_dict[str]: + """Last status message keyed by canonical name.""" + return case_insensitive_dict({hb.name: hb.status for hb in self._states.values()}) + + async def run(self, stop: asyncio.Event) -> None: + """Run pool polling and stale check loop concurrently until stop is set.""" + await asyncio.gather( + self._pool.run(stop), + self._stale_check_loop(stop), + ) + + async def _stale_check_loop(self, stop: asyncio.Event) -> None: + """Check for missed heartbeats every 300ms.""" + while not stop.is_set(): + await asyncio.sleep(0.3) + self._check_stale_connections() + + def _on_frames(self, uuid: UUID, frames: list[bytes]) -> None: + """Process a CHP frame delivered by the pool.""" + try: + name, _timestamp, state_val, flags, interval, status = chp_decode_message(frames) + except Exception: + return + + hb = self._states.get(uuid) + if hb is None: + return + + if hb.name.casefold() != name.casefold(): + self._name_to_uuid.pop(hb.name, None) + hb.name = name + self._name_to_uuid[name] = uuid + + state = SatelliteState(state_val) + + if state != hb.state: + old_state = hb.state + hb.state = state + hb.last_statechange = datetime.now() + if self._on_state_change: + self._on_state_change(name, old_state, state) + + hb.refresh() + hb.interval_ms = interval + hb.role = CHPRole.from_flags(flags) + + if status: + hb.status = status + + if hb.lives != self.INIT_LIVES: + hb.lives = self.INIT_LIVES + + def _check_stale_connections(self) -> None: + """Decrement lives for satellites that have missed heartbeats.""" + for _uuid, hb in list(self._states.items()): + if hb.lives <= 0: + continue + expected = (hb.interval_ms / 1000) * 1.5 + if hb.seconds_since_refresh() > expected: + hb.lives -= 1 + if hb.lives == 0: + if self._on_satellite_dead: + self._on_satellite_dead(hb.name) + hb.state = SatelliteState.DEAD + else: + hb.refresh() + + def close(self) -> None: + """Close all sockets and clear state.""" + self._pool.close() + self._states.clear() + self._name_to_uuid.clear() diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py index 887c3706e..16d903047 100644 --- a/python/constellation/core/async_experimental/bridge.py +++ b/python/constellation/core/async_experimental/bridge.py @@ -6,12 +6,12 @@ import zmq import zmq.asyncio -from constellation.core.chirp import CHIRPMessageType, CHIRPServiceIdentifier +from constellation.core.chirp import CHIRPServiceIdentifier from constellation.core.cscp import CommandTransmitter from constellation.core.message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage from constellation.core.network import get_interface_addresses -from .async_chirp import AsyncCHIRPProtocol +from .async_chirp import AsyncCHIRPManager, CHIRPEvent, DiscoveredService from .async_heartbeat import AsyncHeartbeatReceiver from .async_pools import AsyncSubscriberPool @@ -57,7 +57,7 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: self._cscp_executor = ThreadPoolExecutor(max_workers=1) self._discovered: dict[UUID, dict] = {} - self._chirp_protocol: AsyncCHIRPProtocol | None = None + self._chirp_manager: AsyncCHIRPManager | None = None self.stats = { "state_changes": 0, @@ -69,26 +69,20 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: async def start(self) -> None: """Set up async CHIRP and emit initial service requests.""" interface_addresses = get_interface_addresses(None) - recv_socket = AsyncCHIRPProtocol.create_recv_socket(interface_addresses) - self._chirp_protocol = AsyncCHIRPProtocol( + self._chirp_manager = AsyncCHIRPManager( name="CombinedBridge.Bridge", group=self.group, interface_addresses=interface_addresses, - on_offer=self._on_chirp_offer, - on_depart=self._on_chirp_depart, ) + self._chirp_manager.register_callback("bridge", self._on_chirp_event) + await self._chirp_manager.start(self._loop) - await self._loop.create_datagram_endpoint( - lambda: self._chirp_protocol, - sock=recv_socket, - ) - - self._chirp_protocol.emit(CHIRPServiceIdentifier.HEARTBEAT, CHIRPMessageType.REQUEST) + self._chirp_manager.request(CHIRPServiceIdentifier.HEARTBEAT) await asyncio.sleep(0.1) - self._chirp_protocol.emit(CHIRPServiceIdentifier.MONITORING, CHIRPMessageType.REQUEST) + self._chirp_manager.request(CHIRPServiceIdentifier.MONITORING) await asyncio.sleep(0.1) - self._chirp_protocol.emit(CHIRPServiceIdentifier.CONTROL, CHIRPMessageType.REQUEST) + self._chirp_manager.request(CHIRPServiceIdentifier.CONTROL) async def run(self) -> None: """Run heartbeat and CMDP components concurrently.""" @@ -124,8 +118,8 @@ def shutdown(self) -> None: contexts while sockets are still open. """ self._stop.set() - if self._chirp_protocol is not None: - self._chirp_protocol.close() + if self._chirp_manager is not None: + self._chirp_manager.close() self._cscp_executor.shutdown(wait=True) self._heartbeat.close() self._cmdp_pool.close() @@ -135,47 +129,52 @@ def shutdown(self) -> None: self._ctx.term() self._sync_ctx.term() - def _on_chirp_offer(self, uuid: UUID, address: str, port: int, service: CHIRPServiceIdentifier) -> None: - """Handle a CHIRP OFFER. Called from datagram_received on the event loop.""" - if uuid not in self._discovered: - self._discovered[uuid] = {} + def _on_chirp_event(self, event: CHIRPEvent, service: DiscoveredService) -> None: + """Handle a CHIRP event. Called from datagram_received on the event loop.""" + uuid = service.host_id + address = service.address + port = service.port + service_id = service.service_id - if service in self._discovered[uuid]: - return + if event == CHIRPEvent.SERVICE_CONNECTED: + if uuid not in self._discovered: + self._discovered[uuid] = {} - self._discovered[uuid][service] = (address, port) + if service_id in self._discovered[uuid]: + return - if service == CHIRPServiceIdentifier.HEARTBEAT: - name = self._transmitter_uuids.get(uuid, f"Unknown-{str(uuid)[:8]}") - print(f" [CHIRP] HEARTBEAT from {address}:{port}") - self._heartbeat.add_satellite(uuid, address, port, name) + self._discovered[uuid][service_id] = (address, port) - elif service == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] MONITORING from {address}:{port}") - self._cmdp_pool.add_socket(uuid, address, port) + if service_id == CHIRPServiceIdentifier.HEARTBEAT: + name = self._transmitter_uuids.get(uuid, f"Unknown-{str(uuid)[:8]}") + print(f" [CHIRP] HEARTBEAT from {address}:{port}") + self._heartbeat.add_satellite(uuid, address, port, name) - elif service == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] CONTROL from {address}:{port}") - self._loop.create_task(self._setup_transmitter(uuid, address, port)) + elif service_id == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] MONITORING from {address}:{port}") + self._cmdp_pool.add_socket(uuid, address, port) - def _on_chirp_depart(self, uuid: UUID, service: CHIRPServiceIdentifier) -> None: - """Handle a CHIRP DEPART. Called from datagram_received on the event loop.""" - if uuid not in self._discovered: - return + elif service_id == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] CONTROL from {address}:{port}") + self._loop.create_task(self._setup_transmitter(uuid, address, port)) + + elif event == CHIRPEvent.SERVICE_DISCONNECTED: + if uuid not in self._discovered: + return - self._discovered[uuid].pop(service, None) + self._discovered[uuid].pop(service_id, None) - if service == CHIRPServiceIdentifier.HEARTBEAT: - print(f" [CHIRP] DEPART HEARTBEAT {uuid}") - self._heartbeat.remove_satellite(uuid) + if service_id == CHIRPServiceIdentifier.HEARTBEAT: + print(f" [CHIRP] DEPART HEARTBEAT {uuid}") + self._heartbeat.remove_satellite(uuid) - elif service == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] DEPART MONITORING {uuid}") - self._cmdp_pool.remove_socket(uuid) + elif service_id == CHIRPServiceIdentifier.MONITORING: + print(f" [CHIRP] DEPART MONITORING {uuid}") + self._cmdp_pool.remove_socket(uuid) - elif service == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] DEPART CONTROL {uuid}") - self._cleanup_transmitter(uuid) + elif service_id == CHIRPServiceIdentifier.CONTROL: + print(f" [CHIRP] DEPART CONTROL {uuid}") + self._cleanup_transmitter(uuid) def _on_state_change(self, name: str, old_state, new_state) -> None: self.stats["state_changes"] += 1 From 1eb4fb7af208aa26e7fdf671a93240731cb27829 Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Fri, 22 May 2026 07:36:32 +0200 Subject: [PATCH 06/12] Discovery logic, register_service, addresses list, socket architecture --- .../core/async_experimental/async_chirp.py | 182 +++++++++++++----- .../core/async_experimental/bridge.py | 2 +- 2 files changed, 133 insertions(+), 51 deletions(-) diff --git a/python/constellation/core/async_experimental/async_chirp.py b/python/constellation/core/async_experimental/async_chirp.py index 496627b3e..d14041f42 100644 --- a/python/constellation/core/async_experimental/async_chirp.py +++ b/python/constellation/core/async_experimental/async_chirp.py @@ -2,7 +2,7 @@ import socket import sys from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum, auto from uuid import UUID @@ -24,22 +24,34 @@ class CHIRPEvent(Enum): SERVICE_DISCONNECTED = auto() -@dataclass +@dataclass(eq=False) class DiscoveredService: - """A service discovered via CHIRP.""" + """A service discovered via CHIRP. + + addresses is a list because a host may be reachable on multiple network + interfaces. Additional addresses are appended as OFFERs arrive from the + same host on different interfaces. + """ group_id: UUID host_id: UUID service_id: CHIRPServiceIdentifier port: int - address: str + addresses: list[str] = field(default_factory=list) + + def __eq__(self, other: object) -> bool: + """Equality on host_id + service_id only, matching chirpmanager.py.""" + if isinstance(other, DiscoveredService): + return self.host_id == other.host_id and self.service_id == other.service_id + return NotImplemented + +class AsyncMulticastSocket(asyncio.DatagramProtocol): + """Async-compatible multicast socket and DatagramProtocol. -class AsyncMulticastSocket: - """Async-compatible multicast socket. + Owns the raw socket setup and the asyncio transport layer. + Incoming datagrams are forwarded to datagram_callback when set. - Mirrors MulticastSocket but sets the receive socket non-blocking - so it can be handed to asyncio via create_datagram_endpoint. Send sockets remain synchronous since UDP sendto writes to the kernel buffer without blocking. """ @@ -51,7 +63,8 @@ def __init__( multicast_port: int, ) -> None: self._multicast_endpoint = (multicast_address, multicast_port) - + self._datagram_callback: Callable[[bytes, tuple[str, int]], None] | None = None + self._transport: asyncio.DatagramTransport | None = None self._send_sockets: list[socket.socket] = [] for interface_address in interface_addresses: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) @@ -84,27 +97,49 @@ def __init__( self._recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, ip_mreq) self._recv_socket.setblocking(False) + @property + def datagram_callback(self) -> Callable[[bytes, tuple[str, int]], None] | None: + """Callback invoked by datagram_received for each incoming packet.""" + return self._datagram_callback + + @datagram_callback.setter + def datagram_callback( + self, callback: Callable[[bytes, tuple[str, int]], None] | None + ) -> None: + self._datagram_callback = callback + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + self._transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + if self._datagram_callback is not None: + self._datagram_callback(data, addr) + + async def start(self, loop: asyncio.AbstractEventLoop) -> None: + """Hand the receive socket to the event loop.""" + await loop.create_datagram_endpoint( + lambda: self, + sock=self._recv_socket, + ) + def send(self, data: bytes) -> None: """Send data on all interfaces.""" for sock in self._send_sockets: sock.sendto(data, self._multicast_endpoint) - @property - def recv_socket(self) -> socket.socket: - """The configured non-blocking receive socket.""" - return self._recv_socket - def close(self) -> None: - """Close all sockets.""" + """Close all sockets and the transport.""" for sock in self._send_sockets: sock.close() - self._recv_socket.close() + if self._transport is not None: + self._transport.close() -class AsyncCHIRPListener(asyncio.DatagramProtocol): - """Async CHIRP listener using asyncio.DatagramProtocol. +class AsyncCHIRPListener: + """Async CHIRP listener. Owns an AsyncMulticastSocket, decodes incoming CHIRP messages, + maintains a list of discovered services (mirroring CHIRPManager logic), and dispatches to registered callbacks. Callbacks receive (CHIRPEvent, DiscoveredService). @@ -124,9 +159,10 @@ def __init__( CHIRP_MULTICAST_ADDRESS, CHIRP_PORT, ) + self._socket.datagram_callback = self._on_datagram_received self._callbacks: dict[str, Callable[[CHIRPEvent, DiscoveredService], None]] = {} self._request_callback: Callable[[CHIRPServiceIdentifier], None] | None = None - self._transport: asyncio.DatagramTransport | None = None + self._discovered_services: list[DiscoveredService] = [] def register_callback( self, @@ -144,6 +180,12 @@ def send_chirp_message(self, message: CHIRPMessage) -> None: """Send a CHIRP message on all interfaces.""" self._socket.send(message.pack()) + def get_discovered( + self, service_id: CHIRPServiceIdentifier + ) -> list[DiscoveredService]: + """Return all discovered services matching service_id.""" + return [s for s in self._discovered_services if s.service_id == service_id] + @property def request_callback(self) -> Callable[[CHIRPServiceIdentifier], None] | None: """Callback invoked when a CHIRP REQUEST is received.""" @@ -156,10 +198,16 @@ def request_callback( ) -> None: self._request_callback = callback - def connection_made(self, transport: asyncio.DatagramTransport) -> None: - self._transport = transport + async def start(self, loop: asyncio.AbstractEventLoop) -> None: + """Start the multicast socket on the event loop.""" + await self._socket.start(loop) - def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + def close(self) -> None: + """Close the socket and transport.""" + self._socket.close() + + def _on_datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Decode and dispatch an incoming CHIRP datagram.""" msg = CHIRPMessage() try: msg.unpack(data) @@ -178,36 +226,48 @@ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: self._request_callback(msg.serviceid) return - service = DiscoveredService( - group_id=msg.group_uuid, - host_id=msg.host_uuid, - service_id=msg.serviceid, - port=msg.port, - address=addr[0], - ) - if msg.msgtype == CHIRPMessageType.OFFER: - event = CHIRPEvent.SERVICE_CONNECTED + self._discover_service(msg, addr[0]) elif msg.msgtype == CHIRPMessageType.DEPART and msg.port != 0: - event = CHIRPEvent.SERVICE_DISCONNECTED - else: - return + self._depart_service(msg) + + def _discover_service(self, msg: CHIRPMessage, from_address: str) -> None: + """Mirror CHIRPManager._discover_service logic.""" + already_discovered = False + for svc in list(self._discovered_services): + if svc.host_id == msg.host_uuid and svc.service_id == msg.serviceid: + if svc.port != msg.port: + # Port changed (assume old service is gone) + self._discovered_services.remove(svc) + for cb in self._callbacks.values(): + cb(CHIRPEvent.SERVICE_DISCONNECTED, svc) + else: + # Same service, same port (accumulate address if new) + if from_address not in svc.addresses: + svc.addresses.append(from_address) + already_discovered = True + break + + if not already_discovered: + svc = DiscoveredService( + group_id=msg.group_uuid, + host_id=msg.host_uuid, + service_id=msg.serviceid, + port=msg.port, + addresses=[from_address], + ) + self._discovered_services.append(svc) + for cb in self._callbacks.values(): + cb(CHIRPEvent.SERVICE_CONNECTED, svc) - for callback in self._callbacks.values(): - callback(event, service) - - async def start(self, loop: asyncio.AbstractEventLoop) -> None: - """Hand the receive socket to the event loop.""" - await loop.create_datagram_endpoint( - lambda: self, - sock=self._socket.recv_socket, - ) - - def close(self) -> None: - """Close the socket and transport.""" - self._socket.close() - if self._transport is not None: - self._transport.close() + def _depart_service(self, msg: CHIRPMessage) -> None: + """Mirror CHIRPManager._depart_service logic.""" + for svc in list(self._discovered_services): + if svc.host_id == msg.host_uuid and svc.service_id == msg.serviceid: + self._discovered_services.remove(svc) + for cb in self._callbacks.values(): + cb(CHIRPEvent.SERVICE_DISCONNECTED, svc) + break class AsyncCHIRPManager(AsyncCHIRPListener): @@ -239,8 +299,30 @@ def request(self, service_id: CHIRPServiceIdentifier) -> None: self.send_chirp_message(msg) def register_service(self, service_id: CHIRPServiceIdentifier, port: int) -> None: - """Register a service to offer via CHIRP.""" + """Register a service to offer via CHIRP. + + If the service was already registered on a different port, sends DEPART + for the old port first. Sends OFFER immediately upon registration. + """ + if service_id in self._registered_services: + old_port = self._registered_services[service_id] + depart = CHIRPMessage( + CHIRPMessageType.DEPART, + self._group_uuid, + self._host_uuid, + service_id, + old_port, + ) + self.send_chirp_message(depart) self._registered_services[service_id] = port + offer = CHIRPMessage( + CHIRPMessageType.OFFER, + self._group_uuid, + self._host_uuid, + service_id, + port, + ) + self.send_chirp_message(offer) def unregister_service(self, service_id: CHIRPServiceIdentifier) -> None: """Unregister a service and send DEPART.""" diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py index 16d903047..de8429a4a 100644 --- a/python/constellation/core/async_experimental/bridge.py +++ b/python/constellation/core/async_experimental/bridge.py @@ -132,7 +132,7 @@ def shutdown(self) -> None: def _on_chirp_event(self, event: CHIRPEvent, service: DiscoveredService) -> None: """Handle a CHIRP event. Called from datagram_received on the event loop.""" uuid = service.host_id - address = service.address + address = service.addresses[0] port = service.port service_id = service.service_id From e8883748cf7492011c6f753e0b5e88b0a520e9b8 Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Mon, 25 May 2026 12:40:17 +0200 Subject: [PATCH 07/12] elif Comment Addressed --- python/constellation/core/async_experimental/async_chirp.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/constellation/core/async_experimental/async_chirp.py b/python/constellation/core/async_experimental/async_chirp.py index d14041f42..434c34df6 100644 --- a/python/constellation/core/async_experimental/async_chirp.py +++ b/python/constellation/core/async_experimental/async_chirp.py @@ -224,9 +224,7 @@ def _on_datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: if msg.msgtype == CHIRPMessageType.REQUEST: if self._request_callback is not None: self._request_callback(msg.serviceid) - return - - if msg.msgtype == CHIRPMessageType.OFFER: + elif msg.msgtype == CHIRPMessageType.OFFER: self._discover_service(msg, addr[0]) elif msg.msgtype == CHIRPMessageType.DEPART and msg.port != 0: self._depart_service(msg) From b76c2ceb613213966b9e376fd3f0a26d252b401e Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Thu, 28 May 2026 11:43:10 +0200 Subject: [PATCH 08/12] Fix REUSE headers and formatting in async_experimental files --- .../core/async_experimental/async_chirp.py | 18 +- .../async_experimental/async_heartbeat.py | 5 + .../core/async_experimental/async_pools.py | 251 +++++++++--------- .../core/async_experimental/bridge.py | 27 +- 4 files changed, 159 insertions(+), 142 deletions(-) diff --git a/python/constellation/core/async_experimental/async_chirp.py b/python/constellation/core/async_experimental/async_chirp.py index 434c34df6..f50367a1f 100644 --- a/python/constellation/core/async_experimental/async_chirp.py +++ b/python/constellation/core/async_experimental/async_chirp.py @@ -1,3 +1,8 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 +""" + import asyncio import socket import sys @@ -90,10 +95,7 @@ def __init__( self._recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) self._recv_socket.bind(("0.0.0.0", multicast_port)) for interface_address in interface_addresses: - ip_mreq = ( - socket.inet_aton(multicast_address) - + socket.inet_aton(interface_address) - ) + ip_mreq = socket.inet_aton(multicast_address) + socket.inet_aton(interface_address) self._recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, ip_mreq) self._recv_socket.setblocking(False) @@ -103,9 +105,7 @@ def datagram_callback(self) -> Callable[[bytes, tuple[str, int]], None] | None: return self._datagram_callback @datagram_callback.setter - def datagram_callback( - self, callback: Callable[[bytes, tuple[str, int]], None] | None - ) -> None: + def datagram_callback(self, callback: Callable[[bytes, tuple[str, int]], None] | None) -> None: self._datagram_callback = callback def connection_made(self, transport: asyncio.DatagramTransport) -> None: @@ -180,9 +180,7 @@ def send_chirp_message(self, message: CHIRPMessage) -> None: """Send a CHIRP message on all interfaces.""" self._socket.send(message.pack()) - def get_discovered( - self, service_id: CHIRPServiceIdentifier - ) -> list[DiscoveredService]: + def get_discovered(self, service_id: CHIRPServiceIdentifier) -> list[DiscoveredService]: """Return all discovered services matching service_id.""" return [s for s in self._discovered_services if s.service_id == service_id] diff --git a/python/constellation/core/async_experimental/async_heartbeat.py b/python/constellation/core/async_experimental/async_heartbeat.py index 739cf421f..033ecfc34 100644 --- a/python/constellation/core/async_experimental/async_heartbeat.py +++ b/python/constellation/core/async_experimental/async_heartbeat.py @@ -1,3 +1,8 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 +""" + import asyncio import time from collections.abc import Callable diff --git a/python/constellation/core/async_experimental/async_pools.py b/python/constellation/core/async_experimental/async_pools.py index c0a1e2a95..e0967f7a2 100644 --- a/python/constellation/core/async_experimental/async_pools.py +++ b/python/constellation/core/async_experimental/async_pools.py @@ -1,123 +1,128 @@ -import asyncio -from collections.abc import Callable -from uuid import UUID - -import zmq -import zmq.asyncio - - -class AsyncSubscriberPool: - """Async ZMQ SUB socket pool using zmq.asyncio. - - All methods must be called from the event loop. Use - loop.call_soon_threadsafe() when calling from other threads. - """ - - def __init__( - self, - ctx: zmq.asyncio.Context, - callback: Callable[[UUID, list[bytes]], None], - ) -> None: - self._ctx = ctx - self._callback = callback - self._sockets: dict[UUID, zmq.asyncio.Socket] = {} - self._socket_to_uuid: dict[zmq.asyncio.Socket, UUID] = {} - self._poller = zmq.asyncio.Poller() - self._topics: list[str] = [] - - def add_socket(self, uuid: UUID, address: str, port: int) -> None: - """Add a subscriber socket and apply all current global topics.""" - if uuid in self._sockets: - return - sock = self._ctx.socket(zmq.SUB) - sock.connect(f"tcp://{address}:{port}") - sock.setsockopt(zmq.LINGER, 0) - for topic in self._topics: - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - self._poller.register(sock, zmq.POLLIN) - self._sockets[uuid] = sock - self._socket_to_uuid[sock] = uuid - - def remove_socket(self, uuid: UUID) -> None: - """Remove and close the subscriber socket for a departing satellite.""" - sock = self._sockets.pop(uuid, None) - if sock is not None: - self._socket_to_uuid.pop(sock, None) - self._poller.unregister(sock) - sock.close() - - def subscribe(self, topic: str, uuid: UUID | None = None) -> None: - """Subscribe to a topic on one or all sockets. - - When uuid is None, topic is added to _topics so future sockets added - via add_socket() are automatically subscribed. When uuid is given, - only that socket is subscribed and _topics is not modified. - """ - if uuid is None: - for sock in self._sockets.values(): - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - if topic not in self._topics: - self._topics.append(topic) - else: - sock = self._sockets.get(uuid) - if sock is not None: - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - - def unsubscribe(self, topic: str, uuid: UUID | None = None) -> None: - """Unsubscribe from a topic on one or all sockets. - - When uuid is None, topic is removed from _topics. When uuid is given, - only that socket is unsubscribed and _topics is not modified. - """ - if uuid is None: - for sock in self._sockets.values(): - sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) - if topic in self._topics: - self._topics.remove(topic) - else: - sock = self._sockets.get(uuid) - if sock is not None: - sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) - - def set_topics(self, new_topics: list[str]) -> None: - """Replace all global subscriptions across all sockets.""" - old_set = set(self._topics) - new_set = set(new_topics) - for topic in old_set - new_set: - for sock in self._sockets.values(): - sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) - for topic in new_set - old_set: - for sock in self._sockets.values(): - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - self._topics = list(new_topics) - - async def run(self, stop: asyncio.Event) -> None: - """Poll all sockets until stop is set.""" - while not stop.is_set(): - if not self._sockets: - await asyncio.sleep(0.05) - continue - events = dict(await self._poller.poll(timeout=50)) - for sock in events: - uuid = self._socket_to_uuid.get(sock) - if uuid is None: - continue - try: - msg = await sock.recv_multipart() - self._callback(uuid, msg) - while sock.getsockopt(zmq.EVENTS) & zmq.POLLIN: - msg = await sock.recv_multipart() - self._callback(uuid, msg) - except zmq.ZMQError: - pass - - def close(self) -> None: - """Close all sockets.""" - for sock in self._sockets.values(): - try: - self._poller.unregister(sock) - except Exception: - pass - sock.close() - self._sockets.clear() - self._socket_to_uuid.clear() +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 +""" + +import asyncio +from collections.abc import Callable +from uuid import UUID + +import zmq +import zmq.asyncio + + +class AsyncSubscriberPool: + """Async ZMQ SUB socket pool using zmq.asyncio. + + All methods must be called from the event loop. Use + loop.call_soon_threadsafe() when calling from other threads. + """ + + def __init__( + self, + ctx: zmq.asyncio.Context, + callback: Callable[[UUID, list[bytes]], None], + ) -> None: + self._ctx = ctx + self._callback = callback + self._sockets: dict[UUID, zmq.asyncio.Socket] = {} + self._socket_to_uuid: dict[zmq.asyncio.Socket, UUID] = {} + self._poller = zmq.asyncio.Poller() + self._topics: list[str] = [] + + def add_socket(self, uuid: UUID, address: str, port: int) -> None: + """Add a subscriber socket and apply all current global topics.""" + if uuid in self._sockets: + return + sock = self._ctx.socket(zmq.SUB) + sock.connect(f"tcp://{address}:{port}") + sock.setsockopt(zmq.LINGER, 0) + for topic in self._topics: + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + self._poller.register(sock, zmq.POLLIN) + self._sockets[uuid] = sock + self._socket_to_uuid[sock] = uuid + + def remove_socket(self, uuid: UUID) -> None: + """Remove and close the subscriber socket for a departing satellite.""" + sock = self._sockets.pop(uuid, None) + if sock is not None: + self._socket_to_uuid.pop(sock, None) + self._poller.unregister(sock) + sock.close() + + def subscribe(self, topic: str, uuid: UUID | None = None) -> None: + """Subscribe to a topic on one or all sockets. + + When uuid is None, topic is added to _topics so future sockets added + via add_socket() are automatically subscribed. When uuid is given, + only that socket is subscribed and _topics is not modified. + """ + if uuid is None: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + if topic not in self._topics: + self._topics.append(topic) + else: + sock = self._sockets.get(uuid) + if sock is not None: + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + + def unsubscribe(self, topic: str, uuid: UUID | None = None) -> None: + """Unsubscribe from a topic on one or all sockets. + + When uuid is None, topic is removed from _topics. When uuid is given, + only that socket is unsubscribed and _topics is not modified. + """ + if uuid is None: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + if topic in self._topics: + self._topics.remove(topic) + else: + sock = self._sockets.get(uuid) + if sock is not None: + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + + def set_topics(self, new_topics: list[str]) -> None: + """Replace all global subscriptions across all sockets.""" + old_set = set(self._topics) + new_set = set(new_topics) + for topic in old_set - new_set: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.UNSUBSCRIBE, topic) + for topic in new_set - old_set: + for sock in self._sockets.values(): + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + self._topics = list(new_topics) + + async def run(self, stop: asyncio.Event) -> None: + """Poll all sockets until stop is set.""" + while not stop.is_set(): + if not self._sockets: + await asyncio.sleep(0.05) + continue + events = dict(await self._poller.poll(timeout=50)) + for sock in events: + uuid = self._socket_to_uuid.get(sock) + if uuid is None: + continue + try: + msg = await sock.recv_multipart() + self._callback(uuid, msg) + while sock.getsockopt(zmq.EVENTS) & zmq.POLLIN: + msg = await sock.recv_multipart() + self._callback(uuid, msg) + except zmq.ZMQError: + pass + + def close(self) -> None: + """Close all sockets.""" + for sock in self._sockets.values(): + try: + self._poller.unregister(sock) + except Exception: + pass + sock.close() + self._sockets.clear() + self._socket_to_uuid.clear() diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py index de8429a4a..73402e365 100644 --- a/python/constellation/core/async_experimental/bridge.py +++ b/python/constellation/core/async_experimental/bridge.py @@ -1,3 +1,8 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 +""" + import asyncio import threading from concurrent.futures import ThreadPoolExecutor @@ -43,13 +48,15 @@ def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: self._ctx, callback=self._on_cmdp_message, ) - self._cmdp_pool.set_topics([ - "LOG/INFO", - "LOG/WARNING", - "LOG/STATUS", - "LOG/CRITICAL", - "STAT/", - ]) + self._cmdp_pool.set_topics( + [ + "LOG/INFO", + "LOG/WARNING", + "LOG/STATUS", + "LOG/CRITICAL", + "STAT/", + ] + ) self._transmitters: dict[str, CommandTransmitter] = {} self._transmitter_uuids: dict[UUID, str] = {} @@ -97,6 +104,7 @@ async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str The executor serializes all REQ socket operations so concurrent send_command calls never interleave on the same socket. """ + def _send() -> str: with self._transmitter_lock: ct = self._transmitters.get(canonical_name) @@ -201,6 +209,7 @@ def _on_cmdp_message(self, uuid: UUID, frames: list[bytes]) -> None: async def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: """Connect to a satellite's CSCP port (runs blocking call in executor).""" + def _connect() -> tuple[str, CommandTransmitter] | None: sock = self._sync_ctx.socket(zmq.REQ) sock.connect(f"tcp://{address}:{port}") @@ -242,7 +251,7 @@ async def main() -> None: bridge = CombinedBridge(group, loop) print(f"Combined Bridge for group '{group}'") - print(f"Architecture: asyncio event loop only (no dedicated threads)") + print("Architecture: asyncio event loop only (no dedicated threads)") print() await bridge.start() @@ -305,7 +314,7 @@ async def main() -> None: print(f" Log messages received: {bridge.stats['logs']}") print(f" Metrics received: {bridge.stats['metrics']}") print(f" Commands sent: {bridge.stats['commands_sent']}") - print(f" Architecture: asyncio event loop only (no dedicated threads)") + print(" Architecture: asyncio event loop only (no dedicated threads)") if __name__ == "__main__": From dd7875f88c5f5405e7aead59a2ff2b58a1ed618c Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Thu, 28 May 2026 12:02:23 +0200 Subject: [PATCH 09/12] Added async task factory infrastructure to BaseSatelliteFrame --- python/constellation/core/base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/python/constellation/core/base.py b/python/constellation/core/base.py index 6647b4863..9bc1a6466 100644 --- a/python/constellation/core/base.py +++ b/python/constellation/core/base.py @@ -5,6 +5,7 @@ This module provides a base class for Constellation Satellite modules. """ +import asyncio import atexit import logging import socket @@ -121,6 +122,8 @@ def __init__(self, name: str, **_kwds: Any): self._com_thread_pool: dict[str, threading.Thread] = {} # Event to indicate to communication service threads to stop self._com_thread_evt: threading.Event | None = None + # List of async coroutine factories for async communication tasks + self._com_task_factories: list = [] # add self to list of satellites to destroy on shutdown global SATELLITE_LIST # noqa @@ -146,6 +149,20 @@ def _add_com_thread(self) -> None: """ self.log.debug("Satellite Base class _add_thread called") + def _add_com_task(self) -> None: + """Method to add async coroutine factories to the task pool. + + Does nothing in the base class. Override in subclass and call + super()._add_com_task() first, then append to _com_task_factories. + + """ + + async def _start_com_tasks(self, stop: asyncio.Event) -> None: + """Run all registered async tasks concurrently until stop is set.""" + if not self._com_task_factories: + return + await asyncio.gather(*[factory(stop) for factory in self._com_task_factories]) + def _start_com_threads(self) -> None: """Start all background communication threads.""" self._com_thread_evt = threading.Event() From a13231130846661858b0c441c643b70a8ff07a4c Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Thu, 28 May 2026 19:22:40 +0200 Subject: [PATCH 10/12] Add async framework integration classes --- .../async_basecontroller.py | 136 ++++++++++++++++++ .../async_experimental/async_chirpmanager.py | 71 +++++++++ .../async_heartbeatchecker.py | 71 +++++++++ .../async_monitoringlistener.py | 71 +++++++++ 4 files changed, 349 insertions(+) create mode 100644 python/constellation/core/async_experimental/async_basecontroller.py create mode 100644 python/constellation/core/async_experimental/async_chirpmanager.py create mode 100644 python/constellation/core/async_experimental/async_heartbeatchecker.py create mode 100644 python/constellation/core/async_experimental/async_monitoringlistener.py diff --git a/python/constellation/core/async_experimental/async_basecontroller.py b/python/constellation/core/async_experimental/async_basecontroller.py new file mode 100644 index 000000000..c698cf65b --- /dev/null +++ b/python/constellation/core/async_experimental/async_basecontroller.py @@ -0,0 +1,136 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 + +Async base and scriptable controller framework classes. +""" + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from uuid import UUID + +import zmq + +from ..controller import SatelliteUpdate +from ..cscp import CommandTransmitter +from ..logging import setup_cli_logging +from .async_chirp import CHIRPEvent, DiscoveredService +from .async_heartbeatchecker import AsyncHeartbeatChecker +from .async_monitoringlistener import AsyncMonitoringListener + + +class AsyncBaseController(AsyncMonitoringListener, AsyncHeartbeatChecker): + """Async equivalent of BaseController. + + CSCP commands run in ThreadPoolExecutor(max_workers=1) to keep + blocking REQ socket operations off the event loop. + Discovery and heartbeat run as coroutines. + """ + + def __init__(self, loop: asyncio.AbstractEventLoop, **kwds: Any) -> None: + super().__init__(**kwds) + self._loop = loop + self._transmitters: dict[str, CommandTransmitter] = {} + self._transmitter_uuids: dict[UUID, str] = {} + self._transmitter_lock = threading.Lock() + self._cscp_executor = ThreadPoolExecutor(max_workers=1) + self._sync_ctx = zmq.Context() + + self.register_chirp_callback("basecontroller_control", self._on_control_service) + self.register_chirp_callback("basecontroller_heartbeat", self._on_heartbeat_service) + + def _on_control_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: + """Handle CONTROL service connect/disconnect.""" + if event == CHIRPEvent.SERVICE_CONNECTED: + asyncio.ensure_future(self._setup_transmitter(service.host_id, service.addresses[0], service.port)) + elif event == CHIRPEvent.SERVICE_DISCONNECTED: + self._cleanup_transmitter(service.host_id) + + def _on_heartbeat_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: + """Handle HEARTBEAT service connect/disconnect.""" + if event == CHIRPEvent.SERVICE_CONNECTED: + name = self._transmitter_uuids.get(service.host_id, f"Unknown-{str(service.host_id)[:8]}") + self.register_heartbeat_host(service.host_id, service.addresses[0], service.port, name) + elif event == CHIRPEvent.SERVICE_DISCONNECTED: + self.unregister_heartbeat_host(service.host_id) + + async def command( + self, + cmd: str, + sat: str = "", + satcls: str = "", + payload: Any = None, + ) -> str: + """Send a CSCP command via the dedicated executor.""" + + def _send() -> str: + key = f"{satcls}.{sat}".strip(".") + with self._transmitter_lock: + ct = self._transmitters.get(key) + if ct is None: + return f"ERROR: No transmitter for {key}" + try: + msg = ct.request_get_response(command=cmd, payload=payload) + return f"{msg.verb_msg}" + except Exception as e: + return f"ERROR: {e}" + + return await self._loop.run_in_executor(self._cscp_executor, _send) + + def _on_satellite_update(self, name: str, update_type: SatelliteUpdate) -> None: + """Called on satellite connect/disconnect. Override in subclass.""" + + async def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: + """Connect to a satellite CSCP port in the executor.""" + + def _connect(): + sock = self._sync_ctx.socket(zmq.REQ) + sock.connect(f"tcp://{address}:{port}") + sock.setsockopt(zmq.LINGER, 2000) + sock.setsockopt(zmq.RCVTIMEO, 5000) + try: + ct = CommandTransmitter(self.name, sock) + msg = ct.request_get_response("get_commands") + return msg.sender, ct + except Exception: + sock.close() + return None + + result = await self._loop.run_in_executor(self._cscp_executor, _connect) + if result is None: + return + canonical_name, ct = result + with self._transmitter_lock: + self._transmitters[canonical_name] = ct + self._transmitter_uuids[uuid] = canonical_name + self._on_satellite_update(canonical_name, SatelliteUpdate.ADDED) + + def _cleanup_transmitter(self, uuid: UUID) -> None: + """Remove and close a CSCP transmitter.""" + with self._transmitter_lock: + canonical_name = self._transmitter_uuids.pop(uuid, None) + if canonical_name is None: + return + ct = self._transmitters.pop(canonical_name, None) + if ct is not None: + ct.close() + self._on_satellite_update(canonical_name, SatelliteUpdate.REMOVED) + + def shutdown(self) -> None: + """Shut down all components.""" + self._cscp_executor.shutdown(wait=True) + self.close() + with self._transmitter_lock: + for ct in self._transmitters.values(): + ct.close() + self._sync_ctx.term() + + +class AsyncScriptableController(AsyncBaseController): + """Async equivalent of ScriptableController.""" + + def __init__(self, log_level: str = "INFO", **kwds: Any) -> None: + setup_cli_logging(log_level) + super().__init__(**kwds) diff --git a/python/constellation/core/async_experimental/async_chirpmanager.py b/python/constellation/core/async_experimental/async_chirpmanager.py new file mode 100644 index 000000000..f38a68f9e --- /dev/null +++ b/python/constellation/core/async_experimental/async_chirpmanager.py @@ -0,0 +1,71 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 + +Async CHIRP manager framework class. +""" + +import asyncio +from typing import Any + +from ..base import BaseSatelliteFrame +from ..chirp import CHIRPServiceIdentifier +from ..network import get_interface_addresses +from .async_chirp import AsyncCHIRPManager as _AsyncCHIRPManagerImpl +from .async_chirp import DiscoveredService + + +class AsyncCHIRPManager(BaseSatelliteFrame): + """Async equivalent of CHIRPManager. + + Uses AsyncCHIRPManager from async_chirp internally. + Registers CHIRP discovery as a coroutine via _add_com_task. + """ + + def __init__( + self, + name: str, + group: str, + interface: list[str] | None, + **kwds: Any, + ) -> None: + super().__init__(name=name, **kwds) + self.group = group + interface_addresses = get_interface_addresses(interface) + self._chirp = _AsyncCHIRPManagerImpl( + name=self.name, + group=group, + interface_addresses=interface_addresses, + ) + + def _add_com_task(self) -> None: + """Register the async CHIRP discovery coroutine.""" + super()._add_com_task() + self._com_task_factories.append(self._run_chirp) + + async def _run_chirp(self, stop: asyncio.Event) -> None: + """Start CHIRP discovery and run until stop is set.""" + loop = asyncio.get_running_loop() + await self._chirp.start(loop) + await stop.wait() + self._chirp.close() + + def register_chirp_callback( + self, + callback_id: str, + callback_func, + ) -> None: + """Register a callback for CHIRP service events.""" + self._chirp.register_callback(callback_id, callback_func) + + def unregister_chirp_callback(self, callback_id: str) -> None: + """Remove a previously registered CHIRP callback.""" + self._chirp.unregister_callback(callback_id) + + def request(self, service_id: CHIRPServiceIdentifier) -> None: + """Send a CHIRP REQUEST for a specific service type.""" + self._chirp.request(service_id) + + def get_discovered(self, service_id: CHIRPServiceIdentifier) -> list[DiscoveredService]: + """Return discovered services matching service_id.""" + return self._chirp.get_discovered(service_id) diff --git a/python/constellation/core/async_experimental/async_heartbeatchecker.py b/python/constellation/core/async_experimental/async_heartbeatchecker.py new file mode 100644 index 000000000..c23e0733a --- /dev/null +++ b/python/constellation/core/async_experimental/async_heartbeatchecker.py @@ -0,0 +1,71 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 + +Async heartbeat checker framework class. +""" + +from typing import Any + +import zmq.asyncio + +from ..base import BaseSatelliteFrame +from ..protocol.cscp1 import SatelliteState +from .async_heartbeat import AsyncHeartbeatReceiver + + +class AsyncHeartbeatChecker(BaseSatelliteFrame): + """Async equivalent of HeartbeatChecker. + + Uses AsyncHeartbeatReceiver internally. + Callbacks fire on the event loop — no call_soon_threadsafe needed. + """ + + def __init__(self, **kwds: Any) -> None: + super().__init__(**kwds) + self._async_ctx = zmq.asyncio.Context() + self._hb_receiver = AsyncHeartbeatReceiver( + self._async_ctx, + on_state_change=self._on_state_change, + on_satellite_dead=self._on_satellite_dead, + ) + + def _add_com_task(self) -> None: + """Register the async heartbeat receiver coroutine.""" + super()._add_com_task() + self._com_task_factories.append(self._hb_receiver.run) + + def register_heartbeat_host( + self, + uuid, + address: str, + port: int, + name: str, + ) -> None: + """Register a satellite for heartbeat tracking.""" + self._hb_receiver.add_satellite(uuid, address, port, name) + + def unregister_heartbeat_host(self, uuid) -> None: + """Deregister a satellite from heartbeat tracking.""" + self._hb_receiver.remove_satellite(uuid) + + @property + def heartbeat_states(self): + """Current states keyed by canonical name.""" + return self._hb_receiver.states + + def _on_state_change( + self, + name: str, + old_state: SatelliteState, + new_state: SatelliteState, + ) -> None: + """Called when a satellite changes state. Override in subclass.""" + + def _on_satellite_dead(self, name: str) -> None: + """Called when a satellite stops sending heartbeats. Override in subclass.""" + + def close(self) -> None: + """Close all heartbeat sockets.""" + self._hb_receiver.close() + self._async_ctx.term() diff --git a/python/constellation/core/async_experimental/async_monitoringlistener.py b/python/constellation/core/async_experimental/async_monitoringlistener.py new file mode 100644 index 000000000..b7d472b1a --- /dev/null +++ b/python/constellation/core/async_experimental/async_monitoringlistener.py @@ -0,0 +1,71 @@ +""" +SPDX-FileCopyrightText: 2026 DESY and the Constellation authors +SPDX-License-Identifier: EUPL-1.2 + +Async monitoring listener framework class. +""" + +import logging +from typing import Any +from uuid import UUID + +from ..message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage +from .async_chirp import CHIRPEvent, DiscoveredService +from .async_chirpmanager import AsyncCHIRPManager +from .async_pools import AsyncSubscriberPool + + +class AsyncMonitoringListener(AsyncCHIRPManager): + """Async equivalent of MonitoringListener. + + Inherits AsyncCHIRPManager and adds CMDP log/metric receiving + via AsyncSubscriberPool. + """ + + def __init__(self, **kwds: Any) -> None: + super().__init__(**kwds) + self._cmdp_pool = AsyncSubscriberPool( + self._async_ctx, + callback=self._on_cmdp_message, + ) + self.register_chirp_callback("monitoring_listener", self._on_monitoring_service) + + def _add_com_task(self) -> None: + """Register the async CMDP pool coroutine.""" + super()._add_com_task() + self._com_task_factories.append(self._cmdp_pool.run) + + def set_topics(self, topics: list[str]) -> None: + """Set CMDP topic subscriptions across all sockets.""" + self._cmdp_pool.set_topics(topics) + + def _on_monitoring_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: + """Handle MONITORING service connect/disconnect.""" + if event == CHIRPEvent.SERVICE_CONNECTED: + self._cmdp_pool.add_socket(service.host_id, service.addresses[0], service.port) + elif event == CHIRPEvent.SERVICE_DISCONNECTED: + self._cmdp_pool.remove_socket(service.host_id) + + def _on_cmdp_message(self, uuid: UUID, frames: list[bytes]) -> None: + """Decode and dispatch an incoming CMDP message.""" + try: + msg = CMDP1Message.disassemble(frames) + if msg.is_log_message(): + log_msg = CMDP1LogMessage.from_cmdp_message(msg) + self.receive_log(log_msg.to_log_record()) + elif msg.is_stat_message(): + stat_msg = CMDP1StatMessage.from_cmdp_message(msg) + self.receive_metric( + stat_msg.sender, + stat_msg.metric, + stat_msg.time, + stat_msg.value, + ) + except Exception: + pass + + def receive_log(self, record: logging.LogRecord) -> None: + """Called when a log message arrives. Override in subclass.""" + + def receive_metric(self, sender: str, metric, time, value) -> None: + """Called when a metric arrives. Override in subclass.""" From 46fc67855650087b5008dbc0ae5fc5b1a7b1f1fe Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Fri, 29 May 2026 00:38:36 +0200 Subject: [PATCH 11/12] Added async_experimental package to meson build --- python/constellation/meson.build | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/python/constellation/meson.build b/python/constellation/meson.build index 296341976..8c810996e 100644 --- a/python/constellation/meson.build +++ b/python/constellation/meson.build @@ -86,3 +86,18 @@ version_code_name_file = configure_file( py.install_sources(version_code_name_file, subdir: 'constellation/core', ) + +core_async_experimental_files = files( + 'core/async_experimental/__init__.py', + 'core/async_experimental/async_basecontroller.py', + 'core/async_experimental/async_chirp.py', + 'core/async_experimental/async_chirpmanager.py', + 'core/async_experimental/async_heartbeat.py', + 'core/async_experimental/async_heartbeatchecker.py', + 'core/async_experimental/async_monitoringlistener.py', + 'core/async_experimental/async_pools.py', + 'core/async_experimental/bridge.py', +) + +py.install_sources(core_async_experimental_files, + subdir: 'constellation/core/async_experimental') From 1cdc041d32accd597fef69a9907029f5d2b3002d Mon Sep 17 00:00:00 2001 From: VanitasCodes Date: Tue, 2 Jun 2026 21:20:24 +0200 Subject: [PATCH 12/12] Refactored async_experimental --- .../async_basecontroller.py | 142 ++++---- .../core/async_experimental/async_chirp.py | 77 ++++- .../async_experimental/async_chirpmanager.py | 71 ---- .../async_experimental/async_heartbeat.py | 73 +++- .../async_heartbeatchecker.py | 71 ---- .../async_monitoringlistener.py | 19 +- .../core/async_experimental/bridge.py | 321 ------------------ python/constellation/core/base.py | 3 + python/constellation/meson.build | 3 - 9 files changed, 225 insertions(+), 555 deletions(-) delete mode 100644 python/constellation/core/async_experimental/async_chirpmanager.py delete mode 100644 python/constellation/core/async_experimental/async_heartbeatchecker.py delete mode 100644 python/constellation/core/async_experimental/bridge.py diff --git a/python/constellation/core/async_experimental/async_basecontroller.py b/python/constellation/core/async_experimental/async_basecontroller.py index c698cf65b..9d31bf16b 100644 --- a/python/constellation/core/async_experimental/async_basecontroller.py +++ b/python/constellation/core/async_experimental/async_basecontroller.py @@ -6,43 +6,47 @@ """ import asyncio -import threading -from concurrent.futures import ThreadPoolExecutor from typing import Any from uuid import UUID import zmq +import zmq.asyncio -from ..controller import SatelliteUpdate -from ..cscp import CommandTransmitter -from ..logging import setup_cli_logging -from .async_chirp import CHIRPEvent, DiscoveredService -from .async_heartbeatchecker import AsyncHeartbeatChecker -from .async_monitoringlistener import AsyncMonitoringListener +from constellation.core.async_experimental.async_chirp import CHIRPEvent, DiscoveredService +from constellation.core.async_experimental.async_heartbeat import AsyncHeartbeatChecker +from constellation.core.async_experimental.async_monitoringlistener import AsyncMonitoringListener +from constellation.core.chirp import CHIRPServiceIdentifier +from constellation.core.controller import SatelliteUpdate +from constellation.core.logging import setup_cli_logging +from constellation.core.message.cscp1 import CSCP1Message class AsyncBaseController(AsyncMonitoringListener, AsyncHeartbeatChecker): """Async equivalent of BaseController. - CSCP commands run in ThreadPoolExecutor(max_workers=1) to keep - blocking REQ socket operations off the event loop. + CSCP commands use zmq.asyncio with await send/recv directly, eliminating + the dedicated executor and synchronous context. _async_ctx from + BaseSatelliteFrame is used for all ZMQ sockets including CSCP REQ sockets. Discovery and heartbeat run as coroutines. """ - def __init__(self, loop: asyncio.AbstractEventLoop, **kwds: Any) -> None: + def __init__( + self, + loop: asyncio.AbstractEventLoop | None = None, + **kwds: Any, + ) -> None: super().__init__(**kwds) - self._loop = loop - self._transmitters: dict[str, CommandTransmitter] = {} + self._transmitters: dict[str, zmq.asyncio.Socket] = {} self._transmitter_uuids: dict[UUID, str] = {} - self._transmitter_lock = threading.Lock() - self._cscp_executor = ThreadPoolExecutor(max_workers=1) - self._sync_ctx = zmq.Context() + self._cscp_lock = asyncio.Lock() self.register_chirp_callback("basecontroller_control", self._on_control_service) self.register_chirp_callback("basecontroller_heartbeat", self._on_heartbeat_service) def _on_control_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: """Handle CONTROL service connect/disconnect.""" + if service.service_id != CHIRPServiceIdentifier.CONTROL: + return if event == CHIRPEvent.SERVICE_CONNECTED: asyncio.ensure_future(self._setup_transmitter(service.host_id, service.addresses[0], service.port)) elif event == CHIRPEvent.SERVICE_DISCONNECTED: @@ -50,12 +54,33 @@ def _on_control_service(self, event: CHIRPEvent, service: DiscoveredService) -> def _on_heartbeat_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: """Handle HEARTBEAT service connect/disconnect.""" + if service.service_id != CHIRPServiceIdentifier.HEARTBEAT: + return if event == CHIRPEvent.SERVICE_CONNECTED: name = self._transmitter_uuids.get(service.host_id, f"Unknown-{str(service.host_id)[:8]}") self.register_heartbeat_host(service.host_id, service.addresses[0], service.port, name) elif event == CHIRPEvent.SERVICE_DISCONNECTED: self.unregister_heartbeat_host(service.host_id) + async def _cscp_request( + self, + sock: zmq.asyncio.Socket, + command: str, + payload: Any = None, + ) -> CSCP1Message: + """Send a CSCP1 request and await the response. + + _cscp_lock serialises all REQ/REP exchanges so no two coroutines + share a socket simultaneously. + """ + request = CSCP1Message(self.name, (CSCP1Message.Type.REQUEST, command)) + if payload is not None: + request.payload = payload + async with self._cscp_lock: + await sock.send_multipart(request.assemble().frames) + response_frames = await sock.recv_multipart() + return CSCP1Message.disassemble(response_frames) + async def command( self, cmd: str, @@ -63,69 +88,54 @@ async def command( satcls: str = "", payload: Any = None, ) -> str: - """Send a CSCP command via the dedicated executor.""" - - def _send() -> str: - key = f"{satcls}.{sat}".strip(".") - with self._transmitter_lock: - ct = self._transmitters.get(key) - if ct is None: - return f"ERROR: No transmitter for {key}" - try: - msg = ct.request_get_response(command=cmd, payload=payload) - return f"{msg.verb_msg}" - except Exception as e: - return f"ERROR: {e}" - - return await self._loop.run_in_executor(self._cscp_executor, _send) + """Send a CSCP command on the event loop.""" + key = f"{satcls}.{sat}".strip(".") + sock = self._transmitters.get(key) + if sock is None: + return f"ERROR: No transmitter for {key}" + try: + result = await self._cscp_request(sock, cmd, payload) + return f"{result.verb_msg}" + except Exception as e: + return f"ERROR: {e}" def _on_satellite_update(self, name: str, update_type: SatelliteUpdate) -> None: """Called on satellite connect/disconnect. Override in subclass.""" async def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: - """Connect to a satellite CSCP port in the executor.""" - - def _connect(): - sock = self._sync_ctx.socket(zmq.REQ) - sock.connect(f"tcp://{address}:{port}") - sock.setsockopt(zmq.LINGER, 2000) - sock.setsockopt(zmq.RCVTIMEO, 5000) - try: - ct = CommandTransmitter(self.name, sock) - msg = ct.request_get_response("get_commands") - return msg.sender, ct - except Exception: - sock.close() - return None - - result = await self._loop.run_in_executor(self._cscp_executor, _connect) - if result is None: + """Connect to a satellite CSCP port on the event loop.""" + if uuid in self._transmitter_uuids: return - canonical_name, ct = result - with self._transmitter_lock: - self._transmitters[canonical_name] = ct - self._transmitter_uuids[uuid] = canonical_name + sock = self._async_ctx.socket(zmq.REQ) + sock.connect(f"tcp://{address}:{port}") + sock.setsockopt(zmq.LINGER, 2000) + try: + msg = await asyncio.wait_for(self._cscp_request(sock, "get_commands"), timeout=5.0) + except Exception: + sock.close() + return + canonical_name = msg.sender + self._transmitters[canonical_name] = sock + self._transmitter_uuids[uuid] = canonical_name self._on_satellite_update(canonical_name, SatelliteUpdate.ADDED) def _cleanup_transmitter(self, uuid: UUID) -> None: - """Remove and close a CSCP transmitter.""" - with self._transmitter_lock: - canonical_name = self._transmitter_uuids.pop(uuid, None) - if canonical_name is None: - return - ct = self._transmitters.pop(canonical_name, None) - if ct is not None: - ct.close() + """Remove and close a CSCP socket on satellite departure.""" + canonical_name = self._transmitter_uuids.pop(uuid, None) + if canonical_name is None: + return + sock = self._transmitters.pop(canonical_name, None) + if sock is not None: + sock.close() self._on_satellite_update(canonical_name, SatelliteUpdate.REMOVED) def shutdown(self) -> None: """Shut down all components.""" - self._cscp_executor.shutdown(wait=True) - self.close() - with self._transmitter_lock: - for ct in self._transmitters.values(): - ct.close() - self._sync_ctx.term() + for sock in self._transmitters.values(): + sock.close() + self._hb_receiver.close() + self._cmdp_pool.close() + self._async_ctx.term() class AsyncScriptableController(AsyncBaseController): diff --git a/python/constellation/core/async_experimental/async_chirp.py b/python/constellation/core/async_experimental/async_chirp.py index f50367a1f..7c2e7566a 100644 --- a/python/constellation/core/async_experimental/async_chirp.py +++ b/python/constellation/core/async_experimental/async_chirp.py @@ -1,6 +1,8 @@ """ SPDX-FileCopyrightText: 2026 DESY and the Constellation authors SPDX-License-Identifier: EUPL-1.2 + +Async CHIRP listener and manager. """ import asyncio @@ -9,8 +11,10 @@ from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum, auto +from typing import Any from uuid import UUID +from constellation.core.base import BaseSatelliteFrame from constellation.core.chirp import ( CHIRP_MULTICAST_ADDRESS, CHIRP_PORT, @@ -20,6 +24,7 @@ get_uuid, ) from constellation.core.multicast import MULTICAST_TTL +from constellation.core.network import get_interface_addresses class CHIRPEvent(Enum): @@ -233,12 +238,10 @@ def _discover_service(self, msg: CHIRPMessage, from_address: str) -> None: for svc in list(self._discovered_services): if svc.host_id == msg.host_uuid and svc.service_id == msg.serviceid: if svc.port != msg.port: - # Port changed (assume old service is gone) self._discovered_services.remove(svc) for cb in self._callbacks.values(): cb(CHIRPEvent.SERVICE_DISCONNECTED, svc) else: - # Same service, same port (accumulate address if new) if from_address not in svc.addresses: svc.addresses.append(from_address) already_discovered = True @@ -266,22 +269,62 @@ def _depart_service(self, msg: CHIRPMessage) -> None: break -class AsyncCHIRPManager(AsyncCHIRPListener): - """Async CHIRP manager with service registry. +class AsyncCHIRPManager(BaseSatelliteFrame): + """Async equivalent of CHIRPManager. - Inherits AsyncCHIRPListener and adds the ability to register - services to offer and to send service requests. + Owns an AsyncCHIRPListener and directly implements the service registry. + Replaces both the former async_chirpmanager.AsyncCHIRPManager wrapper and + the former AsyncCHIRPManager(AsyncCHIRPListener) registry class. + Registers CHIRP discovery as a coroutine via _add_com_task. """ def __init__( self, name: str, group: str, - interface_addresses: list[str], + interface: list[str] | None, + **kwds: Any, ) -> None: - super().__init__(name, group, interface_addresses) + super().__init__(name=name, **kwds) + self.group = group + interface_addresses = get_interface_addresses(interface) + self._host_uuid = get_uuid(self.name) + self._group_uuid = get_uuid(group) + self._listener = AsyncCHIRPListener( + name=self.name, + group=group, + interface_addresses=interface_addresses, + ) self._registered_services: dict[CHIRPServiceIdentifier, int] = {} - self.request_callback = self._handle_request + self._listener.request_callback = self._handle_request + + def _add_com_task(self) -> None: + """Register the async CHIRP discovery coroutine.""" + super()._add_com_task() + self._com_task_factories.append(self._run_chirp) + + async def _run_chirp(self, stop: asyncio.Event) -> None: + """Start CHIRP discovery and run until stop is set.""" + loop = asyncio.get_running_loop() + await self._listener.start(loop) + await stop.wait() + self._listener.close() + + def register_chirp_callback( + self, + callback_id: str, + callback_func: Callable[[CHIRPEvent, DiscoveredService], None], + ) -> None: + """Register a callback for CHIRP service events.""" + self._listener.register_callback(callback_id, callback_func) + + def unregister_chirp_callback(self, callback_id: str) -> None: + """Remove a previously registered CHIRP callback.""" + self._listener.unregister_callback(callback_id) + + def get_discovered(self, service_id: CHIRPServiceIdentifier) -> list[DiscoveredService]: + """Return discovered services matching service_id.""" + return self._listener.get_discovered(service_id) def request(self, service_id: CHIRPServiceIdentifier) -> None: """Send a CHIRP REQUEST for a specific service type.""" @@ -292,13 +335,13 @@ def request(self, service_id: CHIRPServiceIdentifier) -> None: service_id, 0, ) - self.send_chirp_message(msg) + self._listener.send_chirp_message(msg) def register_service(self, service_id: CHIRPServiceIdentifier, port: int) -> None: """Register a service to offer via CHIRP. - If the service was already registered on a different port, sends DEPART - for the old port first. Sends OFFER immediately upon registration. + Sends DEPART for the old port if already registered. + Sends OFFER immediately upon registration. """ if service_id in self._registered_services: old_port = self._registered_services[service_id] @@ -309,7 +352,7 @@ def register_service(self, service_id: CHIRPServiceIdentifier, port: int) -> Non service_id, old_port, ) - self.send_chirp_message(depart) + self._listener.send_chirp_message(depart) self._registered_services[service_id] = port offer = CHIRPMessage( CHIRPMessageType.OFFER, @@ -318,7 +361,7 @@ def register_service(self, service_id: CHIRPServiceIdentifier, port: int) -> Non service_id, port, ) - self.send_chirp_message(offer) + self._listener.send_chirp_message(offer) def unregister_service(self, service_id: CHIRPServiceIdentifier) -> None: """Unregister a service and send DEPART.""" @@ -331,7 +374,7 @@ def unregister_service(self, service_id: CHIRPServiceIdentifier) -> None: service_id, port, ) - self.send_chirp_message(msg) + self._listener.send_chirp_message(msg) def emit_offers(self) -> None: """Send OFFER for all registered services.""" @@ -343,7 +386,7 @@ def emit_offers(self) -> None: service_id, port, ) - self.send_chirp_message(msg) + self._listener.send_chirp_message(msg) def _handle_request(self, service_id: CHIRPServiceIdentifier) -> None: """Respond to incoming CHIRP requests with matching offers.""" @@ -356,4 +399,4 @@ def _handle_request(self, service_id: CHIRPServiceIdentifier) -> None: service_id, port, ) - self.send_chirp_message(msg) + self._listener.send_chirp_message(msg) diff --git a/python/constellation/core/async_experimental/async_chirpmanager.py b/python/constellation/core/async_experimental/async_chirpmanager.py deleted file mode 100644 index f38a68f9e..000000000 --- a/python/constellation/core/async_experimental/async_chirpmanager.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -SPDX-FileCopyrightText: 2026 DESY and the Constellation authors -SPDX-License-Identifier: EUPL-1.2 - -Async CHIRP manager framework class. -""" - -import asyncio -from typing import Any - -from ..base import BaseSatelliteFrame -from ..chirp import CHIRPServiceIdentifier -from ..network import get_interface_addresses -from .async_chirp import AsyncCHIRPManager as _AsyncCHIRPManagerImpl -from .async_chirp import DiscoveredService - - -class AsyncCHIRPManager(BaseSatelliteFrame): - """Async equivalent of CHIRPManager. - - Uses AsyncCHIRPManager from async_chirp internally. - Registers CHIRP discovery as a coroutine via _add_com_task. - """ - - def __init__( - self, - name: str, - group: str, - interface: list[str] | None, - **kwds: Any, - ) -> None: - super().__init__(name=name, **kwds) - self.group = group - interface_addresses = get_interface_addresses(interface) - self._chirp = _AsyncCHIRPManagerImpl( - name=self.name, - group=group, - interface_addresses=interface_addresses, - ) - - def _add_com_task(self) -> None: - """Register the async CHIRP discovery coroutine.""" - super()._add_com_task() - self._com_task_factories.append(self._run_chirp) - - async def _run_chirp(self, stop: asyncio.Event) -> None: - """Start CHIRP discovery and run until stop is set.""" - loop = asyncio.get_running_loop() - await self._chirp.start(loop) - await stop.wait() - self._chirp.close() - - def register_chirp_callback( - self, - callback_id: str, - callback_func, - ) -> None: - """Register a callback for CHIRP service events.""" - self._chirp.register_callback(callback_id, callback_func) - - def unregister_chirp_callback(self, callback_id: str) -> None: - """Remove a previously registered CHIRP callback.""" - self._chirp.unregister_callback(callback_id) - - def request(self, service_id: CHIRPServiceIdentifier) -> None: - """Send a CHIRP REQUEST for a specific service type.""" - self._chirp.request(service_id) - - def get_discovered(self, service_id: CHIRPServiceIdentifier) -> list[DiscoveredService]: - """Return discovered services matching service_id.""" - return self._chirp.get_discovered(service_id) diff --git a/python/constellation/core/async_experimental/async_heartbeat.py b/python/constellation/core/async_experimental/async_heartbeat.py index 033ecfc34..5f9b4dff0 100644 --- a/python/constellation/core/async_experimental/async_heartbeat.py +++ b/python/constellation/core/async_experimental/async_heartbeat.py @@ -1,6 +1,8 @@ """ SPDX-FileCopyrightText: 2026 DESY and the Constellation authors SPDX-License-Identifier: EUPL-1.2 + +Async heartbeat receiver and checker. """ import asyncio @@ -8,16 +10,17 @@ from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime +from typing import Any from uuid import UUID import zmq.asyncio +from constellation.core.async_experimental.async_pools import AsyncSubscriberPool +from constellation.core.base import BaseSatelliteFrame from constellation.core.chp import CHPRole, chp_decode_message from constellation.core.protocol.cscp1 import SatelliteState from constellation.core.util import case_insensitive_dict -from .async_pools import AsyncSubscriberPool - @dataclass class HeartbeatState: @@ -172,3 +175,69 @@ def close(self) -> None: self._pool.close() self._states.clear() self._name_to_uuid.clear() + + +class AsyncHeartbeatChecker(BaseSatelliteFrame): + """Async equivalent of HeartbeatChecker. + + Owns an AsyncHeartbeatReceiver internally. + Uses self._async_ctx from BaseSatelliteFrame. + Callbacks fire on the event loop — no call_soon_threadsafe needed. + """ + + def __init__(self, **kwds: Any) -> None: + super().__init__(**kwds) + self._hb_receiver = AsyncHeartbeatReceiver( + self._async_ctx, + on_state_change=self._on_state_change, + on_satellite_dead=self._on_satellite_dead, + ) + + def _add_com_task(self) -> None: + """Register the async heartbeat receiver coroutine.""" + super()._add_com_task() + self._com_task_factories.append(self._hb_receiver.run) + + def register_heartbeat_host( + self, + uuid: UUID, + address: str, + port: int, + name: str, + ) -> None: + """Register a satellite for heartbeat tracking.""" + self._hb_receiver.add_satellite(uuid, address, port, name) + + def unregister_heartbeat_host(self, uuid: UUID) -> None: + """Deregister a satellite from heartbeat tracking.""" + self._hb_receiver.remove_satellite(uuid) + + @property + def heartbeat_states(self) -> case_insensitive_dict[SatelliteState]: + """Current states keyed by canonical name.""" + return self._hb_receiver.states + + @property + def heartbeat_state_changes(self) -> case_insensitive_dict[datetime]: + """Last state change timestamps keyed by canonical name.""" + return self._hb_receiver.state_changes + + @property + def heartbeat_statuses(self) -> case_insensitive_dict[str]: + """Last status messages keyed by canonical name.""" + return self._hb_receiver.statuses + + def _on_state_change( + self, + name: str, + old_state: SatelliteState, + new_state: SatelliteState, + ) -> None: + """Called when a satellite changes state. Override in subclass.""" + + def _on_satellite_dead(self, name: str) -> None: + """Called when a satellite stops sending heartbeats. Override in subclass.""" + + def close(self) -> None: + """Close all heartbeat sockets.""" + self._hb_receiver.close() diff --git a/python/constellation/core/async_experimental/async_heartbeatchecker.py b/python/constellation/core/async_experimental/async_heartbeatchecker.py deleted file mode 100644 index c23e0733a..000000000 --- a/python/constellation/core/async_experimental/async_heartbeatchecker.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -SPDX-FileCopyrightText: 2026 DESY and the Constellation authors -SPDX-License-Identifier: EUPL-1.2 - -Async heartbeat checker framework class. -""" - -from typing import Any - -import zmq.asyncio - -from ..base import BaseSatelliteFrame -from ..protocol.cscp1 import SatelliteState -from .async_heartbeat import AsyncHeartbeatReceiver - - -class AsyncHeartbeatChecker(BaseSatelliteFrame): - """Async equivalent of HeartbeatChecker. - - Uses AsyncHeartbeatReceiver internally. - Callbacks fire on the event loop — no call_soon_threadsafe needed. - """ - - def __init__(self, **kwds: Any) -> None: - super().__init__(**kwds) - self._async_ctx = zmq.asyncio.Context() - self._hb_receiver = AsyncHeartbeatReceiver( - self._async_ctx, - on_state_change=self._on_state_change, - on_satellite_dead=self._on_satellite_dead, - ) - - def _add_com_task(self) -> None: - """Register the async heartbeat receiver coroutine.""" - super()._add_com_task() - self._com_task_factories.append(self._hb_receiver.run) - - def register_heartbeat_host( - self, - uuid, - address: str, - port: int, - name: str, - ) -> None: - """Register a satellite for heartbeat tracking.""" - self._hb_receiver.add_satellite(uuid, address, port, name) - - def unregister_heartbeat_host(self, uuid) -> None: - """Deregister a satellite from heartbeat tracking.""" - self._hb_receiver.remove_satellite(uuid) - - @property - def heartbeat_states(self): - """Current states keyed by canonical name.""" - return self._hb_receiver.states - - def _on_state_change( - self, - name: str, - old_state: SatelliteState, - new_state: SatelliteState, - ) -> None: - """Called when a satellite changes state. Override in subclass.""" - - def _on_satellite_dead(self, name: str) -> None: - """Called when a satellite stops sending heartbeats. Override in subclass.""" - - def close(self) -> None: - """Close all heartbeat sockets.""" - self._hb_receiver.close() - self._async_ctx.term() diff --git a/python/constellation/core/async_experimental/async_monitoringlistener.py b/python/constellation/core/async_experimental/async_monitoringlistener.py index b7d472b1a..ae3617371 100644 --- a/python/constellation/core/async_experimental/async_monitoringlistener.py +++ b/python/constellation/core/async_experimental/async_monitoringlistener.py @@ -9,10 +9,18 @@ from typing import Any from uuid import UUID -from ..message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage -from .async_chirp import CHIRPEvent, DiscoveredService -from .async_chirpmanager import AsyncCHIRPManager -from .async_pools import AsyncSubscriberPool +from constellation.core.async_experimental.async_chirp import ( + AsyncCHIRPManager, + CHIRPEvent, + DiscoveredService, +) +from constellation.core.async_experimental.async_pools import AsyncSubscriberPool +from constellation.core.chirp import CHIRPServiceIdentifier +from constellation.core.message.cmdp1 import ( + CMDP1LogMessage, + CMDP1Message, + CMDP1StatMessage, +) class AsyncMonitoringListener(AsyncCHIRPManager): @@ -20,6 +28,7 @@ class AsyncMonitoringListener(AsyncCHIRPManager): Inherits AsyncCHIRPManager and adds CMDP log/metric receiving via AsyncSubscriberPool. + _async_ctx is provided by BaseSatelliteFrame.__init__ via the MRO chain. """ def __init__(self, **kwds: Any) -> None: @@ -41,6 +50,8 @@ def set_topics(self, topics: list[str]) -> None: def _on_monitoring_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: """Handle MONITORING service connect/disconnect.""" + if service.service_id != CHIRPServiceIdentifier.MONITORING: + return if event == CHIRPEvent.SERVICE_CONNECTED: self._cmdp_pool.add_socket(service.host_id, service.addresses[0], service.port) elif event == CHIRPEvent.SERVICE_DISCONNECTED: diff --git a/python/constellation/core/async_experimental/bridge.py b/python/constellation/core/async_experimental/bridge.py deleted file mode 100644 index 73402e365..000000000 --- a/python/constellation/core/async_experimental/bridge.py +++ /dev/null @@ -1,321 +0,0 @@ -""" -SPDX-FileCopyrightText: 2026 DESY and the Constellation authors -SPDX-License-Identifier: EUPL-1.2 -""" - -import asyncio -import threading -from concurrent.futures import ThreadPoolExecutor -from uuid import UUID - -import zmq -import zmq.asyncio - -from constellation.core.chirp import CHIRPServiceIdentifier -from constellation.core.cscp import CommandTransmitter -from constellation.core.message.cmdp1 import CMDP1LogMessage, CMDP1Message, CMDP1StatMessage -from constellation.core.network import get_interface_addresses - -from .async_chirp import AsyncCHIRPManager, CHIRPEvent, DiscoveredService -from .async_heartbeat import AsyncHeartbeatReceiver -from .async_pools import AsyncSubscriberPool - - -class CombinedBridge: - """Combined async bridge. - - All components run in the asyncio event loop. CHIRP uses - asyncio.DatagramProtocol so no dedicated thread is needed for discovery. - CSCP commands run in a single-thread executor to keep blocking REQ socket - operations off the event loop without risking concurrent socket access. - """ - - def __init__(self, group: str, loop: asyncio.AbstractEventLoop) -> None: - self.group = group - self._loop = loop - - self._ctx = zmq.asyncio.Context() - self._sync_ctx = zmq.Context() - - self._stop = asyncio.Event() - - self._heartbeat = AsyncHeartbeatReceiver( - self._ctx, - on_state_change=self._on_state_change, - on_satellite_dead=self._on_satellite_dead, - ) - self._cmdp_pool = AsyncSubscriberPool( - self._ctx, - callback=self._on_cmdp_message, - ) - self._cmdp_pool.set_topics( - [ - "LOG/INFO", - "LOG/WARNING", - "LOG/STATUS", - "LOG/CRITICAL", - "STAT/", - ] - ) - - self._transmitters: dict[str, CommandTransmitter] = {} - self._transmitter_uuids: dict[UUID, str] = {} - self._transmitter_lock = threading.Lock() - self._cscp_executor = ThreadPoolExecutor(max_workers=1) - - self._discovered: dict[UUID, dict] = {} - self._chirp_manager: AsyncCHIRPManager | None = None - - self.stats = { - "state_changes": 0, - "logs": 0, - "metrics": 0, - "commands_sent": 0, - } - - async def start(self) -> None: - """Set up async CHIRP and emit initial service requests.""" - interface_addresses = get_interface_addresses(None) - - self._chirp_manager = AsyncCHIRPManager( - name="CombinedBridge.Bridge", - group=self.group, - interface_addresses=interface_addresses, - ) - self._chirp_manager.register_callback("bridge", self._on_chirp_event) - await self._chirp_manager.start(self._loop) - - self._chirp_manager.request(CHIRPServiceIdentifier.HEARTBEAT) - await asyncio.sleep(0.1) - self._chirp_manager.request(CHIRPServiceIdentifier.MONITORING) - await asyncio.sleep(0.1) - self._chirp_manager.request(CHIRPServiceIdentifier.CONTROL) - - async def run(self) -> None: - """Run heartbeat and CMDP components concurrently.""" - await asyncio.gather( - self._heartbeat.run(self._stop), - self._cmdp_pool.run(self._stop), - ) - - async def send_command(self, canonical_name: str, cmd: str, payload=None) -> str: - """Send a CSCP command via a dedicated single-thread executor. - - The executor serializes all REQ socket operations so concurrent - send_command calls never interleave on the same socket. - """ - - def _send() -> str: - with self._transmitter_lock: - ct = self._transmitters.get(canonical_name) - if ct is None: - return f"ERROR: No transmitter for {canonical_name}" - try: - msg = ct.request_get_response(command=cmd, payload=payload) - return f"{msg.verb_msg}" - except Exception as e: - return f"ERROR: {e}" - - self.stats["commands_sent"] += 1 - return await self._loop.run_in_executor(self._cscp_executor, _send) - - def shutdown(self) -> None: - """Stop all components. - - Cancel and await run() before calling this to avoid terminating - contexts while sockets are still open. - """ - self._stop.set() - if self._chirp_manager is not None: - self._chirp_manager.close() - self._cscp_executor.shutdown(wait=True) - self._heartbeat.close() - self._cmdp_pool.close() - with self._transmitter_lock: - for ct in self._transmitters.values(): - ct.close() - self._ctx.term() - self._sync_ctx.term() - - def _on_chirp_event(self, event: CHIRPEvent, service: DiscoveredService) -> None: - """Handle a CHIRP event. Called from datagram_received on the event loop.""" - uuid = service.host_id - address = service.addresses[0] - port = service.port - service_id = service.service_id - - if event == CHIRPEvent.SERVICE_CONNECTED: - if uuid not in self._discovered: - self._discovered[uuid] = {} - - if service_id in self._discovered[uuid]: - return - - self._discovered[uuid][service_id] = (address, port) - - if service_id == CHIRPServiceIdentifier.HEARTBEAT: - name = self._transmitter_uuids.get(uuid, f"Unknown-{str(uuid)[:8]}") - print(f" [CHIRP] HEARTBEAT from {address}:{port}") - self._heartbeat.add_satellite(uuid, address, port, name) - - elif service_id == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] MONITORING from {address}:{port}") - self._cmdp_pool.add_socket(uuid, address, port) - - elif service_id == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] CONTROL from {address}:{port}") - self._loop.create_task(self._setup_transmitter(uuid, address, port)) - - elif event == CHIRPEvent.SERVICE_DISCONNECTED: - if uuid not in self._discovered: - return - - self._discovered[uuid].pop(service_id, None) - - if service_id == CHIRPServiceIdentifier.HEARTBEAT: - print(f" [CHIRP] DEPART HEARTBEAT {uuid}") - self._heartbeat.remove_satellite(uuid) - - elif service_id == CHIRPServiceIdentifier.MONITORING: - print(f" [CHIRP] DEPART MONITORING {uuid}") - self._cmdp_pool.remove_socket(uuid) - - elif service_id == CHIRPServiceIdentifier.CONTROL: - print(f" [CHIRP] DEPART CONTROL {uuid}") - self._cleanup_transmitter(uuid) - - def _on_state_change(self, name: str, old_state, new_state) -> None: - self.stats["state_changes"] += 1 - print(f" [STATE] {name}: {old_state.name} -> {new_state.name}") - - def _on_satellite_dead(self, name: str) -> None: - print(f" [DEAD] {name} stopped sending heartbeats") - - def _on_cmdp_message(self, uuid: UUID, frames: list[bytes]) -> None: - try: - msg = CMDP1Message.disassemble(frames) - if msg.is_log_message(): - log_msg = CMDP1LogMessage.from_cmdp_message(msg) - record = log_msg.to_log_record() - sender = getattr(record, "sender", record.name) - self.stats["logs"] += 1 - print(f" [LOG] [{record.levelname:8s}] {sender}: {record.getMessage()}") - elif msg.is_stat_message(): - stat_msg = CMDP1StatMessage.from_cmdp_message(msg) - self.stats["metrics"] += 1 - print(f" [STAT] {stat_msg.metric.name} = {stat_msg.value}{stat_msg.metric.unit}") - except Exception: - pass - - async def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: - """Connect to a satellite's CSCP port (runs blocking call in executor).""" - - def _connect() -> tuple[str, CommandTransmitter] | None: - sock = self._sync_ctx.socket(zmq.REQ) - sock.connect(f"tcp://{address}:{port}") - sock.setsockopt(zmq.LINGER, 2000) - sock.setsockopt(zmq.RCVTIMEO, 5000) - try: - ct = CommandTransmitter("CombinedBridge.Bridge", sock) - msg = ct.request_get_response("get_commands") - return msg.sender, ct - except Exception as e: - sock.close() - print(f" [CSCP] Failed to connect to {address}:{port}: {e}") - return None - - result = await self._loop.run_in_executor(self._cscp_executor, _connect) - if result is None: - return - - canonical_name, ct = result - with self._transmitter_lock: - self._transmitters[canonical_name] = ct - self._transmitter_uuids[uuid] = canonical_name - print(f" [CSCP] Connected to {canonical_name}") - - def _cleanup_transmitter(self, uuid: UUID) -> None: - """Remove and close a CSCP transmitter on satellite departure.""" - with self._transmitter_lock: - canonical_name = self._transmitter_uuids.pop(uuid, None) - if canonical_name is None: - return - ct = self._transmitters.pop(canonical_name, None) - if ct is not None: - ct.close() - - -async def main() -> None: - group = "test" - loop = asyncio.get_running_loop() - bridge = CombinedBridge(group, loop) - - print(f"Combined Bridge for group '{group}'") - print("Architecture: asyncio event loop only (no dedicated threads)") - print() - - await bridge.start() - await asyncio.sleep(2) - - run_task = asyncio.create_task(bridge.run()) - - print("Waiting for satellite discovery...") - for _ in range(20): - if bridge._transmitters: - break - await asyncio.sleep(0.5) - - if not bridge._transmitters: - print("No satellites found. Make sure PyRandomTransmitter is running.") - bridge._stop.set() - run_task.cancel() - try: - await run_task - except asyncio.CancelledError: - pass - bridge.shutdown() - return - - sat_name = list(bridge._transmitters.keys())[0] - print(f"\nFound: {sat_name}") - print(f"Current states: {bridge._heartbeat.states}") - print() - - print("--- Sending commands ---") - - result = await bridge.send_command(sat_name, "initialize", {}) - print(f" initialize -> {result}") - await asyncio.sleep(2) - print(f" States: {bridge._heartbeat.states}") - - result = await bridge.send_command(sat_name, "launch") - print(f" launch -> {result}") - await asyncio.sleep(2) - print(f" States: {bridge._heartbeat.states}") - - result = await bridge.send_command(sat_name, "land") - print(f" land -> {result}") - await asyncio.sleep(2) - print(f" States: {bridge._heartbeat.states}") - - await asyncio.sleep(3) - - run_task.cancel() - try: - await run_task - except asyncio.CancelledError: - pass - - bridge.shutdown() - - print() - print("--- Results ---") - print(f" State changes detected: {bridge.stats['state_changes']}") - print(f" Log messages received: {bridge.stats['logs']}") - print(f" Metrics received: {bridge.stats['metrics']}") - print(f" Commands sent: {bridge.stats['commands_sent']}") - print(" Architecture: asyncio event loop only (no dedicated threads)") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/constellation/core/base.py b/python/constellation/core/base.py index 9bc1a6466..7e1c25d1e 100644 --- a/python/constellation/core/base.py +++ b/python/constellation/core/base.py @@ -15,6 +15,7 @@ from typing import Any, cast import zmq +import zmq.asyncio from . import __version__, __version_code_name__ from .logging import ConstellationLogger @@ -106,6 +107,7 @@ def __init__(self, name: str, **_kwds: Any): # add type name to create the canonical name self.name = f"{self.type}.{name}" self.context = zmq.Context() + self._async_ctx = zmq.asyncio.Context() self.log = self.get_logger(self.type.upper()) @@ -197,6 +199,7 @@ def terminate(self) -> None: self.reentry() self.log.debug("Terminating ZMQ context.") self.context.term() + self._async_ctx.term() SATELLITE_LIST: list[BaseSatelliteFrame] = [] diff --git a/python/constellation/meson.build b/python/constellation/meson.build index 8c810996e..cdfd3361c 100644 --- a/python/constellation/meson.build +++ b/python/constellation/meson.build @@ -91,12 +91,9 @@ core_async_experimental_files = files( 'core/async_experimental/__init__.py', 'core/async_experimental/async_basecontroller.py', 'core/async_experimental/async_chirp.py', - 'core/async_experimental/async_chirpmanager.py', 'core/async_experimental/async_heartbeat.py', - 'core/async_experimental/async_heartbeatchecker.py', 'core/async_experimental/async_monitoringlistener.py', 'core/async_experimental/async_pools.py', - 'core/async_experimental/bridge.py', ) py.install_sources(core_async_experimental_files,