From caff7db49373f6496ab8cde4f440c2910fa602dc Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 21 Jan 2026 13:57:04 +0100 Subject: [PATCH] plugin: make DeviceMgr.run non-blocking, fix lock Prevents `DeviceMgr.run()` from blocking the `Plugins` `DaemonThread` by scheduling the hww timeout check instead of awaiting its result on the `Plugins` thread. If something in the `_hwd_comms_executor` thread is waiting for user input, e.g. when setting up a hww in the wizard the user needs to unlock the hww for `HardwareClientBase.get_xpub()` to return, the `_hwd_comms_executor` is blocked. If then `DeviceMgr.run()` gets called by the `Plugins` `DaemonThread` concurrently and tries to check the hww timeout on the `_hwd_comms_executor` as well the `DaemonThread` is blocked too until the `_hwd_comms_executor` gets unblocked (and the `DaemonThread.job_lock` is taken. Now if something tries to take the `DaemonThread.job_lock` it blocks as well, so if a user e.g. tries to load a new plugin from the plugins dialog the whole gui thread will freeze until the hww gets unlocked. --- electrum/plugin.py | 16 +++++++++++++--- electrum/util.py | 4 ++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/electrum/plugin.py b/electrum/plugin.py index 021df3e6a..3a7892b52 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -36,6 +36,7 @@ from urllib.parse import urlparse from typing import (NamedTuple, Any, Union, TYPE_CHECKING, Optional, Tuple, Dict, Iterable, List, Sequence, Callable, TypeVar, Mapping) import concurrent +from concurrent.futures import Future import zipimport from functools import wraps, partial from itertools import chain @@ -1040,6 +1041,7 @@ class DeviceMgr(ThreadJob): self._recognised_vendor = {} # type: Dict[int, HW_PluginBase] # vendor_id -> Plugin # Custom enumerate functions for devices we don't know about. self._enumerate_func = set() # Needs self.lock. + self._ongoing_timeout_checks = {} # type: Dict[str, Future] self.lock = threading.RLock() @@ -1053,10 +1055,16 @@ class DeviceMgr(ThreadJob): """Handle device timeouts. Runs in the context of the Plugins thread.""" with self.lock: - clients = list(self.clients.keys()) + clients = list(self.clients.items()) cutoff = time.time() - self.config.get_session_timeout() - for client in clients: - client.timeout(cutoff) + for client, client_id in clients: + if fut := self._ongoing_timeout_checks.get(client_id): + if not fut.done(): + continue + # scheduling the timeout check prevents blocking the Plugins DaemonThread if the + # _hwd_comms_executor Thread is blocked (e.g. due to it awaiting user input). + fut = _hwd_comms_executor.submit(client.timeout, cutoff) + self._ongoing_timeout_checks[client_id] = fut def register_devices(self, device_pairs, *, plugin: 'HW_PluginBase'): for pair in device_pairs: @@ -1113,6 +1121,8 @@ class DeviceMgr(ThreadJob): with self.lock: client = self._client_by_id(id_) self.clients.pop(client, None) + if fut := self._ongoing_timeout_checks.pop(id_, None): + fut.cancel() if client: client.close() diff --git a/electrum/util.py b/electrum/util.py index 5223d8570..300a410a2 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -378,10 +378,14 @@ class DaemonThread(threading.Thread, Logger): # malformed or malicious server responses with self.job_lock: for job in self.jobs: + start = time.perf_counter() try: job.run() except Exception as e: self.logger.exception('') + duration = time.perf_counter() - start + if duration > 0.5: + self.logger.warning(f"thread job {job} blocked {self} DaemonThread for {duration:.2f} s") def remove_jobs(self, jobs): with self.job_lock: