Repository navigation
--ble still hangs on macOS #921
Description
Even with v2.7.8 that includes #893, the CLI still hangs for me on macOS Ventura 13.7.8 and Python 3.14 installed from MacPorts.
I traced the issue down to re-enterant BLEInterface.close():
BLEInterface.close() calls self.client.disconnect(), and Bleak invokes disconnected_callback=lambda _: self.close() during that disconnect, so close() re-enters itself mid-shutdown and hangs.
Just add print("Closing...") to the top of close() to see it yourself.
The following patch fixes that for me, feel free to include it if you think it is a valid approach:
diff --git a/meshtastic/ble_interface.py b/meshtastic/ble_interface.py
index 0237672..a484650 100644
--- a/meshtastic/ble_interface.py
+++ b/meshtastic/ble_interface.py
@@ -234,25 +234,32 @@ def _sendToRadioImpl(self, toRadio) -> None:
self.should_read = True
def close(self) -> None:
+ if getattr(self, "_closing", False):
+ return
+
+ self._closing = True
try:
- MeshInterface.close(self)
- except Exception as e:
- logger.error(f"Error closing mesh interface: {e}")
-
- if self._want_receive:
- self._want_receive = False # Tell the thread we want it to stop
- if self._receiveThread:
- self._receiveThread.join(
- timeout=2
- ) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect)
- self._receiveThread = None
-
- if self.client:
- atexit.unregister(self._exit_handler)
- self.client.disconnect()
- self.client.close()
- self.client = None
- self._disconnected() # send the disconnected indicator up to clients
+ try:
+ MeshInterface.close(self)
+ except Exception as e:
+ logger.error(f"Error closing mesh interface: {e}")
+
+ if self._want_receive:
+ self._want_receive = False # Tell the thread we want it to stop
+ if self._receiveThread:
+ self._receiveThread.join(
+ timeout=2
+ ) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect)
+ self._receiveThread = None
+
+ if self.client:
+ atexit.unregister(self._exit_handler)
+ self.client.disconnect()
+ self.client.close()
+ self.client = None
+ self._disconnected() # send the disconnected indicator up to clients
+ finally:
+ self._closing = False
class BLEClient:To avoid patching the code, I use the following trick as a workaround (place that snippet into your Python's site-packages, e.g. .venv/lib/python3.14/site-packages/meshtastic_ble_close_guard.pth):
import meshtastic.ble_interface as b;exec("o=b.BLEInterface.close\ndef c(self,*a,**k):\n if getattr(self,'_closing',False): return\n self._closing=True\n try: return o(self,*a,**k)\n finally: self._closing=False\nb.BLEInterface.close=c",{"b":b})
Possibly related issue #909
Activity
alxrirje commentedon Jul 4, 2026
спасибо, заработало
roberthadow commentedon Jul 5, 2026
@alxrirje В любое время, товарищ. Главное, чтобы это не было решением для проведения специальной военной операции.
mielune commentedon Aug 26, 2026
Confirming this on macOS with the current release, and I think I can add the missing piece: the re-entrancy you identified is real, but it is not what makes it hang - the hang comes from which thread the re-entrant
close()runs on.Versions:
meshtastic2.7.11,bleak3.0.2, Python 3.12, T-Beam / ESP32, firmware 2.6.11. Note thatble_interface.pyonmasteris currently byte-identical to the 2.7.11 file, so this is unfixed in both.Trace
Tail of
meshtastic --debug --no-nodes --ble <ADDR> --info, trimmed to the teardown:2001decodes as protobuf field 4, varint 1 =ToRadio.disconnect = True, i.e.MeshInterface._sendDisconnect(). It is sent twice: once by the intendedclose(), then again after the peripheral is already gone.Why it hangs rather than erroring
BLEInterface.connect()wires the callback atble_interface.py:192:In bleak's CoreBluetooth backend,
centralManager_didDisconnectPeripheral_error_dispatches throughtry_call_soon_threadsafe(loop, py_delegate.did_disconnect_peripheral, ...)(CentralManagerDelegate.py:177), anddid_disconnect_peripheralinvokescallback()directly (CentralManagerDelegate.py:399-402). Sodisconnected_callback— and therefore the re-entrantclose()— executes onBLEClient._eventLoop, insideBLEClient._eventThread.That re-entrant
close()reaches_sendToRadioImpl→write_gatt_char→BLEClient.async_await:Called from the event-loop thread itself, this blocks the loop on a future only that loop could ever resolve. Deterministic self-deadlock, and since no caller ever passes a
timeout, nothing breaks it out. On top of that the peripheral is already disconnected, sodidWriteValueForCharacteristicwould never fire regardless.This also explains the shape reported in #909 (same final log line, on BlueZ), and it is the same event-loop-thread confusion as #968 - there it surfaces as
RuntimeError: cannot join current threadfromself._eventThread.join()inclose(), here as a silent hang one step earlier in the same call.Suggested direction
Three independent hardenings, any two of which are enough to stop the hang:
BLEInterface.close()(as in your patch) - makes the second_sendDisconnect()a no-op.close()should detectthreading.current_thread() is client._eventThreadand re-dispatch off it. This is also the fix BLE: close() can self-join the event-loop thread on disconnect #968 asks for, since_eventThread.join()has the same problem.async_await- a stalled BLE op should raise, not hang forever. This is the same goal as the now-closed fix(ble): don't hang on a stalled bleak disconnect during teardown #967, but applied at the one chokepoint every BLE call goes through rather than at the disconnect site.Happy to open a PR along these lines if that is useful.
Verified
I instrumented the two suspect code paths locally (log line on entry to the re-entrant
close(), and on detecting that it runs on the event-loop thread). Both fire, in this order:The first marker only triggers on
threading.current_thread() is client._eventThread, so the event-loop-thread claim above is observed, not just inferred from the bleak source. With the second_sendDisconnect()suppressed, theBleakClient.disconnectfuture resolves and the CLI exits cleanly. No hang, on repeated runs.Caveat: this exercises the solicited disconnect (normal teardown), where the re-dispatched
close()correctly finds cleanup already in progress and returns. The unsolicited disconnect of #968 - device dropping out of range with noclose()in flight - is not covered by this test; there the re-dispatched thread would have to perform the real cleanup, and that path is untested here.Workaround meanwhile
The hang is purely at teardown - all requested output has already been written by then.
--info,--export-configetc. are usable with a hard outer timeout (gtimeout 90 meshtastic ..., exit 124) or a^C, as #909 also notes.