From 69178945e5b64e73413169056673714a3db2d8cf Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 13 Aug 2026 10:31:57 +0200 Subject: [PATCH] fix: prevent libzmq abort and per-frame decode errors in prediction subscriber Sniff for '{' before JSON-parsing zmq parts: JPEG frames start with 0xff and raised UnicodeDecodeError on every frame. Stop closing the socket from the GUI thread in stop(): zmq sockets are not thread-safe and the concurrent close/recv tripped the signaler.cpp POLLIN assertion (core dump); run() closes it in its own thread via the 500ms RCVTIMEO. uv.lock: sync version to 0.8.3. Co-Authored-By: Claude Fable 5 --- src/aare/gui/threads/prediction_subscriber.py | 14 ++++++----- tests/unit/gui/test_prediction_subscriber.py | 24 +++++++++++++++++++ uv.lock | 2 +- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 044fe99f..29fe7876 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -87,6 +87,10 @@ class PredictionSubscriber(QThread): self._measure_focus = enabled def _try_parse_json(self, part: bytes) -> dict | None: + # Binary frames (JPEG starts with 0xff) are not JSON; decoding them as + # utf-8 raises UnicodeDecodeError on every frame. Cheap sniff instead. + if not part.lstrip()[:1] == b"{": + return None try: decoded = json.loads(part.decode("utf-8")) return decoded if isinstance(decoded, dict) else None @@ -254,11 +258,9 @@ class PredictionSubscriber(QThread): self.running = False self.requestInterruption() - try: - if self._sock is not None: - self._sock.close(0) - except Exception: - logger.debug("Error while stopping the prediction subscriber", exc_info=True) - + # Do NOT close the socket here: zmq sockets are not thread-safe, and + # closing from the GUI thread while run() uses it aborts libzmq + # (Assertion failed: pfd.revents & POLLIN, signaler.cpp). RCVTIMEO=500ms + # guarantees run() notices running=False and closes it in its own thread. if not self.wait(1500): logger.warning("PredictionSubscriber did not stop within timeout") diff --git a/tests/unit/gui/test_prediction_subscriber.py b/tests/unit/gui/test_prediction_subscriber.py index e69de29b..6617ba55 100644 --- a/tests/unit/gui/test_prediction_subscriber.py +++ b/tests/unit/gui/test_prediction_subscriber.py @@ -0,0 +1,24 @@ +"""Checks the binary-frame sniff in _try_parse_json: JPEG frames (0xff lead +byte) must be rejected without attempting utf-8 decode (previously raised +UnicodeDecodeError on every frame).""" + +from aare.gui.threads.prediction_subscriber import PredictionSubscriber + + +def _parse(part: bytes): + # Called unbound: _try_parse_json touches no instance state, so no + # QThread/zmq construction is needed. + return PredictionSubscriber._try_parse_json(object(), part) + + +def test_binary_jpeg_frame_is_not_json(): + assert _parse(b"\xff\xd8\xff\xe0somejpegbytes") is None + + +def test_json_dict_is_parsed(): + assert _parse(b'{"encoding": "jpeg"}') == {"encoding": "jpeg"} + + +def test_json_non_dict_is_rejected(): + assert _parse(b"[1, 2]") is None + assert _parse(b"") is None diff --git a/uv.lock b/uv.lock index 9a39da31..72a73a21 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "aaredaq" -version = "0.8.1" +version = "0.8.3" source = { editable = "." } dependencies = [ { name = "aarecommon" },