Skip to content

Commit 6887c86

Browse files
author
Jeff Whitlatch - ko7m
committed
Allow BLE close cleanup to retry after exceptions
1 parent f2820f7 commit 6887c86

2 files changed

Lines changed: 61 additions & 10 deletions

File tree

‎meshtastic/ble_interface.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -256,24 +256,28 @@ def close(self) -> None:
256256
self._closing = True
257257

258258
try:
259-
MeshInterface.close(self)
260-
except Exception as e:
261-
logger.error(f"Error closing mesh interface: {e}")
259+
try:
260+
MeshInterface.close(self)
261+
except Exception as e:
262+
logger.error(f"Error closing mesh interface: {e}")
262263

263-
if self._want_receive:
264264
self._want_receive = False # Tell the thread we want it to stop
265265
if self._receiveThread:
266266
self._receiveThread.join(
267267
timeout=2
268268
) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect)
269269
self._receiveThread = None
270270

271-
if self.client:
272-
atexit.unregister(self._exit_handler)
273-
self.client.disconnect()
274-
self.client.close()
275-
self.client = None
276-
self._disconnected() # send the disconnected indicator up to clients
271+
if self.client:
272+
self.client.disconnect()
273+
self.client.close()
274+
atexit.unregister(self._exit_handler)
275+
self.client = None
276+
self._disconnected() # send the disconnected indicator up to clients
277+
except BaseException:
278+
# Keep successful closes idempotent, but allow failed cleanup to retry.
279+
self._closing = False
280+
raise
277281

278282

279283
class BLEClient:

‎meshtastic/tests/test_ble_interface.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,53 @@ def fake_disconnect():
9393
with patch("meshtastic.mesh_interface.MeshInterface.close"), \
9494
patch.object(iface, "_disconnected"):
9595
iface.close()
96+
iface.close() # A completed close must also remain idempotent.
9697

9798
assert disconnect_count == 1, "disconnect() should be called exactly once"
9899
assert iface.client is None, "client should be cleaned up"
100+
101+
102+
@pytest.mark.unit
103+
@pytest.mark.parametrize("failure", ["join", "disconnect", "close", "notification"])
104+
def test_ble_close_can_retry_after_cleanup_failure(failure) -> None:
105+
"""A failed cleanup must not permanently disable close()."""
106+
iface = object.__new__(BLEInterface)
107+
iface._closing = False
108+
iface._want_receive = True
109+
receive_thread = MagicMock()
110+
iface._receiveThread = receive_thread
111+
client = MagicMock()
112+
iface.client = client
113+
iface._exit_handler = lambda: None
114+
notification = MagicMock()
115+
iface._disconnected = notification
116+
failing_step = {
117+
"join": receive_thread.join,
118+
"disconnect": client.disconnect,
119+
"close": client.close,
120+
"notification": notification,
121+
}[failure]
122+
failing_step.side_effect = RuntimeError("cleanup failed")
123+
124+
with patch("meshtastic.mesh_interface.MeshInterface.close"), \
125+
patch("meshtastic.ble_interface.atexit.unregister") as unregister:
126+
with pytest.raises(RuntimeError, match="cleanup failed"):
127+
iface.close()
128+
129+
assert iface._closing is False
130+
if failure != "notification":
131+
assert iface.client is client
132+
unregister.assert_not_called()
133+
134+
failing_step.side_effect = None
135+
iface.close()
136+
iface.close()
137+
138+
assert iface._closing is True
139+
assert iface.client is None
140+
assert iface._receiveThread is None
141+
assert iface._want_receive is False
142+
assert failing_step.call_count == 2
143+
unregister.assert_called_once_with(iface._exit_handler)
144+
client.close.assert_called()
145+
notification.assert_called()

0 commit comments

Comments
 (0)