forked from viewit/KX-Bridge-Release
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.
95 lines
2.6 KiB
Python
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
|