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:
@@ -81,6 +81,7 @@ class ConfigurationTests(unittest.TestCase):
|
||||
self.assertIsNone(config.router.fallback_restore_mode)
|
||||
self.assertEqual(config.storage.uncertain_review_after_seconds, 900.0)
|
||||
self.assertEqual(config.mqtt.publish_ack_timeout_seconds, 2.0)
|
||||
self.assertEqual(config.mqtt.router_echo_grace_seconds, 2.0)
|
||||
|
||||
def test_documented_environment_overrides_apply(self):
|
||||
env = {
|
||||
@@ -152,6 +153,15 @@ class ConfigurationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ConfigError, "must be finite"):
|
||||
load_config(path, environ={})
|
||||
|
||||
def test_negative_router_echo_grace_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.yaml"
|
||||
path.write_text(
|
||||
"mqtt:\n router_echo_grace_seconds: -1\n", encoding="utf-8"
|
||||
)
|
||||
with self.assertRaisesRegex(ConfigError, "router_echo_grace_seconds"):
|
||||
load_config(path, environ={})
|
||||
|
||||
def test_runtime_paths_must_be_absolute(self):
|
||||
cases = (
|
||||
("storage:\n media_base: relative/media\n", "storage.media_base"),
|
||||
|
||||
Reference in New Issue
Block a user