From 33b3712870d75bf27075a0cab81eefd73f78db19 Mon Sep 17 00:00:00 2001 From: andreasgriffin Date: Tue, 31 Mar 2026 20:10:29 +0200 Subject: [PATCH] squashed --- bitcoin_usb/jade_ble_client.py | 103 +++++++++++++++++++++-- bitcoin_usb/usb_gui.py | 4 +- tests/test_bluetooth_scan_support.py | 27 +++--- tests/test_jade_ble_client.py | 119 +++++++++++++++++++++++---- 4 files changed, 214 insertions(+), 39 deletions(-) diff --git a/bitcoin_usb/jade_ble_client.py b/bitcoin_usb/jade_ble_client.py index cb90014..06b0bbc 100644 --- a/bitcoin_usb/jade_ble_client.py +++ b/bitcoin_usb/jade_ble_client.py @@ -1,5 +1,6 @@ import asyncio import collections +import logging import os import platform import re @@ -24,6 +25,8 @@ from jadepy import jade_ble as jade_ble_module from jadepy.jade_ble import JadeBleImpl as BlockstreamJadeBleImpl +logger = logging.getLogger(__name__) + DEFAULT_MAX_AUTH_ATTEMPTS = 3 DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS = 6.0 DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS = 15.0 @@ -38,6 +41,40 @@ _PREFERRED_BLE_ADDRESS: ContextVar[str | None] = ContextVar("_PREFERRED_BLE_ADDRESS", default=None) +class _SafeBleakCallbackLoop: + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + def create_future(self) -> asyncio.Future[Any]: + return self._loop.create_future() + + def call_soon_threadsafe(self, callback: Any, *args: Any) -> Any: + if self._loop.is_closed(): + logger.debug("Ignoring late CoreBluetooth callback on closed event loop") + return None + try: + return self._loop.call_soon_threadsafe(callback, *args) + except RuntimeError as e: + if str(e) != "Event loop is closed": + raise + logger.debug("Ignoring late CoreBluetooth callback after event loop shutdown") + return None + + +def _protect_corebluetooth_manager_loop(manager: Any) -> None: + event_loop = manager.event_loop + if isinstance(event_loop, _SafeBleakCallbackLoop): + return + manager.event_loop = _SafeBleakCallbackLoop(event_loop) + + +def _protect_corebluetooth_peripheral_delegate_loop(delegate: Any) -> None: + event_loop = delegate._event_loop + if isinstance(event_loop, _SafeBleakCallbackLoop): + return + delegate._event_loop = _SafeBleakCallbackLoop(event_loop) + + @contextmanager def _preferred_ble_address(address: str | None) -> Iterator[None]: # set() returns a token representing the previous value for this context. @@ -59,11 +96,57 @@ def _extract_jade_serial_number(device_name: str) -> str | None: return match.groupdict().get("serial") +def _protect_corebluetooth_callback_loop(scanner: BleakScanner) -> None: + if platform.system() != "Darwin": + return + backend: Any = scanner._backend + _protect_corebluetooth_manager_loop(backend._manager) + + +def _protect_corebluetooth_client_callback_loops(client: Any) -> None: + if platform.system() != "Darwin": + return + try: + backend = client._backend + except AttributeError: + return + + try: + manager = backend._central_manager_delegate + except AttributeError: + manager = None + if manager is not None: + _protect_corebluetooth_manager_loop(manager) + + try: + delegate = backend._delegate + except AttributeError: + delegate = None + if delegate is not None: + _protect_corebluetooth_peripheral_delegate_loop(delegate) + + +async def _discover_ble_devices_async(scan_timeout: float) -> list[Any]: + scanner = BleakScanner() + _protect_corebluetooth_callback_loop(scanner) + async with scanner: + await asyncio.sleep(max(0.1, scan_timeout)) + return scanner.discovered_devices + + +async def _scan_ble_devices_async(scan_timeout: float) -> list[Any]: + return await _discover_ble_devices_async(scan_timeout=scan_timeout) + + +def scan_ble_devices(loop_in_thread: LoopInThread, scan_timeout: float) -> list[Any]: + return loop_in_thread.run_foreground(_scan_ble_devices_async(scan_timeout=scan_timeout)) + + def discover_jade_ble_devices( loop_in_thread: LoopInThread, scan_timeout: float = DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS, ) -> list[dict[str, Any]]: - devices = loop_in_thread.run_foreground(BleakScanner.discover(timeout=max(1.0, scan_timeout))) + devices = scan_ble_devices(loop_in_thread, scan_timeout=max(1.0, scan_timeout)) discovered: list[dict[str, Any]] = [] seen_addresses: set[str] = set() @@ -193,7 +276,7 @@ async def _input_stream(): self.scan_timeout -= scan_time devices = await self._await_ble_operation( - BleakScanner.discover(timeout=scan_time), + _discover_ble_devices_async(scan_timeout=scan_time), timeout_seconds=float(scan_time) + self.gatt_operation_timeout_seconds, operation_name="discover", ) @@ -237,18 +320,22 @@ def _disconnection_handler(client: Any) -> None: attempts_remaining -= 1 try: client = jade_ble_module.bleak.BleakClient( - device_mac, disconnected_callback=_disconnection_handler + device_mac, + disconnected_callback=_disconnection_handler, ) except TypeError: client = jade_ble_module.bleak.BleakClient(device_mac) needs_set_disconnection_callback = True jade_ble_module.logger.info(f"Connecting to: {full_name} ({device_mac})") - await self._await_ble_operation( - client.connect(), - timeout_seconds=self.connect_timeout_seconds, - operation_name="connect", - ) + try: + await self._await_ble_operation( + client.connect(), + timeout_seconds=self.connect_timeout_seconds, + operation_name="connect", + ) + finally: + _protect_corebluetooth_client_callback_loops(client) connected = client.is_connected jade_ble_module.logger.info(f"Connected: {connected}") except Exception as e: diff --git a/bitcoin_usb/usb_gui.py b/bitcoin_usb/usb_gui.py index f63f761..95a96c5 100644 --- a/bitcoin_usb/usb_gui.py +++ b/bitcoin_usb/usb_gui.py @@ -20,7 +20,7 @@ from bitcoin_usb.address_types import AddressType from bitcoin_usb.dialogs import DeviceDialog, get_message_box -from bitcoin_usb.jade_ble_client import discover_jade_ble_devices +from bitcoin_usb.jade_ble_client import discover_jade_ble_devices, scan_ble_devices from .device import USBDevice, bdknetwork_to_chain from .i18n import translate @@ -41,7 +41,7 @@ def can_scan_bluetooth_devices(loop_in_thread: LoopInThread, probe_timeout: floa return False try: - loop_in_thread.run_foreground(BleakScanner.discover(timeout=max(0.1, probe_timeout))) + scan_ble_devices(loop_in_thread, scan_timeout=max(0.1, probe_timeout)) except Exception as e: logger.info("Bluetooth scanning unavailable in this environment: %s", e) return False diff --git a/tests/test_bluetooth_scan_support.py b/tests/test_bluetooth_scan_support.py index 071760b..d1745a1 100644 --- a/tests/test_bluetooth_scan_support.py +++ b/tests/test_bluetooth_scan_support.py @@ -6,32 +6,33 @@ def test_can_scan_bluetooth_devices_uses_ble_operation_wrapper(monkeypatch) -> N calls: dict[str, object] = {"run_called": False} monkeypatch.setattr(usb_gui, "is_ble_available", lambda: True) - monkeypatch.setattr(usb_gui.BleakScanner, "discover", lambda timeout: {"timeout": timeout}) + loop_in_thread = object() - class _LoopInThread: - def run_foreground(self, coroutine): - calls["run_called"] = True - calls["discover_call"] = coroutine - return [] + def fake_scan_ble_devices(loop, scan_timeout): + calls["run_called"] = True + calls["loop"] = loop + calls["scan_timeout"] = scan_timeout + return [] - loop_in_thread = _LoopInThread() + monkeypatch.setattr(usb_gui, "scan_ble_devices", fake_scan_ble_devices) assert usb_gui.can_scan_bluetooth_devices(loop_in_thread) is True assert calls == { "run_called": True, - "discover_call": {"timeout": 0.2}, + "loop": loop_in_thread, + "scan_timeout": 0.2, } def test_can_scan_bluetooth_devices_returns_false_on_probe_exception(monkeypatch) -> None: monkeypatch.setattr(usb_gui, "is_ble_available", lambda: True) - class _LoopInThread: - def run_foreground(self, coroutine): - coroutine.close() - raise RuntimeError("probe failed") + def fail_scan_ble_devices(_loop_in_thread, _scan_timeout): + raise RuntimeError("probe failed") - assert usb_gui.can_scan_bluetooth_devices(_LoopInThread()) is False + monkeypatch.setattr(usb_gui, "scan_ble_devices", fail_scan_ble_devices) + + assert usb_gui.can_scan_bluetooth_devices(object()) is False def test_get_bluetooth_devices_retries_support_probe_on_macos(monkeypatch) -> None: diff --git a/tests/test_jade_ble_client.py b/tests/test_jade_ble_client.py index dbd015f..cb895dd 100644 --- a/tests/test_jade_ble_client.py +++ b/tests/test_jade_ble_client.py @@ -3,7 +3,13 @@ import pytest from hwilib.devices.jadepy.jade_error import JadeError -from bitcoin_usb.jade_ble_client import CompatibleJadeBleImpl, discover_jade_ble_devices +from bitcoin_usb.jade_ble_client import ( + CompatibleJadeBleImpl, + _SafeBleakCallbackLoop, + _protect_corebluetooth_client_callback_loops, + _scan_ble_devices_async, + discover_jade_ble_devices, +) async def _never_stream(): @@ -54,22 +60,18 @@ def __init__(self, name: str, address: str) -> None: self.address = address captured: dict[str, object] = {} + loop_in_thread = object() - monkeypatch.setattr( - "bitcoin_usb.jade_ble_client.BleakScanner.discover", - lambda timeout: {"timeout": timeout}, - ) + def fake_scan_ble_devices(loop, scan_timeout): + captured["loop"] = loop + captured["scan_timeout"] = scan_timeout + return [ + _Device("Jade ABC123", "AA:BB"), + _Device("Jade ABC123", "AA:BB"), + _Device("Other Device", "CC:DD"), + ] - class _LoopInThread: - def run_foreground(self, coroutine): - captured["discover_call"] = coroutine - return [ - _Device("Jade ABC123", "AA:BB"), - _Device("Jade ABC123", "AA:BB"), - _Device("Other Device", "CC:DD"), - ] - - loop_in_thread = _LoopInThread() + monkeypatch.setattr("bitcoin_usb.jade_ble_client.scan_ble_devices", fake_scan_ble_devices) assert discover_jade_ble_devices(loop_in_thread, scan_timeout=2.5) == [ { @@ -84,4 +86,89 @@ def run_foreground(self, coroutine): "bluetooth_serial_number": "ABC123", } ] - assert captured["discover_call"] == {"timeout": 2.5} + assert captured["loop"] is loop_in_thread + assert captured["scan_timeout"] == 2.5 + + +def test_safe_bleak_callback_loop_ignores_closed_loop_callbacks() -> None: + loop = asyncio.new_event_loop() + wrapped = _SafeBleakCallbackLoop(loop) + loop.close() + + wrapped.call_soon_threadsafe(lambda: None) + + +def test_scan_ble_devices_async_wraps_darwin_manager_loop(monkeypatch) -> None: + class _Manager: + def __init__(self) -> None: + self.event_loop = asyncio.new_event_loop() + + manager = _Manager() + manager_loop = manager.event_loop + + class _Backend: + def __init__(self) -> None: + self._manager = manager + + class _Scanner: + def __init__(self) -> None: + self._backend = _Backend() + self.discovered_devices = ["device"] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def _fake_sleep(_timeout: float) -> None: + return None + + monkeypatch.setattr("bitcoin_usb.jade_ble_client.platform.system", lambda: "Darwin") + monkeypatch.setattr("bitcoin_usb.jade_ble_client.BleakScanner", _Scanner) + monkeypatch.setattr("bitcoin_usb.jade_ble_client.asyncio.sleep", _fake_sleep) + + loop = asyncio.new_event_loop() + try: + assert loop.run_until_complete(_scan_ble_devices_async(0.2)) == ["device"] + finally: + loop.close() + manager_loop.close() + + assert isinstance(manager.event_loop, _SafeBleakCallbackLoop) + + +def test_protect_corebluetooth_client_callback_loops_wraps_manager_and_delegate( + monkeypatch, +) -> None: + class _Manager: + def __init__(self) -> None: + self.event_loop = asyncio.new_event_loop() + + class _Delegate: + def __init__(self) -> None: + self._event_loop = asyncio.new_event_loop() + + class _Backend: + def __init__(self) -> None: + self._central_manager_delegate = _Manager() + self._delegate = _Delegate() + + class _Client: + def __init__(self) -> None: + self._backend = _Backend() + + client = _Client() + manager_loop = client._backend._central_manager_delegate.event_loop + delegate_loop = client._backend._delegate._event_loop + + monkeypatch.setattr("bitcoin_usb.jade_ble_client.platform.system", lambda: "Darwin") + + try: + _protect_corebluetooth_client_callback_loops(client) + finally: + manager_loop.close() + delegate_loop.close() + + assert isinstance(client._backend._central_manager_delegate.event_loop, _SafeBleakCallbackLoop) + assert isinstance(client._backend._delegate._event_loop, _SafeBleakCallbackLoop)