The bridge could get permanently stuck after a printer went offline and came back, even with the printer confirmed reachable via ping/nc - only a full container restart recovered it. Two compounding bugs: 1. Two independent code paths could trigger _reconnect() concurrently with no coordination: the reader thread (on a failed keepalive ping) and publish()/publish_web() (on a failed sendall(), which happens constantly once the socket is dead, since the poll loop calls query_info() every poll_interval). Both would race into their own _do_connect(), each opening a competing TLS handshake against a printer that likely only accepts one mTLS session at a time - so neither converges, and the failure repeats every ~3s instead of backing off. Added a lock so a second _reconnect() call waits for the first to finish instead of starting a competing handshake. 2. publish() swallows send/reconnect failures internally and returns None instead of raising - so _poll_loop's `if info: ...` branch was silently skipped on failure, but the surrounding except-block (which would have triggered the existing, correct offline/reconnect transition) was never reached, since no exception was ever thrown. The poll loop had no way to tell "printer sent nothing this tick" apart from "the MQTT session is dead". Added client.is_connected() and check it explicitly when query_info() returns falsy, routing a dead session into the same clean offline branch already used for a TCP-unreachable printer. Verified: bridge continued printing normally throughout (live print in progress on the real printer during this fix), full test suite green (111 tests), new tests cover the concurrent-reconnect lock and the poll-loop offline transition on a swallowed send failure.
97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
"""
|
|
Tests für _poll_loop's Umgang mit einer toten MQTT-Session (Issue #105).
|
|
publish()/query_info() swallow send failures internally and return None
|
|
instead of raising - the poll loop must treat that (combined with
|
|
is_connected() == False) as "connection lost" and switch to the offline
|
|
branch, instead of silently retrying forever every poll_interval.
|
|
"""
|
|
import argparse
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from kobrax_moonraker_bridge import KobraXBridge
|
|
|
|
|
|
def _bridge():
|
|
c = MagicMock()
|
|
c.callbacks = {}
|
|
c.connected = False
|
|
args = argparse.Namespace(
|
|
printer_ip="192.168.1.100", mqtt_port=9883, username="", password="",
|
|
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
|
data_dir=tempfile.mkdtemp(prefix="kxpoll-"), poll_interval=0.05,
|
|
)
|
|
return KobraXBridge(c, args=args)
|
|
|
|
|
|
def test_poll_loop_switches_to_offline_when_query_returns_none_and_disconnected():
|
|
b = _bridge()
|
|
b._state["print_state"] = "standby"
|
|
b._state["kobra_state"] = "free"
|
|
b.client.query_info.return_value = None
|
|
b.client.is_connected.return_value = False
|
|
b._printer_reachable = MagicMock(return_value=True) # TCP still fine
|
|
|
|
stop_event = threading.Event()
|
|
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
|
|
t.start()
|
|
time.sleep(0.2)
|
|
stop_event.set()
|
|
t.join(timeout=2)
|
|
|
|
assert b._state["kobra_state"] == "offline"
|
|
b.client.disconnect.assert_called()
|
|
|
|
|
|
def test_poll_loop_stays_online_when_query_returns_none_but_still_connected():
|
|
"""A single missed poll tick (info momentarily falsy) must not flip the
|
|
bridge offline if the MQTT session itself is still alive."""
|
|
b = _bridge()
|
|
b._state["print_state"] = "standby"
|
|
b._state["kobra_state"] = "free"
|
|
b.client.query_info.return_value = None
|
|
b.client.is_connected.return_value = True # session still up
|
|
b.client.query_multicolor_box.return_value = None
|
|
b._printer_reachable = MagicMock(return_value=True)
|
|
|
|
stop_event = threading.Event()
|
|
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
|
|
t.start()
|
|
time.sleep(0.2)
|
|
stop_event.set()
|
|
t.join(timeout=2)
|
|
|
|
assert b._state["kobra_state"] != "offline"
|
|
|
|
|
|
def test_poll_loop_recovers_via_offline_branch_once_reachable_again():
|
|
"""Once flipped offline, the existing offline branch should re-connect
|
|
as soon as the printer becomes reachable again (pre-existing behavior,
|
|
unaffected by this fix)."""
|
|
b = _bridge()
|
|
b._state["print_state"] = "standby"
|
|
b._state["kobra_state"] = "free"
|
|
b.client.query_info.return_value = None
|
|
b.client.is_connected.return_value = False
|
|
b._printer_reachable = MagicMock(return_value=True)
|
|
|
|
stop_event = threading.Event()
|
|
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
|
|
t.start()
|
|
time.sleep(0.15) # let it flip offline
|
|
assert b._state["kobra_state"] == "offline"
|
|
|
|
# Printer "comes back": client.connect() succeeds, subsequent query_info
|
|
# starts returning real data again.
|
|
b.client.connect.side_effect = None
|
|
b.client.query_info.return_value = {"data": {"state": "free"}}
|
|
time.sleep(0.2)
|
|
stop_event.set()
|
|
t.join(timeout=2)
|
|
|
|
b.client.connect.assert_called()
|