Skip to content

Commit 9011baa

Browse files
committed
StreamInterface: prevent socket/reader-thread leak on handshake failure in __init__
If connect() or waitForConfig() raises during __init__ (handshake timeout, bad stream, config error), the reader thread started by connect() keeps running and the underlying stream/socket stays open — but the caller never receives a reference to the half-initialized instance, so they cannot call close() themselves. The leak compounds on every retry from a caller's reconnect loop. Fix: wrap connect() + waitForConfig() in try/except; call self.close() on any exception before re-raising. Also guard close() against RuntimeError from joining an unstarted reader thread (happens when close() runs from a failed __init__ before connect() could spawn it). Discovered while debugging a real-world Meshtastic firmware crash where a passive logger's retrying TCPInterface() calls against a node with 250-entry NodeDB produced a reconnect storm — every retry triggered a full config+NodeDB dump on the node, compounding heap pressure, which then exposed null-deref bugs in Router::perhapsDecode / MeshService (firmware side fixed in meshtastic/firmware#10226 and #10229). The client-side leak is independent of those firmware bugs and worth fixing on its own.
1 parent cec79a7 commit 9011baa

1 file changed

Lines changed: 22 additions & 4 deletions

File tree

meshtastic/stream_interface.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Stream Interface base class
22
"""
3+
import contextlib
34
import io
45
import logging
56
import threading
@@ -61,9 +62,20 @@ def __init__( # pylint: disable=R0917
6162

6263
# Start the reader thread after superclass constructor completes init
6364
if connectNow:
64-
self.connect()
65-
if not noProto:
66-
self.waitForConfig()
65+
try:
66+
self.connect()
67+
if not noProto:
68+
self.waitForConfig()
69+
except Exception:
70+
# Handshake failed (timeout, config error, bad stream). The caller
71+
# never receives a reference to this half-initialized instance, so
72+
# they cannot call close() themselves. If we don't clean up here,
73+
# the reader thread (already started by connect()) keeps running
74+
# and the underlying stream/socket leaks — the leak compounds on
75+
# every retry from the caller's reconnect loop.
76+
with contextlib.suppress(Exception):
77+
self.close()
78+
raise
6779

6880
def connect(self) -> None:
6981
"""Connect to our radio
@@ -136,7 +148,13 @@ def close(self) -> None:
136148
# reader thread to close things for us
137149
self._wantExit = True
138150
if self._rxThread != threading.current_thread():
139-
self._rxThread.join() # wait for it to exit
151+
try:
152+
self._rxThread.join() # wait for it to exit
153+
except RuntimeError:
154+
# Thread was never started — happens when close() is invoked
155+
# from a failed __init__ before connect() could spawn it.
156+
# Nothing to join; safe to ignore.
157+
pass
140158

141159
def _handleLogByte(self, b):
142160
"""Handle a byte that is part of a log message from the device."""

0 commit comments

Comments
 (0)