fix: close Audio Router self-echo PUBACK/subscription-delivery race
publish_router_mode registered its self-echo correlation marker before publishing but removed it in a finally block the instant the QoS 1 publish() call observed its PUBACK. A broker may legally deliver a Controller's own subscription copy of that same publish after the PUBACK, since MQTT does not order the two; when that happened, the Controller mistook its own late echo for live operator intent and relinquished automatic restoration ownership. Keep the marker claimable for a short, bounded mqtt.router_echo_grace_seconds window (default 2s) after the PUBACK instead of clearing it immediately. Genuine external/operator commands are unaffected: once the grace window elapses, a matching payload is still correctly treated as operator intent and ownership is relinquished as before. Verified with deterministic fake-clock unit tests plus a real Mosquitto broker integration test (tests/test_router_echo_mosquitto_integration.py, skipped automatically when Docker is unavailable).
This commit is contained in:
+76
-15
@@ -10,6 +10,14 @@ from controller.config import ControllerConfig, MqttConfig
|
||||
from controller.mqtt_adapter import MqttAdapter
|
||||
|
||||
|
||||
class MutableClock:
|
||||
def __init__(self, value=0.0):
|
||||
self.value = value
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class FakeMessageInfo:
|
||||
def __init__(self, mid, published):
|
||||
self.rc = mqtt.MQTT_ERR_SUCCESS
|
||||
@@ -245,7 +253,7 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
("audio/player/mode/set", "video", 1, False),
|
||||
)
|
||||
|
||||
def test_router_self_echo_is_marked_only_while_publish_is_in_flight(self):
|
||||
def test_router_self_echo_is_marked_while_publish_is_in_flight(self):
|
||||
self.adapter.start()
|
||||
self.adapter._on_connect(self.client, None, None, 0)
|
||||
self.events.get_nowait()
|
||||
@@ -268,20 +276,73 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
self.assertTrue(self.adapter.publish_router_mode("video").accepted)
|
||||
self.assertTrue(self.events.get_nowait().controller_echo)
|
||||
|
||||
self.client.publish_hook = None
|
||||
self.adapter._on_message(
|
||||
self.client,
|
||||
None,
|
||||
SimpleNamespace(
|
||||
topic="audio/player/mode/set",
|
||||
payload=b"video",
|
||||
qos=1,
|
||||
retain=False,
|
||||
dup=False,
|
||||
mid=32,
|
||||
),
|
||||
)
|
||||
self.assertFalse(self.events.get_nowait().controller_echo)
|
||||
def test_router_self_echo_delivered_after_puback_is_still_claimed_within_grace(
|
||||
self,
|
||||
):
|
||||
# Regression: a broker may legally deliver our own subscription echo
|
||||
# after the PUBACK that already acknowledged the same publish. If the
|
||||
# echo marker were cleared the instant publish() observes that
|
||||
# PUBACK, this late echo would be mistaken for live operator intent
|
||||
# and cause automatic Router restoration ownership to be relinquished.
|
||||
clock = MutableClock(1000.0)
|
||||
client = FakeClient()
|
||||
events = queue.Queue()
|
||||
adapter = MqttAdapter(self.config, client=client, events=events, monotonic=clock)
|
||||
try:
|
||||
adapter.start()
|
||||
adapter._on_connect(client, None, None, 0)
|
||||
events.get_nowait()
|
||||
|
||||
self.assertTrue(adapter.publish_router_mode("video").accepted)
|
||||
clock.value += self.config.mqtt.router_echo_grace_seconds - 0.5
|
||||
|
||||
adapter._on_message(
|
||||
client,
|
||||
None,
|
||||
SimpleNamespace(
|
||||
topic="audio/player/mode/set",
|
||||
payload=b"video",
|
||||
qos=1,
|
||||
retain=False,
|
||||
dup=False,
|
||||
mid=32,
|
||||
),
|
||||
)
|
||||
self.assertTrue(events.get_nowait().controller_echo)
|
||||
finally:
|
||||
adapter.stop()
|
||||
|
||||
def test_router_self_echo_marker_expires_after_grace_window(self):
|
||||
# A genuine external/operator command that happens to repeat the same
|
||||
# value must still be recognized as operator intent once the bounded
|
||||
# grace window has elapsed; the marker must not linger forever.
|
||||
clock = MutableClock(1000.0)
|
||||
client = FakeClient()
|
||||
events = queue.Queue()
|
||||
adapter = MqttAdapter(self.config, client=client, events=events, monotonic=clock)
|
||||
try:
|
||||
adapter.start()
|
||||
adapter._on_connect(client, None, None, 0)
|
||||
events.get_nowait()
|
||||
|
||||
self.assertTrue(adapter.publish_router_mode("video").accepted)
|
||||
clock.value += self.config.mqtt.router_echo_grace_seconds + 0.5
|
||||
|
||||
adapter._on_message(
|
||||
client,
|
||||
None,
|
||||
SimpleNamespace(
|
||||
topic="audio/player/mode/set",
|
||||
payload=b"video",
|
||||
qos=1,
|
||||
retain=False,
|
||||
dup=False,
|
||||
mid=32,
|
||||
),
|
||||
)
|
||||
self.assertFalse(events.get_nowait().controller_echo)
|
||||
finally:
|
||||
adapter.stop()
|
||||
|
||||
def test_retained_router_set_never_consumes_controller_echo_marker(self):
|
||||
self.adapter.start()
|
||||
|
||||
Reference in New Issue
Block a user