""" Unix-domain socket client for sending opportunity signals to the executor. Connects to the executor's SignalSocketServer and keeps the connection open for burst sending. If the connection is lost the background reconnect loop will retry every 2 seconds. All send operations are non-blocking: a warning is logged if the underlying writer is not connected rather than raising. """ import asyncio import json import uuid from pathlib import Path import structlog logger = structlog.get_logger().bind(component="signal_socket_client") class SignalSocketClient: """ Non-blocking signal sender that maintains a persistent Unix-socket connection. The caller invokes send_signal() for each opportunity; the client serialises the payload and writes it to the socket. If the socket is not connected (e.g. after a server restart) the signal is dropped with a warning log. """ def __init__(self, socket_path: Path) -> None: self._socket_path = socket_path self._log = logger self._writer: asyncio.StreamWriter | None = None self._running = False self._reconnect_task: asyncio.Task | None = None async def start(self) -> asyncio.Task: """Start the background reconnect loop and return the task.""" self._running = True self._reconnect_task = asyncio.create_task(self._reconnect_loop()) return self._reconnect_task async def _reconnect_loop(self) -> None: """ Attempt to connect and then wait for the connection to close. On connection failure a 2-second backoff is applied before retrying. The loop exits when self._running becomes False (see close()). """ while self._running: try: reader, writer = await asyncio.open_unix_connection(path=str(self._socket_path)) self._writer = writer self._log.info("connected", path=str(self._socket_path)) try: await writer.wait_closed() except Exception: pass except (ConnectionRefusedError, FileNotFoundError) as e: if not self._running: break self._log.warning("connection_retrying", error=str(e)) await asyncio.sleep(2.0) except Exception as e: self._log.error("reconnect_error", error=str(e)) await asyncio.sleep(5.0) finally: self._writer = None async def send_signal(self, signal: dict) -> None: """ Serialise a signal dict and write it to the socket. If the socket is not connected this is a no-op (logged as warning). The correlation_id is assigned here if not already set. """ writer = self._writer if not writer: self._log.warning("not_connected") return correlation_id = signal.get("correlation_id", "") or str(uuid.uuid4()) signal["correlation_id"] = correlation_id msg = json.dumps(signal) + "\n" try: writer.write(msg.encode()) await writer.drain() self._log.debug("signal_sent", correlation_id=correlation_id) except Exception as e: self._log.error("signal_send_failed", correlation_id=correlation_id, error=str(e)) async def close(self) -> None: """ Stop the reconnect loop and close the writer if open. Safe to call multiple times; after close() any send_signal() call will be a no-op. """ self._running = False if self._reconnect_task: self._reconnect_task.cancel() try: await self._reconnect_task except asyncio.CancelledError: pass self._reconnect_task = None writer = self._writer if writer: self._writer = None writer.close() try: await writer.wait_closed() except Exception: pass