Found during a targeted code review, not from a user report: - _drain() could get permanently stuck: valid JSON that isn't an object (e.g. a bare number or list, which json.loads() happily accepts) crashed _dispatch()'s dict-oriented logic, and the exception escaped before self._buf was advanced past the bad packet - so the same malformed bytes sat at the front of the buffer and re-crashed every subsequent _drain() call, forcing a reconnect each time. Now the buffer always advances (via try/finally) and _dispatch() rejects non-dict payloads with a warning log instead of crashing. - _pending_msgid/_pending_report were mutated without synchronization while the reader thread reads/resolves them in _dispatch() - a check-then-set race on the shared report_key slot meant two concurrent publish() calls for the same msg_type could have one silently miss its reply. Added a dedicated lock around all registration/cleanup. - The report-topic resolution path never verified a reply's msgid matched the waiter it was about to resolve, so a late reply for an already-timed-out request could be delivered to an unrelated, newer caller waiting on the same report_key. Entries now carry their own msgid for this comparison; replies without a msgid still resolve normally (most printer push-reports don't carry one). - upload_gcode() silently sent a request with an empty session token when the upload URL was missing "?s=", instead of raising a clear error at the actual point of failure. It also leaked the upload socket's file descriptor on any send/recv failure other than a timeout, since there was no try/finally around its lifetime. New tests in tests/test_client_robustness.py cover all of the above.
190 lines
7.0 KiB
Python
190 lines
7.0 KiB
Python
"""Robustness fixes found during a targeted code review of kobrax_client.py:
|
|
|
|
1. _drain()/_dispatch() must not get stuck reprocessing the same malformed
|
|
packet forever when the printer sends valid JSON that isn't an object
|
|
(e.g. a bare number or list) - previously an exception from _dispatch()
|
|
escaped before self._buf was advanced, so the same bytes sat at the
|
|
front of the buffer and crashed every subsequent _drain() call, each one
|
|
forcing a reconnect via the reader loop's exception handler.
|
|
2. publish()'s pending-dict registration/cleanup must be safe against
|
|
concurrent callers for the same msg_type, and a stale/late reply must
|
|
not be delivered to a newer, unrelated caller waiting on the same
|
|
report-topic suffix.
|
|
3. upload_gcode() must raise a clear error for a malformed upload URL
|
|
(missing "?s=") instead of silently sending an unauthenticated request,
|
|
and must not leak the upload socket on a send/recv failure.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from kobrax_client import KobraXClient, _build_publish
|
|
|
|
|
|
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 _frame(topic: str, payload) -> bytes:
|
|
"""Builds a raw MQTT PUBLISH frame carrying `payload` as JSON, the same
|
|
wire format _drain() parses."""
|
|
return _build_publish(topic, json.dumps(payload))
|
|
|
|
|
|
# --- Fix 1: malformed (non-dict) JSON payload must not wedge the buffer ---
|
|
|
|
def test_drain_advances_buffer_past_non_dict_payload():
|
|
c = _client()
|
|
# A bare JSON number is valid JSON but not a dict - json.loads succeeds,
|
|
# but _dispatch()'s dict-oriented logic would previously crash on it.
|
|
bad = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", 42)
|
|
good = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", {"state": "done"})
|
|
c._buf = bad + good
|
|
|
|
c._drain() # must not raise
|
|
|
|
# Both packets must have been consumed - the malformed one logged/skipped,
|
|
# the buffer advanced past it so the following valid packet is also
|
|
# processed, not left stuck behind it.
|
|
assert c._buf == b""
|
|
|
|
|
|
def test_drain_does_not_reprocess_bad_packet_on_repeated_calls():
|
|
"""Regression guard for the original bug: before the fix, a crash in
|
|
_dispatch() left self._buf untouched, so the bad packet stayed at the
|
|
front and every _drain() call re-crashed on it."""
|
|
c = _client()
|
|
bad = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", [1, 2, 3])
|
|
c._buf = bad
|
|
|
|
c._drain()
|
|
assert c._buf == b"" # consumed, not stuck
|
|
|
|
# A second call on the now-empty buffer must be a no-op, not a re-crash.
|
|
c._drain()
|
|
assert c._buf == b""
|
|
|
|
|
|
def test_dispatch_rejects_non_dict_payload_directly():
|
|
c = _client()
|
|
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", "not a dict")
|
|
# No exception, no registered pending entries get corrupted.
|
|
assert c._pending_report == {}
|
|
|
|
|
|
# --- Fix 2: pending-dict thread-safety + msgid correlation ---
|
|
|
|
def test_publish_concurrent_same_msg_type_both_get_delivered():
|
|
"""Two overlapping publish() calls for the same msg_type must each
|
|
receive their OWN reply, not have one silently miss out because the
|
|
other already claimed the shared report_key slot."""
|
|
c = _client()
|
|
c._running = True
|
|
c._sock = MagicMock()
|
|
c._ensure_reader = lambda: None # no real reader thread needed for this test
|
|
|
|
results = {}
|
|
|
|
def call(label):
|
|
results[label] = c.publish("info", "query", timeout=2.0)
|
|
|
|
t1 = threading.Thread(target=call, args=("a",))
|
|
t2 = threading.Thread(target=call, args=("b",))
|
|
t1.start()
|
|
time.sleep(0.02)
|
|
t2.start()
|
|
time.sleep(0.05)
|
|
|
|
# Simulate the printer replying to whichever msgid-bearing requests are
|
|
# currently pending, by msgid (the reliable path both requests can use).
|
|
with c._pending_lock:
|
|
pending = dict(c._pending_msgid)
|
|
for msgid, entry in pending.items():
|
|
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
|
{"msgid": msgid, "state": "done", "data": {"ok": True}})
|
|
|
|
t1.join(timeout=3)
|
|
t2.join(timeout=3)
|
|
|
|
assert results["a"] is not None
|
|
assert results["b"] is not None
|
|
|
|
|
|
def test_dispatch_ignores_stale_reply_with_mismatched_msgid():
|
|
"""A late reply carrying a msgid that doesn't match what the current
|
|
report_key waiter is expecting must not be delivered to it - it belongs
|
|
to an earlier, already-resolved/timed-out request."""
|
|
c = _client()
|
|
c._running = True
|
|
c._sock = MagicMock()
|
|
|
|
event = threading.Event()
|
|
entry = {"event": event, "result": None, "msgid": "the-real-one"}
|
|
with c._pending_lock:
|
|
c._pending_report["info/report"] = entry
|
|
|
|
# A stale reply for a different msgid must not resolve this waiter.
|
|
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
|
{"msgid": "some-other-stale-id", "state": "done"})
|
|
assert not event.is_set()
|
|
assert entry["result"] is None
|
|
|
|
# The matching reply must resolve it.
|
|
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
|
{"msgid": "the-real-one", "state": "done"})
|
|
assert event.is_set()
|
|
assert entry["result"]["msgid"] == "the-real-one"
|
|
|
|
|
|
def test_dispatch_delivers_reply_without_msgid_as_before():
|
|
"""Regression guard: plenty of printer reports carry no msgid at all
|
|
(e.g. spontaneous status pushes) - those must still resolve a waiter
|
|
registered without one (entry["msgid"] is falsy)."""
|
|
c = _client()
|
|
event = threading.Event()
|
|
entry = {"event": event, "result": None, "msgid": None}
|
|
with c._pending_lock:
|
|
c._pending_report["info/report"] = entry
|
|
|
|
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
|
{"state": "done"})
|
|
assert event.is_set()
|
|
|
|
|
|
# --- Fix 3: upload_gcode() URL validation + socket cleanup ---
|
|
|
|
def test_upload_gcode_raises_clear_error_on_missing_session_token(tmp_path):
|
|
c = _client()
|
|
f = tmp_path / "test.gcode"
|
|
f.write_text("; gcode")
|
|
|
|
with pytest.raises(RuntimeError, match="session token"):
|
|
c.upload_gcode(str(f), upload_url="http://192.168.1.100:18910/gcode_upload")
|
|
|
|
|
|
def test_upload_gcode_closes_socket_on_send_failure(tmp_path):
|
|
c = _client()
|
|
f = tmp_path / "test.gcode"
|
|
f.write_text("; gcode")
|
|
|
|
fake_sock = MagicMock()
|
|
fake_sock.sendall.side_effect = ConnectionResetError("printer went away")
|
|
|
|
with patch("socket.create_connection", return_value=fake_sock):
|
|
with pytest.raises(ConnectionResetError):
|
|
c.upload_gcode(str(f), upload_url="http://192.168.1.100:18910/gcode_upload?s=tok123")
|
|
|
|
fake_sock.close.assert_called_once()
|