""" 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 def test_reconnect_non_blocking_returns_immediately_while_reconnect_in_progress(): """The poll-loop path (publish/publish_web) must NOT block while the reader thread's persistent reconnect is running its multi-minute backoff loop - it has to return so the poll loop can flip kobra_state to "offline". A printer unplugged mid-connection otherwise left the dashboard stuck on the last known state indefinitely (Issue #103 follow-up).""" c = _client() c._running = True started = threading.Event() release = threading.Event() def slow_persistent_do_connect(): started.set() # Simulate the printer still being gone: never succeeds until released. release.wait(timeout=5.0) raise OSError("still unreachable") c._do_connect = slow_persistent_do_connect # First reconnect (reader-thread style): persistent, holds the lock, stuck # in backoff. t1 = threading.Thread(target=lambda: c._reconnect(persist=True), daemon=True) t1.start() assert started.wait(timeout=2.0) # Poll-loop style call must return basically instantly, not block on t1. t0 = time.time() result = c._reconnect(wait_if_in_progress=False, persist=False) elapsed = time.time() - t0 assert elapsed < 0.5, f"non-blocking reconnect blocked for {elapsed:.2f}s" assert result is False # socket is down while the other reconnect churns release.set() # let the daemon thread unwind def test_reconnect_one_shot_does_not_loop_on_failure(): """persist=False must attempt the handshake at most once and return, instead of entering the backoff loop (which would block the caller).""" c = _client() c._running = True attempts = [] def failing_do_connect(): attempts.append(1) raise OSError("unreachable") c._do_connect = failing_do_connect t0 = time.time() result = c._reconnect(persist=False) elapsed = time.time() - t0 assert result is False assert len(attempts) == 1 # exactly one attempt, no backoff retries assert elapsed < 0.5