Files
multi-agent-mux/tests/_local_mqtt.py
T
GodopuandClaude Opus 5.5 2826e709ae test(issue-26): no test depends on a third-party MQTT broker
- tests/_local_mqtt.py: in-process MQTT 3.1.1 broker on 127.0.0.1 (what
  the repo uses: protocol level 4, clean session, QoS 0/1, retained).
- conftest: one session broker named by MQTT_BROKER/MQTT_PORT/MQTT_TLS;
  other redirecting/auth MQTT_* dropped and restored afterwards.
- Harnesses of the 22 broker-dependent tests (#18 acceptance1/2/3/5,
  #6 t1_1/2/7/8/14/15/16/20, #19 MQTT loop x2, #33 b3/r3/b7, #30
  s9/s10/r2) use it; the "broker unreachable" skips are gone (a local
  broker that does not deliver is a failure). acceptance1/5 and #19 also
  assert on the subscriber, runner and audit logs the real producers write.
- tests/test_local_mqtt.py: conformance with the real clients and the real
  publish_event -> job_subscriber path (C0), an AST guard against public
  broker literals and MQTT_* deleted without a replacement (C2), and the
  session's broker config (C3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:54:58 +00:00

273 lines
9.6 KiB
Python

"""Issue #26 - a test-only, in-process MQTT 3.1.1 broker.
The suite used to round-trip through the public default broker
(mqtt_common.BrokerConfig.host). conftest starts one of these per session on
127.0.0.1 and points MQTT_BROKER/MQTT_PORT/MQTT_TLS at it, so every real
producer (publish_event, job_subscriber, the wrapper, the remote runner, the
monitor daemon) talks to a broker the test process owns.
Scope is what the repo uses: paho-mqtt 2.x clients with its defaults
(protocol level 4 = 3.1.1, clean session, no will), QoS 0/1, retained
messages. No TLS, no auth checks, no persistent sessions, no QoS 2, no
redelivery of unacknowledged QoS 1 messages. The name does not start with
`test_`, so pytest never collects it.
"""
from __future__ import annotations
import collections
import socket
import struct
import threading
CONNECT, CONNACK, PUBLISH, PUBACK = 1, 2, 3, 4
SUBSCRIBE, SUBACK, UNSUBSCRIBE, UNSUBACK = 8, 9, 10, 11
PINGREQ, PINGRESP, DISCONNECT = 12, 13, 14
NAMES = {CONNECT: "CONNECT", PUBLISH: "PUBLISH", PUBACK: "PUBACK", SUBSCRIBE: "SUBSCRIBE",
UNSUBSCRIBE: "UNSUBSCRIBE", PINGREQ: "PINGREQ", DISCONNECT: "DISCONNECT"}
_CURRENT = None
def topic_matches(filt: str, topic: str) -> bool:
f, t = filt.split("/"), topic.split("/")
if topic.startswith("$") and f[0] in ("+", "#"):
return False
for i, part in enumerate(f):
if part == "#":
return True
if i >= len(t) or (part != "+" and part != t[i]):
return False
return len(f) == len(t)
def _varint(n: int) -> bytes:
out = bytearray()
while True:
n, digit = divmod(n, 128)
out.append(digit | (0x80 if n else 0))
if not n:
return bytes(out)
def _packet(first: int, body: bytes) -> bytes:
return bytes([first]) + _varint(len(body)) + body
def _str(data: bytes, i: int):
(n,) = struct.unpack_from("!H", data, i)
return data[i + 2:i + 2 + n], i + 2 + n
class _Conn:
def __init__(self, sock):
self.sock = sock
self.lock = threading.Lock()
self.subs = {} # filter -> granted qos
self.next_pid = 0
self.client_id = None
def send(self, data: bytes) -> None:
with self.lock:
try:
self.sock.sendall(data)
except OSError:
pass
def recv_exact(self, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("closed")
buf += chunk
return buf
class LocalBroker:
def __init__(self):
self._lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._lsock.bind(("127.0.0.1", 0))
self._lsock.listen(128)
self.host, self.port = self._lsock.getsockname()
self._lock = threading.Lock()
self._conns = set()
self.retained = {} # topic -> (payload, qos)
self.connections = 0
self.protocol_levels = [] # one entry per CONNECT
self.client_ids = []
self.packets = collections.Counter() # inbound packets by name
self._stopped = False
# -- lifecycle -----------------------------------------------------------
def start(self) -> "LocalBroker":
threading.Thread(target=self._accept_loop, name="local-mqtt-accept", daemon=True).start()
return self
def stop(self) -> None:
self._stopped = True
try:
self._lsock.close()
except OSError:
pass
with self._lock:
conns = list(self._conns)
for c in conns:
try:
c.sock.shutdown(socket.SHUT_RDWR)
c.sock.close()
except OSError:
pass
def env(self) -> dict:
return {"MQTT_BROKER": self.host, "MQTT_PORT": str(self.port), "MQTT_TLS": "0"}
def stats(self) -> dict:
with self._lock:
return {"connections": self.connections, "protocol_levels": list(self.protocol_levels),
"packets": dict(self.packets), "open": len(self._conns)}
# -- network -------------------------------------------------------------
def _accept_loop(self) -> None:
while not self._stopped:
try:
sock, _ = self._lsock.accept()
except OSError:
return
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
threading.Thread(target=self._serve, args=(_Conn(sock),), name="local-mqtt-conn", daemon=True).start()
def _serve(self, conn: _Conn) -> None:
with self._lock:
self._conns.add(conn)
try:
while True:
first = conn.recv_exact(1)[0]
length, mult = 0, 1
while True:
b = conn.recv_exact(1)[0]
length += (b & 0x7F) * mult
mult *= 128
if not b & 0x80:
break
body = conn.recv_exact(length) if length else b""
ptype = first >> 4
with self._lock:
self.packets[NAMES.get(ptype, str(ptype))] += 1
if conn.client_id is None and ptype != CONNECT:
return
if not self._handle(conn, ptype, first & 0x0F, body):
return
except (ConnectionError, OSError, struct.error, IndexError):
return
finally:
with self._lock:
self._conns.discard(conn)
try:
conn.sock.close()
except OSError:
pass
def _handle(self, conn: _Conn, ptype: int, flags: int, body: bytes) -> bool:
if ptype == CONNECT:
_name, i = _str(body, 0)
level, cflags = body[i], body[i + 1]
client_id, i = _str(body, i + 4)
with self._lock:
self.connections += 1
self.protocol_levels.append(level)
self.client_ids.append(client_id.decode("utf-8", "replace"))
if level not in (3, 4): # 3.1 / 3.1.1 only (no MQTT v5 in the repo)
conn.send(_packet(CONNACK << 4, b"\x00\x01"))
return False
conn.client_id = client_id.decode("utf-8", "replace")
conn.send(_packet(CONNACK << 4, b"\x00\x00"))
return True
if ptype == PUBLISH:
qos, retain = (flags >> 1) & 3, bool(flags & 1)
topic, i = _str(body, 0)
if qos == 2: # the repo publishes QoS 0/1 only
return False
if qos == 1:
pid = body[i:i + 2]
i += 2
conn.send(_packet(PUBACK << 4, pid))
self._route(topic.decode("utf-8"), body[i:], qos, retain)
return True
if ptype == SUBSCRIBE:
pid, i, granted, new = body[:2], 2, bytearray(), []
while i < len(body):
filt, i = _str(body, i)
q = min(body[i], 1)
i += 1
conn.subs[filt.decode("utf-8")] = q
granted.append(q)
new.append((filt.decode("utf-8"), q))
conn.send(_packet(SUBACK << 4, pid + bytes(granted)))
with self._lock:
retained = list(self.retained.items())
for filt, q in new:
for topic, (payload, rq) in retained:
if topic_matches(filt, topic):
self._deliver(conn, topic, payload, min(rq, q), True)
return True
if ptype == UNSUBSCRIBE:
pid, i = body[:2], 2
while i < len(body):
filt, i = _str(body, i)
conn.subs.pop(filt.decode("utf-8"), None)
conn.send(_packet(UNSUBACK << 4, pid))
return True
if ptype == PINGREQ:
conn.send(_packet(PINGRESP << 4, b""))
return True
if ptype == PUBACK: # a subscriber acknowledging our QoS 1 delivery
return True
return False # DISCONNECT, or anything unsupported
def _route(self, topic: str, payload: bytes, qos: int, retain: bool) -> None:
with self._lock:
if retain:
if payload:
self.retained[topic] = (payload, qos)
else:
self.retained.pop(topic, None)
conns = list(self._conns)
for c in conns:
granted = [q for f, q in list(c.subs.items()) if topic_matches(f, topic)]
if granted:
self._deliver(c, topic, payload, min(qos, max(granted)), False)
def _deliver(self, conn: _Conn, topic: str, payload: bytes, qos: int, retain: bool) -> None:
t = topic.encode("utf-8")
body = struct.pack("!H", len(t)) + t
if qos:
with conn.lock:
conn.next_pid = conn.next_pid % 65535 + 1
pid = conn.next_pid
body += struct.pack("!H", pid)
conn.send(_packet((PUBLISH << 4) | (qos << 1) | int(retain), body + payload))
def start_broker() -> LocalBroker:
global _CURRENT
_CURRENT = LocalBroker().start()
return _CURRENT
def stop_broker() -> None:
global _CURRENT
if _CURRENT is not None:
_CURRENT.stop()
_CURRENT = None
def current_broker() -> LocalBroker:
assert _CURRENT is not None, "the session's local MQTT broker is not running (conftest)"
return _CURRENT
def local_broker_env() -> dict:
"""MQTT_BROKER / MQTT_PORT / MQTT_TLS for this session's local broker."""
return current_broker().env()