diff --git a/meshtastic/ble_interface.py b/meshtastic/ble_interface.py index 64a63616f..c8a5b13fb 100644 --- a/meshtastic/ble_interface.py +++ b/meshtastic/ble_interface.py @@ -56,6 +56,7 @@ def __init__( # pylint: disable=R0917 ) self.should_read = False + self._closing = False logger.debug("Threads starting") self._want_receive = True @@ -250,6 +251,10 @@ def _sendToRadioImpl(self, toRadio) -> None: self.should_read = True def close(self) -> None: + if self._closing: + return + self._closing = True + try: MeshInterface.close(self) except Exception as e: diff --git a/meshtastic/tests/test_ble_interface.py b/meshtastic/tests/test_ble_interface.py index 0b725e65c..f0569cd0f 100644 --- a/meshtastic/tests/test_ble_interface.py +++ b/meshtastic/tests/test_ble_interface.py @@ -1,5 +1,6 @@ """Meshtastic unit tests for ble_interface.py""" +import atexit from unittest.mock import MagicMock, patch import pytest @@ -67,3 +68,31 @@ def test_ble_receive_wraps_unexpected_bleak_error_with_kind(): with pytest.raises(BLEInterface.BLEError) as excinfo: iface._receiveFromRadioImpl() assert excinfo.value.kind == BLEInterface.BLEError.READ_ERROR + + +@pytest.mark.unit +def test_ble_close_reentrant_does_not_deadlock() -> None: + """close() must not deadlock when disconnect callback re-enters close().""" + iface = object.__new__(BLEInterface) + iface._closing = False + iface._want_receive = False + iface._receiveThread = None + + disconnect_count = 0 + + def fake_disconnect(): + nonlocal disconnect_count + disconnect_count += 1 + iface.close() # re-entrant call — should return immediately + + mock_client = MagicMock() + mock_client.disconnect.side_effect = fake_disconnect + iface.client = mock_client + iface._exit_handler = lambda: None + + with patch("meshtastic.mesh_interface.MeshInterface.close"), \ + patch.object(iface, "_disconnected"): + iface.close() + + assert disconnect_count == 1, "disconnect() should be called exactly once" + assert iface.client is None, "client should be cleaned up"