Skip to content

--ble still hangs on macOS #921

Open

Description

@max-arnold

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

  1. alxrirje commented on Jul 4, 2026

    @alxrirje

    спасибо, заработало

  2. roberthadow commented on Jul 5, 2026

    @roberthadow

    @alxrirje В любое время, товарищ. Главное, чтобы это не было решением для проведения специальной военной операции.

  3. mielune commented on Aug 26, 2026

    @mielune

    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: meshtastic 2.7.11, bleak 3.0.2, Python 3.12, T-Beam / ESP32, firmware 2.6.11. Note that ble_interface.py on master is 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:

    DEBUG ble_interface.py async_await     async_await: waiting for <coroutine object BleakClient.write_gatt_char>
    DEBUG PeripheralDelegate.py            peripheral_didWriteValueForCharacteristic_error_
    DEBUG client.py write_gatt_char        Write Characteristic f75c76d2-...-7866124401e7 : b' \x01'
    DEBUG ble_interface.py async_await     async_await: complete
    DEBUG client.py read_gatt_char         Read Characteristic 2c55e69e-...: bytearray(b'')
    DEBUG ble_interface.py async_await     async_await: complete
    DEBUG ble_interface.py async_await     async_await: waiting for <coroutine object BleakClient.disconnect>
    DEBUG CentralManagerDelegate.py        centralManager_didDisconnectPeripheral_error_
    DEBUG CentralManagerDelegate.py        Peripheral Device disconnected!
    DEBUG ble_interface.py _sendToRadioImpl TORADIO write: 2001          <-- second disconnect
    DEBUG ble_interface.py async_await     async_await: waiting for <coroutine object BleakClient.write_gatt_char>
                                           <-- hangs here forever
    

    2001 decodes as protobuf field 4, varint 1 = ToRadio.disconnect = True, i.e. MeshInterface._sendDisconnect(). It is sent twice: once by the intended close(), then again after the peripheral is already gone.

    Why it hangs rather than erroring

    BLEInterface.connect() wires the callback at ble_interface.py:192:

    client = BLEClient(device.address, disconnected_callback=lambda _: self.close())

    In bleak's CoreBluetooth backend, centralManager_didDisconnectPeripheral_error_ dispatches through try_call_soon_threadsafe(loop, py_delegate.did_disconnect_peripheral, ...) (CentralManagerDelegate.py:177), and did_disconnect_peripheral invokes callback() directly (CentralManagerDelegate.py:399-402). So disconnected_callback — and therefore the re-entrant close() — executes on BLEClient._eventLoop, inside BLEClient._eventThread.

    That re-entrant close() reaches _sendToRadioImpl → write_gatt_char → BLEClient.async_await:

    def async_await(self, coro, timeout=None):
        future = self.async_run(coro)          # run_coroutine_threadsafe(coro, self._eventLoop)
        ...
        result = future.result(timeout)        # ble_interface.py:339 — timeout is always None

    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, so didWriteValueForCharacteristic would 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 thread from self._eventThread.join() in close(), 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:

    1. Re-entrancy guard in BLEInterface.close() (as in your patch) - makes the second _sendDisconnect() a no-op.
    2. Never block the event loop on itself: close() should detect threading.current_thread() is client._eventThread and 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.
    3. A default timeout in 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:

    DEBUG ble_interface.py async_await      async_await: waiting for <coroutine object BleakClient.disconnect>
    DEBUG CentralManagerDelegate.py         centralManager_didDisconnectPeripheral_error_
    DEBUG CentralManagerDelegate.py         Peripheral Device disconnected!
    [patch] close() called from the asyncio loop thread -> re-dispatched      <-- confirms the thread
    [patch] close() re-entrant -> skipped                                     <-- second _sendDisconnect suppressed
    DEBUG ble_interface.py async_await      async_await: complete
    <process exits normally>
    

    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, the BleakClient.disconnect future 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 no close() 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-config etc. are usable with a hard outer timeout (gtimeout 90 meshtastic ..., exit 124) or a ^C, as #909 also notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions