All checks were successful
Nightly Build / build (push) Successful in 6m38s
Discovered while testing the smart-plug power-switch feature: unplugging the printer left the dashboard stuck showing it as online/"ready" indefinitely. Live-tested against a real printer to isolate two independent, compounding causes: 1. The MQTT socket had no TCP keepalive. A connection killed without a clean TCP close (unplugged, not a graceful shutdown) looks alive to the OS for as long as its default dead-connection timeout - 15+ minutes on Linux - since a send on a half-open connection is buffered by the kernel and doesn't fail immediately. Fixed with SO_KEEPALIVE (short idle/interval/count) plus TCP_USER_TIMEOUT, since keepalive probes alone only fire on an idle connection - verified live that a printer disappearing while a send was still in flight (the common case, since the poll loop sends every few seconds) instead falls back to the far slower normal TCP retransmission timer, which keepalive settings don't affect at all. 2. Even after the socket was correctly detected as dead, the status poll loop could hang indefinitely inside publish() waiting for a reconnect attempt already running on the MQTT reader thread (the Issue #105 reconnect-lock serialization), and therefore never reached the is_connected() check that flips kobra_state to "offline". _reconnect() now takes wait_if_in_progress/persist flags so the poll loop's call returns immediately with at most one attempt instead of blocking through someone else's multi-minute backoff loop - persistent retrying stays the reader thread's job. A disconnected printer is now detected and reflected on the dashboard within about 15 seconds. Live-verified across repeated disconnect/ reconnect cycles that no sockets, threads, or file descriptors are left behind (checked via /proc/<pid>/fd and /proc/<pid>/task) - the transient FIN-WAIT-2 entries seen while the printer's TLS service is still booting belong to the kernel's own connection teardown, not to processes held by the bridge, and clear on their own.
153 lines
4.6 KiB
Python
153 lines
4.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
|
|
|
|
|
|
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
|