From 192999f178bbccf37361adb67874957eeb511ee0 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 28 Aug 2026 11:46:40 +0200 Subject: [PATCH] Bound MQTT broker operations with a per-node timeout Without one, aiomqtt's disconnect acknowledgement has no deadline, so a subscriber cancelled while its socket is dead never finishes unwinding and teardown abandons the task. The knob is per node because brokers differ. --- backend/fluksio/flow/nodes/mqtt.py | 16 ++++++++++++++++ backend/tests/flow/test_senders.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/fluksio/flow/nodes/mqtt.py b/backend/fluksio/flow/nodes/mqtt.py index f936ca8..499ac2b 100644 --- a/backend/fluksio/flow/nodes/mqtt.py +++ b/backend/fluksio/flow/nodes/mqtt.py @@ -83,6 +83,8 @@ class MqttNode(Node): - ``qos`` (int): Quality of Service level 0, 1, or 2 (default: 0) - ``retain`` (bool): Retain flag for published messages (default: False) - ``keepalive`` (int): Keepalive interval in seconds (default: 60) + - ``timeout`` (float): Deadline for a broker operation in seconds + (default: 10.0) :type params: dict :param name: Optional name for the node. :type name: str | None @@ -151,6 +153,7 @@ class MqttNode(Node): "qos", "retain", "keepalive", + "timeout", "json_keys", "_topic_to_ports", "_wildcards", @@ -175,6 +178,15 @@ class MqttNode(Node): qos: int = 0 retain: bool = False keepalive: int = 60 + # Bounds every broker operation: subscribe, publish, and the + # disconnect acknowledgement on the way out. Without one a client + # whose socket died waits for that ack forever, and the task never + # finishes unwinding. Brokers differ, so it is per node. + timeout: float = Field( + default=10.0, + gt=0, + description="Give up on a broker operation after this many seconds.", + ) # Which key to lift out of a JSON object payload. A device that wraps # its reading — Victron's ``{"value": 5}`` — is otherwise a Python node # per port. One key for every port, or a per-port mapping. @@ -247,6 +259,7 @@ class MqttNode(Node): self.qos = cfg.qos self.retain = cfg.retain self.keepalive = cfg.keepalive + self.timeout = cfg.timeout # Runtime state self._subscription_task: asyncio.Task[None] | None = None @@ -372,6 +385,7 @@ class MqttNode(Node): password=self.password, identifier=self.client_id, keepalive=self.keepalive, + timeout=self.timeout, ) as client: self.report_health("ok") while True: @@ -393,6 +407,7 @@ class MqttNode(Node): password=self.password, identifier=self.client_id, keepalive=self.keepalive, + timeout=self.timeout, ) as client: await self._publish_with(client, data) @@ -556,6 +571,7 @@ class MqttNode(Node): password=self.password, identifier=self.client_id, keepalive=self.keepalive, + timeout=self.timeout, ) as client: # Subscribe to every unique topic for topic in self._topic_to_ports: diff --git a/backend/tests/flow/test_senders.py b/backend/tests/flow/test_senders.py index 0f1ee11..953d246 100644 --- a/backend/tests/flow/test_senders.py +++ b/backend/tests/flow/test_senders.py @@ -89,3 +89,33 @@ def test_a_string_goes_on_the_wire_bare(): asyncio.run(node._publish_with(client, {"plug": "ON", "level": 60})) assert client.published == [("actor/plug", "ON"), ("light/level", "60")] + + +def test_the_configured_timeout_reaches_the_broker_client(monkeypatch): + """Without one, a dead socket makes the disconnect ack wait forever.""" + import aiomqtt + + seen: dict = {} + + class FakeClient: + def __init__(self, **kwargs): + seen.update(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def publish(self, *_, **__): + return None + + monkeypatch.setattr(aiomqtt, "Client", FakeClient) + + node = MqttNode( + requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)], + params={"topic": {"setpoint": "heating/setpoint"}, "timeout": 2.5}, + ) + asyncio.run(node._publish_once({"setpoint": 21.0})) + + assert seen["timeout"] == 2.5