Skip to content

Commit f2820f7

Browse files
ko7mcursoragent
andcommitted
Fix BLE close() deadlock from re-entrant disconnect callback
When close() disconnects the BLE device, Bleak fires the disconnected_callback which calls close() again. The second close() tries to disconnect while the first is still in progress, causing a deadlock. Add a _closing guard flag to prevent the re-entrant call. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0539a96 commit f2820f7

2 files changed

Lines changed: 34 additions & 0 deletions

File tree

meshtastic/ble_interface.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def __init__( # pylint: disable=R0917
5656
)
5757

5858
self.should_read = False
59+
self._closing = False
5960

6061
logger.debug("Threads starting")
6162
self._want_receive = True
@@ -250,6 +251,10 @@ def _sendToRadioImpl(self, toRadio) -> None:
250251
self.should_read = True
251252

252253
def close(self) -> None:
254+
if self._closing:
255+
return
256+
self._closing = True
257+
253258
try:
254259
MeshInterface.close(self)
255260
except Exception as e:

meshtastic/tests/test_ble_interface.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Meshtastic unit tests for ble_interface.py"""
22

3+
import atexit
34
from unittest.mock import MagicMock, patch
45

56
import pytest
@@ -67,3 +68,31 @@ def test_ble_receive_wraps_unexpected_bleak_error_with_kind():
6768
with pytest.raises(BLEInterface.BLEError) as excinfo:
6869
iface._receiveFromRadioImpl()
6970
assert excinfo.value.kind == BLEInterface.BLEError.READ_ERROR
71+
72+
73+
@pytest.mark.unit
74+
def test_ble_close_reentrant_does_not_deadlock() -> None:
75+
"""close() must not deadlock when disconnect callback re-enters close()."""
76+
iface = object.__new__(BLEInterface)
77+
iface._closing = False
78+
iface._want_receive = False
79+
iface._receiveThread = None
80+
81+
disconnect_count = 0
82+
83+
def fake_disconnect():
84+
nonlocal disconnect_count
85+
disconnect_count += 1
86+
iface.close() # re-entrant call — should return immediately
87+
88+
mock_client = MagicMock()
89+
mock_client.disconnect.side_effect = fake_disconnect
90+
iface.client = mock_client
91+
iface._exit_handler = lambda: None
92+
93+
with patch("meshtastic.mesh_interface.MeshInterface.close"), \
94+
patch.object(iface, "_disconnected"):
95+
iface.close()
96+
97+
assert disconnect_count == 1, "disconnect() should be called exactly once"
98+
assert iface.client is None, "client should be cleaned up"

0 commit comments

Comments
 (0)