Files
KX-Bridge-Release/tests/test_mqtt_reconnect.py
viewit 0a9bf6def6 fix(mqtt): resolve reconnect deadlock after printer disconnect (Issue #105)
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.
2026-08-02 13:15:23 +02:00

95 lines
2.6 KiB
Python

"""
Tests für den MQTT-Reconnect-Mechanismus (Issue #105) — der Client hing nach
einem Drucker-Reconnect fest, weil zwei unabhängige Fehlerpfade (der Reader-
Thread-Keepalive und publish()'s eigener Reconnect-Trigger) unkoordiniert
parallel liefen, UND weil der Poll-Loop einen None-Rückgabewert von publish()
(statt einer Exception) nie als "Verbindung tot" erkannte.
"""
import threading
import time
import pytest
from kobrax_client import KobraXClient
def _client(**overrides):
kwargs = dict(
host="192.168.1.100", username="u", password="p",
mode_id="20030", device_id="abc123", port=9883,
client_id="test",
)
kwargs.update(overrides)
return KobraXClient(**kwargs)
def test_is_connected_false_when_no_socket():
c = _client()
assert c.is_connected() is False
def test_is_connected_true_when_socket_present():
c = _client()
c._sock = object() # any truthy stand-in for a real socket
assert c.is_connected() is True
def test_reconnect_concurrent_calls_only_run_do_connect_once():
"""Two threads calling _reconnect() at the same time must not both run
_do_connect() - only one handshake should happen; the second caller waits
for the first instead of racing it (Issue #105)."""
c = _client()
c._running = True
do_connect_calls = []
call_lock = threading.Lock()
release_event = threading.Event()
def fake_do_connect():
with call_lock:
do_connect_calls.append(1)
# Simulate a slow handshake so the second _reconnect() call has time
# to observe the lock as already held.
release_event.wait(timeout=2.0)
c._sock = object()
c._do_connect = fake_do_connect
results = []
def run():
results.append(c._reconnect())
t1 = threading.Thread(target=run)
t2 = threading.Thread(target=run)
t1.start()
time.sleep(0.05) # let t1 acquire the lock and enter _do_connect first
t2.start()
time.sleep(0.1)
release_event.set() # let the in-flight handshake finish
t1.join(timeout=3)
t2.join(timeout=3)
assert len(do_connect_calls) == 1
assert results == [True, True]
def test_reconnect_second_waiter_returns_after_first_completes():
c = _client()
c._running = True
def fake_do_connect():
time.sleep(0.1)
c._sock = object()
c._do_connect = fake_do_connect
t1 = threading.Thread(target=c._reconnect)
t1.start()
time.sleep(0.02)
# Second call while the first is still mid-handshake.
result = c._reconnect()
t1.join(timeout=3)
assert result is True
assert c._sock is not None