From b3447a28c538a6488d0b2c884f7e652f3b0092f0 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Wed, 15 Jul 2026 22:00:29 +0200 Subject: [PATCH] harden UBX parsing/dispatch and report peer-side disconnects in GUI A struct size-mismatch or any other parse error in a listener could propagate uncaught out of the socket-reading thread and silently kill it. Catch broadly in the event loop and per-listener in call_listener, and make the NAV-SAT/NAV-SIG/RXM-RAWX group parsers tolerate a payload length that isn't an exact multiple of the group size instead of crashing on a short struct.unpack. Also wire NetworkBackend's socket-loss detection through to ReceiverManager (via a queued signal, since the callback fires from the backend thread) so the GUI reflects a peer-initiated disconnect instead of leaving the row stuck showing "connected". Verified against a real ZED-X20P: it drops the TCP session on its own after ~20-28s regardless of traffic; the GUI now marks the receiver disconnected and frees its ID for reconnecting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AzmCaNjDb3TAqPpTwunKTY --- backend/network_backend.py | 7 ++++++- gui/data_model.py | 12 +++++++++++- msg/talker.py | 7 +++++-- ubx/messages/nav.py | 8 ++++++-- ubx/messages/rxm.py | 4 +++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/backend/network_backend.py b/backend/network_backend.py index c5e608e..8f97c81 100644 --- a/backend/network_backend.py +++ b/backend/network_backend.py @@ -10,11 +10,12 @@ from backend.a_backend import ABackend class NetworkBackend(ABackend): - def __init__(self, servers: list[dict]): + def __init__(self, servers: list[dict], on_disconnect=None): self.sel = None self.sock_list = [{'name': s['name'], 'addr': (socket.gethostbyname(s['host']), s['port']), 'xcvr': None, 'queue': Queue()} for s in servers] self.thread = Thread(target=self.event_loop) self.loop_enable = True + self.on_disconnect = on_disconnect def send(self, xcvr: ATransceiver, data: bytes): sock = self.find_by_name(xcvr.name) @@ -89,6 +90,10 @@ class NetworkBackend(ABackend): print(f"Connection {sock['name']} to {sock['addr']} lost: {exc}") self.sel.unregister(key.fileobj) key.fileobj.close() + if self.on_disconnect: + self.on_disconnect(sock['name']) + except Exception as exc: + print(f"Connection {sock['name']}: error processing data, ignoring: {exc}") except KeyboardInterrupt: print("Caught keyboard interrupt, exiting") diff --git a/gui/data_model.py b/gui/data_model.py index 162721f..d7671ec 100644 --- a/gui/data_model.py +++ b/gui/data_model.py @@ -93,12 +93,14 @@ class ReceiverManager(QObject): connection_changed = pyqtSignal(str, bool) # rid, connected receiver_added = pyqtSignal(str) receiver_removed = pyqtSignal(str) + _connection_lost = pyqtSignal(str) # internal: backend thread -> GUI thread def __init__(self): super().__init__() self._states: dict = {} self._configs: dict = {} self._lru: list = self._load_lru() + self._connection_lost.connect(self._handle_connection_lost) # ── LRU ────────────────────────────────────────────────────────────────── @@ -164,7 +166,8 @@ class ReceiverManager(QObject): if config.conn_type == 'tcp': backend = NetworkBackend( - [{'name': rid, 'host': config.host, 'port': config.port}] + [{'name': rid, 'host': config.host, 'port': config.port}], + on_disconnect=lambda name, r=rid: self._connection_lost.emit(r), ) elif config.conn_type == 'serial': backend = SerialBackend(config.serial_port, config.baud_rate) @@ -211,6 +214,13 @@ class ReceiverManager(QObject): self._push_lru(config) self.connection_changed.emit(rid, True) + def _handle_connection_lost(self, rid: str): + state = self._states.get(rid) + if not state or not state.connected: + return + print(f"Receiver '{rid}': connection lost") + self.remove_receiver(rid) + def disconnect_receiver(self, rid: str): state = self._states.get(rid) if not state or not state.connected: diff --git a/msg/talker.py b/msg/talker.py index 187b394..d7314ca 100644 --- a/msg/talker.py +++ b/msg/talker.py @@ -11,5 +11,8 @@ class MsgTalker(ABC): def call_listener(self, msg: MsgContainer): for sink in self.sinks: - if sink.is_msg(msg): - sink.on_recv(msg) + try: + if sink.is_msg(msg): + sink.on_recv(msg) + except Exception as exc: + print(f"Listener {sink.__class__.__name__} failed on {msg}: {exc}") diff --git a/ubx/messages/nav.py b/ubx/messages/nav.py index 96b5bd4..0b1c3e2 100644 --- a/ubx/messages/nav.py +++ b/ubx/messages/nav.py @@ -34,11 +34,13 @@ class Sig(UbxMsg): remain = len(data) - offset group_size = 16 n = offset - while remain: + while remain >= group_size: signal = cls.Group1.from_bytes(data[n:n + group_size]) n += group_size remain -= group_size obj.signals.append(signal) + if remain: + print(f"Sig.from_bytes: {remain} trailing bytes unparsed (payload size mismatch)") return obj.__dict__ class Sat(UbxMsg): @@ -70,9 +72,11 @@ class Sat(UbxMsg): remain = len(data) - offset group_size = 12 n = offset - while remain: + while remain >= group_size: satellite = cls.Group1.from_bytes(data[n:n + group_size]) n += group_size remain -= group_size obj.satellites.append(satellite) + if remain: + print(f"Sat.from_bytes: {remain} trailing bytes unparsed (payload size mismatch)") return obj.__dict__ diff --git a/ubx/messages/rxm.py b/ubx/messages/rxm.py index 781468d..b0503ee 100644 --- a/ubx/messages/rxm.py +++ b/ubx/messages/rxm.py @@ -40,9 +40,11 @@ class RawX(UbxMsg): remain = len(data) - offset group_size = 32 n = offset - while remain: + while remain >= group_size: group = cls.Group1.from_bytes(data[n:n + 32]) n += group_size remain -= group_size obj.meas.append(group) + if remain: + print(f"RawX.from_bytes: {remain} trailing bytes unparsed (payload size mismatch)") return obj.__dict__