Files
swaits b15b661c2d feat!: mux-py on DongLoRa Protocol v2 with tag-aware dispatch
The 1.0 Python mux, mirroring the Rust donglora-mux 1.0 one-for-one and implementing the full §13 multi-client semantics on the DongLoRa Protocol v2 wire format.

* Structural rewrite. donglora_mux/{daemon,intercept,session,tagmap}.py replace the 601-line __init__.py monolith.

* Tag-aware dispatch. TagMapper with skip-zero allocation and per-client bulk drop on disconnect. Multiple clients can have commands in flight concurrently; the single-oneshot response slot is gone.

* Full §13.2 multi-client SET_CONFIG semantics. Tracks (client_id, modulation) lock owner and synthesizes OK(APPLIED/MINE), OK(ALREADY_MATCHED/OTHER), or OK(LOCKED_MISMATCH/OTHER).

* TX loopback fan-out (§13.4). On TX_DONE(TRANSMITTED) the mux synthesizes an RX frame with origin = LOCAL_LOOPBACK for every connected client except the transmitter.

* Async ERR(EFRAME) fan-out: tag-0 framing errors are broadcast to all clients.

* Per-client TX cache on ClientSession so the loopback fan-out does not need the sender to re-buffer.

* Socket and serial fcntl.flock to prevent multiple mux instances from fighting over the same dongle or socket path.

* 500 ms keepalive matching PROTOCOL.md §3.4 (2x margin on the 1 s inactivity timer), replacing the 5 s cadence from 0.x.

* Clean SIGINT / SIGTERM shutdown: per-client handler tasks are tracked and cancelled, so blocked reader.read() calls no longer pin the event loop open.
2026-04-22 09:55:28 -06:00

78 lines
2.8 KiB
Python

"""Per-client session state and bounded send queue.
Each connected client gets a :class:`ClientSession` tracking its id,
RX-interest flag, a per-client TX cache (used by the §13.4 loopback
fan-out), and a bounded asyncio queue of outgoing wire-encoded frames.
Mirrors ``mux-rs/src/session.rs``.
"""
from __future__ import annotations
import asyncio
import contextlib
import itertools
import logging
log = logging.getLogger("donglora-mux")
# Bounded send queue capacity per client. Matches mux-rs SEND_QUEUE_CAP.
SEND_QUEUE_CAP = 256
# Emit a drop-count summary after this many drops per client — a flood of
# RX while the client is slow shouldn't produce one log line per frame.
DROP_LOG_EVERY = 32
# Monotonic client-id source. Shared across every session in the process.
_next_client_id = itertools.count()
class ClientSession:
"""State carried for a single connected client.
Does NOT own the underlying stream; the daemon spawns a per-client
reader/writer pair of asyncio tasks that do I/O directly. This type
is the shared rendez-vous point those tasks enqueue frames through.
"""
__slots__ = ("_queue", "drops", "id", "rx_interested", "tx_cache")
def __init__(self) -> None:
self.id: int = next(_next_client_id)
self.rx_interested: bool = False
# Keyed by the client's upstream tag. Populated when a TX is
# forwarded to the dongle; consumed on TX_DONE(TRANSMITTED) by
# the reader so it can synthesize a loopback RX for other
# clients without asking the sender to re-buffer the payload.
self.tx_cache: dict[int, bytes] = {}
self._queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=SEND_QUEUE_CAP)
self.drops: int = 0
@property
def label(self) -> str:
return f"client-{self.id}"
async def next_outgoing(self) -> bytes:
"""Await the next frame to send. Intended for the client-writer task."""
return await self._queue.get()
def enqueue(self, frame: bytes) -> None:
"""Best-effort non-blocking enqueue of a wire frame.
Full queue → drop the frame, bump the counter, occasionally log.
This prevents one slow client from back-pressuring the whole mux.
"""
try:
self._queue.put_nowait(frame)
except asyncio.QueueFull:
self.drops += 1
if self.drops == 1 or self.drops % DROP_LOG_EVERY == 0:
log.warning("%s: send queue full, dropped %d frames so far", self.label, self.drops)
def close(self, writer: asyncio.StreamWriter | None = None) -> None:
"""Best-effort close. The daemon normally drives shutdown via
task cancellation + stream close; this is a safety net."""
if writer is not None:
with contextlib.suppress(Exception):
writer.close()