"""UNIX-socket client — :class:`UnixSocketBus` implementation of :class:`BaseBus`. Holds one open socket to the local :class:`~decnet.bus.unix_server.BusServer`. Operations: * :meth:`publish` writes a single ``PUB`` frame and returns; no ack. * :meth:`subscribe` writes a ``SUB`` frame and returns a :class:`~decnet.bus.base.Subscription` backed by an :class:`asyncio.Queue` that the background reader task feeds. One background reader task per bus instance dispatches incoming ``EVT`` frames to every registered subscription whose pattern matches the topic. On connection drop or close, every subscription is woken via a sentinel so iterators unblock cleanly; callers see :class:`StopAsyncIteration` from the ``async for`` loop. No auto-reconnect in MVP. If the server restarts, callers must :meth:`close` the bus and construct a new one. This mirrors how other DECNET workers handle their dependencies — the systemd ``Restart=on-failure`` supervision above us is the retry loop. """ from __future__ import annotations import asyncio import contextlib import os import pathlib from typing import Any from decnet.bus import protocol from decnet.bus.base import ( BaseBus, Event, Subscription, _CLOSE_SENTINEL, matches, ) from decnet.bus.fake import _enqueue_drop_oldest as _enqueue_event_drop_oldest from decnet.logging import get_logger log = get_logger("bus.client") _INBOUND_QUEUE_SIZE = 1024 class _UnixSubscription(Subscription): def __init__( self, bus: "UnixSocketBus", pattern: str, queue: "asyncio.Queue[Any]", ) -> None: super().__init__(pattern) self._bus = bus self._queue = queue async def __anext__(self) -> Event: if self._closed: raise StopAsyncIteration item = await self._queue.get() if item is _CLOSE_SENTINEL: raise StopAsyncIteration return item async def _aclose(self) -> None: await self._bus._unregister(self) try: self._queue.put_nowait(_CLOSE_SENTINEL) except asyncio.QueueFull: pass class UnixSocketBus(BaseBus): """Client handle for a local :class:`BusServer`. One instance per process typically; multiple instances simply open multiple sockets to the same server. Connection is lazy — the first :meth:`connect` (or any publish/subscribe call via ``async with``) opens the socket. """ def __init__( self, socket_path: pathlib.Path | str, *, client_name: str | None = None, ) -> None: self._path = pathlib.Path(socket_path) self._client_name = client_name or f"decnet-bus-client[{os.getpid()}]" self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None self._reader_task: asyncio.Task[None] | None = None self._subs: list[_UnixSubscription] = [] self._lock = asyncio.Lock() self._write_lock = asyncio.Lock() self._closed = False # Sticky flag: the first publish-on-closed-bus call logs at # WARNING so operators see that a publish was dropped; subsequent # calls on the same instance log at DEBUG only to prevent a # log flood when stream threads drain after close. The bus is # critical infra, so the first warning is non-negotiable. self._closed_publish_warned = False # ─── Lifecycle ────────────────────────────────────────────────────────── async def connect(self) -> None: if self._writer is not None: return if self._closed: raise RuntimeError("connect on closed bus") self._reader, self._writer = await asyncio.open_unix_connection(str(self._path)) await self._send(protocol.encode(protocol.HELLO, args=self._client_name)) self._reader_task = asyncio.create_task(self._reader_loop()) log.debug("bus.client: connected to %s as %s", self._path, self._client_name) async def close(self) -> None: if self._closed: return self._closed = True # Best-effort BYE — we don't care if it fails. if self._writer is not None and not self._writer.is_closing(): with contextlib.suppress(Exception): await self._send(protocol.encode(protocol.BYE)) if self._reader_task is not None: self._reader_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._reader_task self._reader_task = None if self._writer is not None: with contextlib.suppress(Exception): self._writer.close() await self._writer.wait_closed() self._writer = None self._reader = None # Wake every subscription so `async for` exits. for sub in list(self._subs): with contextlib.suppress(asyncio.QueueFull): sub._queue.put_nowait(_CLOSE_SENTINEL) self._subs.clear() # ─── Pub/Sub ──────────────────────────────────────────────────────────── async def publish( self, topic: str, payload: dict[str, Any], *, event_type: str = "", ) -> None: if self._closed: # Degrade gracefully: the DB is the source of truth, the bus # is only the notification layer. Raising here made every # caller via publish_safely flood the logs once per stream # line during shutdown races. First drop warns loudly; # subsequent drops on the same instance are DEBUG-only. if not self._closed_publish_warned: self._closed_publish_warned = True log.warning( "bus.client: publish on closed bus dropped topic=%s " "(further drops on this instance logged at DEBUG)", topic, ) else: log.debug("bus.client: publish on closed bus dropped topic=%s", topic) return if self._writer is None: await self.connect() body = Event(topic=topic, payload=payload, type=event_type).to_dict() try: await self._send(protocol.encode(protocol.PUB, args=topic, body=body)) except (ConnectionError, BrokenPipeError) as exc: # Bus loss is a logged warning, never a publisher crash. The # DB-as-source-of-truth invariant means the work is already # persisted; the missing event is just a missed notification. log.warning("bus.client: publish failed: %s", exc) def subscribe(self, pattern: str) -> Subscription: if self._closed: raise RuntimeError("subscribe on closed bus") queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=_INBOUND_QUEUE_SIZE) sub = _UnixSubscription(self, pattern, queue) self._subs.append(sub) # Schedule the SUB frame asynchronously so subscribe() stays sync, # matching the BaseBus signature. The caller will shortly `async # with` / `async for` the subscription, which will run the event # loop and pick this task up. asyncio.ensure_future(self._send_sub(pattern)) return sub async def _send_sub(self, pattern: str) -> None: try: if self._writer is None: await self.connect() await self._send(protocol.encode(protocol.SUB, args=pattern)) except Exception as exc: # pragma: no cover - network paths in live tests log.warning("bus.client: SUB %s failed: %s", pattern, exc) async def _unregister(self, sub: _UnixSubscription) -> None: try: self._subs.remove(sub) except ValueError: return # Tell the server we no longer want events for this pattern if no # other local subscription still wants it. if not any(s.pattern == sub.pattern for s in self._subs): with contextlib.suppress(Exception): await self._send(protocol.encode(protocol.UNSUB, args=sub.pattern)) # ─── Internal I/O ─────────────────────────────────────────────────────── async def _send(self, frame_bytes: bytes) -> None: if self._writer is None: raise ConnectionError("bus.client: not connected") async with self._write_lock: self._writer.write(frame_bytes) await self._writer.drain() async def _reader_loop(self) -> None: if self._reader is None: return try: while True: frame = await protocol.read_frame(self._reader) if frame is None: break if frame.verb != protocol.EVT: # Clients only ever legitimately receive EVT (or BYE). if frame.verb == protocol.BYE: break log.warning("bus.client: unexpected verb from server: %s", frame.verb) continue topic = frame.args data = protocol.decode_body(frame.body) if frame.body else {} event = Event.from_dict(topic, data) self._dispatch(event) except protocol.ProtocolError as exc: log.warning("bus.client: protocol error: %s", exc) except (asyncio.IncompleteReadError, ConnectionError): pass except asyncio.CancelledError: raise except Exception: # pragma: no cover log.exception("bus.client: reader loop crashed") finally: # Server-side close — wake every subscription. for sub in list(self._subs): with contextlib.suppress(asyncio.QueueFull): sub._queue.put_nowait(_CLOSE_SENTINEL) def _dispatch(self, event: Event) -> None: for sub in self._subs: if matches(sub.pattern, event.topic): _enqueue_event_drop_oldest(sub._queue, event)