From 13259e881db571e89cabdca284ec3463364ab861 Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 18:37:24 +0000 Subject: [PATCH] feat(net): parallel Check + timeout counters + GUI tooltips/order Networking (anti-freeze): - Add check_transactions_parallel: pressing "Check" now contacts will-executors concurrently with a fast-fail timeout (CHECK_TIMEOUT=8, 1 retry) and a global deadline (CHECK_GLOBAL_DEADLINE=30), so a single dead server no longer freezes the "checking transaction" dialog for ~140s (old default 10s x 10 retries). - check_transaction now accepts timeout/max_retries/retry_sleep kwargs. - Rewrite BalWindow.check_transactions_task to use the parallel helper with live progress + an elapsed-time counter "Checking transactions: 2/5 (4s / 30s)". Reliable elapsed-time counters (Xs / DEADLINEs): - Replace the unreliable raw heartbeat thread (whose pyqtSignal emissions were not marshalled and never repainted) with an on_tick callback driven from the CALLING thread in push/ping/check parallel helpers. - Show the maximum wait too (e.g. "3s / 30s") so the user knows when the operation will give up, in the wizard Broadcasting, Ping and Check dialogs. - Expose networking constants (PUSH_*/CHECK_*/DEFAULT_TIMEOUT) as Willexecutors class attributes for a single source of truth in the GUI. GUI: - Add hover tooltips: Wizard ("Wizard - Build your will"), Delivery time (truck), Check Alive (siren), Calendar, Check (refresh). - Reorder the Will toolbar to: Wizard | Delivery time | Check Alive | Calendar | Check; tighten layout margins so it all fits the Will window. Tests: - parallel_ping_test.py: add coverage for check_transactions_parallel (parallel timing, global deadline, on_tick from the calling thread) and static checks that check_transactions_task/loop_push use the parallel helpers + on_tick counter with the 'Xs / Ns' format. Verified: ruff (0 new issues), 182 official tests pass, parallel/smoke/gui_fixes/ windows_overflow/external_zip tests pass. --- bal/core/willexecutors.py | 349 +++++++++++++++++++++++++++++++----- bal/gui/qt/dialogs.py | 54 +++++- bal/gui/qt/lists.py | 11 +- bal/gui/qt/widgets.py | 9 + bal/gui/qt/window.py | 120 +++++++++++-- tests/parallel_ping_test.py | 204 +++++++++++++++++++++ 6 files changed, 689 insertions(+), 58 deletions(-) diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py index 4231766..b1bd643 100644 --- a/bal/core/willexecutors.py +++ b/bal/core/willexecutors.py @@ -25,7 +25,36 @@ from electrum.network import Network from .plugin_base import BalPlugin +# Per-request timeout (seconds) for interactive operations (ping / info / +# list download). These fail fast (no retries) so a dead server does not +# block the UI. DEFAULT_TIMEOUT = 5 + +# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a +# couple of quick retries to survive a transient hiccup -- but far from the old +# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server. +# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES) +# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard +# also enforces a global deadline on top of this (see push_transactions_parallel). +PUSH_TIMEOUT = 8 +PUSH_MAX_RETRIES = 2 +PUSH_RETRY_SLEEP = 1 + +# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once +# it elapses we stop waiting for the still-pending servers, mark them as +# "Timeout" and let the wizard proceed instead of appearing stuck. +PUSH_GLOBAL_DEADLINE = 30 + +# Check (searchtx) timeouts. Used when the user presses "Check" to verify that +# each will-executor still holds the transaction. Like the broadcast path, the +# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the +# "checking transaction" dialog on a single dead server. Fail fast with one +# quick retry, and cap the whole batch with a global deadline. +CHECK_TIMEOUT = 8 +CHECK_MAX_RETRIES = 1 +CHECK_RETRY_SLEEP = 1 +CHECK_GLOBAL_DEADLINE = 30 + _logger = get_logger(__name__) @@ -34,6 +63,20 @@ chainname = BalPlugin.chainname class Willexecutors: + # Expose the networking constants as class attributes so the GUI layer can + # reference them (e.g. to show the "Xs / DEADLINEs" countdown) without + # importing module-level names. Single source of truth: the module + # constants defined above. + DEFAULT_TIMEOUT = DEFAULT_TIMEOUT + PUSH_TIMEOUT = PUSH_TIMEOUT + PUSH_MAX_RETRIES = PUSH_MAX_RETRIES + PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP + PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE + CHECK_TIMEOUT = CHECK_TIMEOUT + CHECK_MAX_RETRIES = CHECK_MAX_RETRIES + CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP + CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE + @staticmethod def save(bal_plugin, willexecutors): _logger.debug(f"save {willexecutors},{chainname}") @@ -241,7 +284,15 @@ class Willexecutors: pass @staticmethod - def push_transactions_to_willexecutor(willexecutor): + def push_transactions_to_willexecutor( + willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES, + retry_sleep=PUSH_RETRY_SLEEP, + ): + # ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to + # send_request so the broadcast fails fast on a dead/slow server instead + # of hanging for ~140s (the old default was 10s timeout x 10 retries + + # 30s of sleeps). A small number of quick retries still protects + # against a transient hiccup without freezing the wizard. out = True try: _logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}") @@ -249,6 +300,9 @@ class Willexecutors: "post", willexecutor["url"] + "/" + chainname + "/pushtxs", data=willexecutor["txs"].encode("ascii"), + timeout=timeout, + max_retries=max_retries, + retry_sleep=retry_sleep, ): willexecutor["broadcast_status"] = _("Success") _logger.debug(f"pushed: {w}") @@ -304,7 +358,8 @@ class Willexecutors: @staticmethod def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, - timeout=DEFAULT_TIMEOUT): + timeout=DEFAULT_TIMEOUT, on_tick=None, + tick_interval=1.0): """Ping every will-executor concurrently and report results as they arrive. @@ -324,10 +379,16 @@ class Willexecutors: max_workers: maximum number of concurrent pings. timeout: per-request timeout in seconds (fast-fail, no retries). + on_tick: optional ``callback()`` invoked periodically (every + ``tick_interval`` seconds) **from the calling thread** while + waiting for servers, so a Qt caller can refresh an elapsed-time + counter from the same thread that drives ``on_each``. + Returns: The same ``willexecutors`` mapping, updated in place. """ - from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED items = list(willexecutors.items()) if not items: @@ -340,38 +401,75 @@ class Willexecutors: ok = we.get("status") == 200 return url, we, ok - workers = max(1, min(max_workers, len(items))) - with ThreadPoolExecutor(max_workers=workers, - thread_name_prefix="bal-ping") as pool: - futures = [pool.submit(_ping_one, url, we) for url, we in items] - for fut in as_completed(futures): + def _fire_tick(): + if on_tick is not None: try: - url, we, ok = fut.result() - except Exception as e: # defensive: never let one server crash all - _logger.error(f"ping_servers_parallel worker error: {e}") - continue - willexecutors[url] = we - if on_each is not None: + on_tick() + except Exception as cb_err: + _logger.error(f"ping on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(items))) + # Manual pool (no ``with``) so we can poll futures in short slices and + # drive ``on_tick`` from THIS thread between waits (reliable Qt repaint). + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping") + futures = {pool.submit(_ping_one, url, we) for url, we in items} + try: + pending = set(futures) + while pending: + done, pending = wait( + pending, timeout=tick_interval, return_when=FIRST_COMPLETED + ) + for fut in done: try: - on_each(url, we, ok) - except Exception as cb_err: - _logger.error(f"ping on_each callback error: {cb_err}") + url, we, ok = fut.result() + except Exception as e: # defensive: one server never crashes all + _logger.error(f"ping_servers_parallel worker error: {e}") + continue + willexecutors[url] = we + if on_each is not None: + try: + on_each(url, we, ok) + except Exception as cb_err: + _logger.error(f"ping on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + finally: + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) return willexecutors @staticmethod - def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8): + def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8, + deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None, + on_tick=None, tick_interval=1.0): """Push transactions to multiple will-executors concurrently. Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation. - Each server keeps the historical retry behaviour of - :meth:`push_transactions_to_willexecutor` (which is important so a real - transaction is not lost to a transient hiccup), but the servers are now - contacted in parallel instead of one-after-another, and results are - reported via ``on_each(url, we_dict, ok, exc)`` as they complete. + Each server keeps a short retry behaviour + (:meth:`push_transactions_to_willexecutor`) so a real transaction is not + lost to a transient hiccup, but servers are contacted in parallel and + results are reported via ``on_each(url, we_dict, ok, exc)`` as they + complete. - Returns ``{url: (ok, exception_or_None)}``. + A global wall-clock ``deadline`` (seconds) caps the whole operation: if + some servers are still pending when it elapses, we stop waiting, mark + them via ``on_timeout(url, we_dict)`` and return, so the caller (the + wizard) is never stuck behind one unresponsive server. Pass + ``deadline=None`` to wait indefinitely (old behaviour). + + ``on_tick()`` is invoked periodically (every ``tick_interval`` seconds) + **from the calling thread** while waiting for workers. This lets a Qt + caller refresh an elapsed-time counter from the same thread that drives + ``on_each`` (so its pyqtSignal repaints reliably), instead of relying on + a separate heartbeat thread whose signal emissions are not marshalled. + + Returns ``{url: (ok, exception_or_None)}`` for the servers that + answered in time (timed-out servers are reported via ``on_timeout``). """ - from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] results = {} @@ -387,22 +485,188 @@ class Willexecutors: except Exception as e: return url, we, False, e - workers = max(1, min(max_workers, len(targets))) - with ThreadPoolExecutor(max_workers=workers, - thread_name_prefix="bal-push") as pool: - futures = [pool.submit(_push_one, url, we) for url, we in targets] - for fut in as_completed(futures): + def _fire_tick(): + if on_tick is not None: try: - url, we, ok, exc = fut.result() - except Exception as e: - _logger.error(f"push_transactions_parallel worker error: {e}") - continue - results[url] = (ok, exc) - if on_each is not None: + on_tick() + except Exception as cb_err: + _logger.error(f"push on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(targets))) + # NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its + # __exit__ calls shutdown(wait=True), which would block on a hung worker + # and defeat the whole point of the global deadline. We shut the pool + # down without waiting once the deadline elapses; the daemon worker(s) + # stuck on a dead socket will be torn down when their request finally + # times out (PUSH_TIMEOUT), without holding up the wizard. + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push") + fut_to_url = {pool.submit(_push_one, url, we): (url, we) + for url, we in targets} + start = time.time() + try: + # Poll the futures in short slices so we can call ``on_tick`` from + # THIS thread between waits. ``wait(..., timeout=tick_interval)`` + # returns as soon as a future completes OR the slice elapses, + # whichever comes first, so the counter advances ~once per second + # while the parallel push runs. + pending = set(fut_to_url.keys()) + while pending: + if deadline is not None and (time.time() - start) >= deadline: + break + slice_timeout = tick_interval + if deadline is not None: + remaining = deadline - (time.time() - start) + slice_timeout = max(0.0, min(tick_interval, remaining)) + done, pending = wait( + pending, timeout=slice_timeout, return_when=FIRST_COMPLETED + ) + for fut in done: try: - on_each(url, we, ok, exc) - except Exception as cb_err: - _logger.error(f"push on_each callback error: {cb_err}") + url, we, ok, exc = fut.result() + except Exception as e: + _logger.error( + f"push_transactions_parallel worker error: {e}" + ) + continue + results[url] = (ok, exc) + if on_each is not None: + try: + on_each(url, we, ok, exc) + except Exception as cb_err: + _logger.error(f"push on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + # Any server still pending here hit the global deadline. + if pending: + elapsed = time.time() - start + _logger.warning( + f"push global deadline ({deadline}s) reached after " + f"{elapsed:.1f}s; {len(pending)} server(s) " + f"did not answer in time" + ) + for fut in pending: + url, we = fut_to_url[fut] + if url in results: + continue + if on_timeout is not None: + try: + on_timeout(url, we) + except Exception as cb_err: + _logger.error( + f"push on_timeout callback error: {cb_err}" + ) + finally: + # Do not block on still-running workers (Python 3.9+: cancel queued). + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + return results + + @staticmethod + def check_transactions_parallel(items, *, on_each=None, max_workers=8, + deadline=CHECK_GLOBAL_DEADLINE, + on_timeout=None, on_tick=None, + tick_interval=1.0): + """Check (searchtx) several will-executors concurrently. + + Same design as :meth:`push_transactions_parallel`, but for the "Check" + operation: it verifies that each will-executor still holds its + transaction. ``items`` is an iterable of ``(wid, url)`` pairs (one per + will-item that has a will-executor). + + Each server is contacted in parallel with a short fail-fast retry + (:meth:`check_transaction`), results are reported via + ``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()`` + is called periodically from the calling thread to refresh a counter, and + a global ``deadline`` guarantees the dialog never freezes behind one + unresponsive server (pending servers are reported via + ``on_timeout(wid, url)``). + + Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers + that answered in time. + """ + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED + + targets = [(wid, url) for wid, url in items if url] + results = {} + if not targets: + return results + + def _check_one(wid, url): + try: + res = Willexecutors.check_transaction(wid, url) + return wid, url, res, None + except Exception as e: + return wid, url, None, e + + def _fire_tick(): + if on_tick is not None: + try: + on_tick() + except Exception as cb_err: + _logger.error(f"check on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(targets))) + # Manual pool (no ``with``): we must not block on a hung worker when the + # global deadline elapses (see push_transactions_parallel for details). + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check") + fut_to_target = {pool.submit(_check_one, wid, url): (wid, url) + for wid, url in targets} + start = time.time() + try: + pending = set(fut_to_target.keys()) + while pending: + if deadline is not None and (time.time() - start) >= deadline: + break + slice_timeout = tick_interval + if deadline is not None: + remaining = deadline - (time.time() - start) + slice_timeout = max(0.0, min(tick_interval, remaining)) + done, pending = wait( + pending, timeout=slice_timeout, return_when=FIRST_COMPLETED + ) + for fut in done: + try: + wid, url, res, exc = fut.result() + except Exception as e: + _logger.error( + f"check_transactions_parallel worker error: {e}" + ) + continue + results[wid] = (res, exc) + if on_each is not None: + try: + on_each(wid, url, res, exc) + except Exception as cb_err: + _logger.error(f"check on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + # Any server still pending here hit the global deadline. + if pending: + elapsed = time.time() - start + _logger.warning( + f"check global deadline ({deadline}s) reached after " + f"{elapsed:.1f}s; {len(pending)} server(s) " + f"did not answer in time" + ) + for fut in pending: + wid, url = fut_to_target[fut] + if wid in results: + continue + if on_timeout is not None: + try: + on_timeout(wid, url) + except Exception as cb_err: + _logger.error( + f"check on_timeout callback error: {cb_err}" + ) + finally: + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) return results @staticmethod @@ -457,11 +721,14 @@ class Willexecutors: return {} @staticmethod - def check_transaction(txid, url): + def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT, + max_retries=CHECK_MAX_RETRIES, + retry_sleep=CHECK_RETRY_SLEEP): _logger.debug(f"{url}:{txid}") try: w = Willexecutors.send_request( - "post", url + "/searchtx", data=txid.encode("ascii") + "post", url + "/searchtx", data=txid.encode("ascii"), + timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, ) return w except Exception as e: diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index aea90c1..4914e48 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -725,6 +725,20 @@ class BalBuildWillDialog(BalDialog): # check logic untouched. already_present = [] retry_flag = {"value": False} + total = len(selected) + done = {"count": 0} + + deadline = Willexecutors.PUSH_GLOBAL_DEADLINE + + def _status_line(): + # e.g. "Broadcasting your will to executors: 2/3 (5s / 30s)". + # The "/ 30s" makes the maximum wait explicit, so the user knows + # the wizard will proceed by then (the global deadline) instead + # of wondering how long the counter will keep climbing. + return "{} {}/{} ({}s / {}s)".format( + _("Broadcasting"), done["count"], total, + min(int(time.time() - push_start), deadline), deadline, + ) def on_each(url, willexecutor, ok, exc): # Runs from a worker thread. Do only thread-safe book-keeping @@ -739,18 +753,50 @@ class BalBuildWillDialog(BalDialog): for wid in willexecutor["txsids"]: self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) retry_flag["value"] = True + done["count"] += 1 + self.msg_edit_row("{} : {}".format(url, "Ok" if ok else "Ko")) + self.msg_set_pushing(_status_line()) + + def on_timeout(url, willexecutor): + # The global deadline elapsed before this server answered. Mark + # its txs as failed (so the user can retry later) and show it. + for wid in willexecutor.get("txsids", []): + self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) + retry_flag["value"] = True self.msg_edit_row( - "{} : {}".format(url, "Ok" if ok else "Ko") + "{} : {}".format(url, self.msg_error(_("Timeout - no answer"))) ) if self._stopping: return # Push to all selected will-executors in parallel: a slow/dead # server no longer blocks the others, so the wizard's "Broadcasting" - # step is no longer sequential. Each server still keeps its own - # retry behaviour inside push_transactions_to_willexecutor. - Willexecutors.push_transactions_parallel(selected, on_each=on_each) + # step is no longer sequential. Each server keeps a short retry + # behaviour, and a global deadline guarantees the wizard always + # proceeds even if a server never answers. + push_start = time.time() + self.msg_set_pushing(_status_line()) + # Refresh the elapsed-seconds counter while the (blocking) parallel + # push runs, so the user sees time advancing instead of a frozen + # "Trasmissione". The tick is driven from THIS (Task) thread by + # push_transactions_parallel, the same thread that drives on_each, so + # the pyqtSignal repaint is reliable (a separate heartbeat thread's + # signal emissions were not being marshalled and never repainted). + def on_tick(): + if self._stopping: + return + self.msg_set_pushing(_status_line()) + + Willexecutors.push_transactions_parallel( + selected, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick + ) + + # Final summary line with the total elapsed time. + self.msg_set_pushing( + "{}/{} ({}s)".format(done["count"], total, + int(time.time() - push_start)) + ) retry = retry_flag["value"] # Verify the "already present" servers (sequential, original logic). diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index f355cc9..1d1f867 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -451,6 +451,8 @@ class PreviewList(MyTreeView, MessageBoxMixin): self.bal_window.bal_plugin.read_file("icons/wizard.png") ) ) + # Tooltip so the icon is self-explanatory when hovered. + wizard.setToolTip(_("Wizard - Build your will")) wizard.clicked.connect(self.bal_window.init_wizard) # display = QPushButton(_("Display")) # display.clicked.connect(self.bal_window.preview_modal_dialog) @@ -461,13 +463,20 @@ class PreviewList(MyTreeView, MessageBoxMixin): self.bal_window.bal_plugin.read_file("icons/reload.png") ) ) + # Tooltip so the icon is self-explanatory when hovered. + refresh.setToolTip(_("Check")) refresh.clicked.connect(self.check) widget = QWidget(self) hlayout = QHBoxLayout(widget) + hlayout.setContentsMargins(0, 0, 0, 0) self.will_settings_widget = WillSettingsWidget(self.bal_window, self) - hlayout.addWidget(self.will_settings_widget) + # Toolbar order (left -> right): + # Wizard | Delivery time | Check Alive | Calendar | Check (refresh) + # The Wizard button goes first (leftmost); the settings widget already + # lays out delivery/check-alive/calendar in that order internally. hlayout.addWidget(wizard) + hlayout.addWidget(self.will_settings_widget) hlayout.addWidget(refresh) toolbar.insertWidget(2, widget) diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 6c68079..80f54d7 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -150,6 +150,7 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): + " - y: number of years after currrent day(ex: 1y means one year from today)\n" ) label_text = None + tooltip_text = None base_field = None def __init__(self, bal_window, parent, default_locktime=None): @@ -182,6 +183,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): #hbox.addWidget(QLabel(self.label_text)) help_button=HelpButton(self.help_text) help_button.setText(self.label_text) + # Show a short label (e.g. "Delivery time" / "Check Alive") when the + # user hovers the icon, so the emoji button is self-explanatory. + if self.tooltip_text: + help_button.setToolTip(_(self.tooltip_text)) #help_button.setStyleSheet("font-size: 155555); hbox.addWidget(help_button) self.combo.currentIndexChanged.connect(self.on_current_index_changed) @@ -422,6 +427,7 @@ class ThresholdTimeWidget(BalTimeEditWidget): ) label_text = "🚨" #label_text = "Check Alive" + tooltip_text = "Check Alive" base_field = "threshold" def __init__(self, bal_window, parent, init_value=None): @@ -442,6 +448,7 @@ class LockTimeWidget(BalTimeEditWidget): ) label_text = "🚛" #label_text = "Locktime" + tooltip_text = "Delivery time" base_field = "locktime" def __init__(self, bal_window, parent, init_value=None): @@ -468,6 +475,8 @@ class WillSettingsWidget(QWidget): self.bal_window.bal_plugin.read_file("icons/calendar.png") ) ) + # Tooltip so the icon is self-explanatory when hovered. + self.calendar_button.setToolTip(_("Calendar")) self.calendar_button.clicked.connect(self.open_or_save_calendar) self.widgets["locktime"] = LockTimeWidget(bal_window, self) self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index cfca79e..975d85a 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -14,6 +14,8 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates it with the GUI. """ +import threading + from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget @@ -898,15 +900,50 @@ class BalWindow: def check_transactions_task(self, will): start = time.time() - for wid, w in will.items(): - if self.waiting_dialog._stopping: - return - if w.we: - self.waiting_dialog.update( - "checking transaction: {}\n willexecutor: {}".format(wid, w.we["url"]) - ) + # Servers are now contacted in parallel (see + # Willexecutors.check_transactions_parallel) with a fast-fail timeout and + # a global deadline, so a single slow/dead will-executor no longer + # freezes the "checking transaction" dialog for minutes. The dialog + # shows live progress plus an elapsed-time counter (Xs / DEADLINEs). + targets = [(wid, w.we["url"]) for wid, w in will.items() if w.we] + total = len(targets) + deadline = Willexecutors.CHECK_GLOBAL_DEADLINE + done = {"count": 0} - w.set_check_willexecutor(Willexecutors.check_transaction(wid, w.we["url"])) + def _status_line(): + return "{} {}/{} ({}s / {}s)".format( + _("Checking transactions"), done["count"], total, + min(int(time.time() - start), deadline), deadline, + ) + + def on_each(wid, url, res, exc): + # Reuse the original per-item logic: set_check_willexecutor handles + # both a real response and a None/failure (-> CHECK_FAIL). + try: + will[wid].set_check_willexecutor(res) + except Exception as e: + _logger.error(f"check on_each error for {wid}: {e}") + done["count"] += 1 + self.waiting_dialog.update(_status_line()) + + def on_timeout(wid, url): + # The global deadline elapsed before this server answered: mark the + # item as failed (None response) so the user can retry later. + try: + will[wid].set_check_willexecutor(None) + except Exception as e: + _logger.error(f"check on_timeout error for {wid}: {e}") + + def on_tick(): + if getattr(self.waiting_dialog, "_stopping", False): + return + self.waiting_dialog.update(_status_line()) + + if total: + self.waiting_dialog.update(_status_line()) + Willexecutors.check_transactions_parallel( + targets, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick + ) if time.time() - start < 3: time.sleep(3 - (time.time() - start)) @@ -1005,8 +1042,43 @@ class BalWindow: if fn_on_failure is None: fn_on_failure = log_error + base_msg = _("Downloading will-executors list...") + download_start = time.time() + # Upper bound shown to the user. fetch_will_executors_list tries up to + # two endpoints, each with timeout=10 and one retry (~21s worst case), + # so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how + # long they may have to wait instead of an open-ended counter. + download_deadline = 45 + def task(): - return self.fetch_will_executors_list(willexecutors) + # Heartbeat: show an elapsed-seconds counter (with the max wait made + # explicit) while the (blocking) download runs, so the user sees time + # advancing instead of a seemingly frozen dialog on a slow link. + stop_heartbeat = threading.Event() + + def _heartbeat(): + while not stop_heartbeat.wait(1.0): + if getattr(self.waiting_dialog, "_stopping", False): + return + try: + self.waiting_dialog.update( + "{} ({}s / {}s)".format( + base_msg, + min(int(time.time() - download_start), + download_deadline), + download_deadline, + ) + ) + except Exception: + return + + hb = threading.Thread(target=_heartbeat, name="bal-dl-hb", + daemon=True) + hb.start() + try: + return self.fetch_will_executors_list(willexecutors) + finally: + stop_heartbeat.set() def on_success(result): if result: @@ -1019,9 +1091,8 @@ class BalWindow: _logger.error(f"download_list failed: {exc_info}") self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) - msg = _("Downloading will-executors list...") self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False + self, base_msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() @@ -1034,9 +1105,23 @@ class BalWindow: # every server's (possibly timing-out) request. pinged = set() failed = set() + total = len(wes) + ping_start = time.time() + + ping_deadline = Willexecutors.PUSH_GLOBAL_DEADLINE def get_title(): + # Header shows progress + an elapsed-seconds counter with the max + # wait made explicit (e.g. "3s / 30s"), so the user sees time + # advancing and knows how long it may take, instead of a seemingly + # frozen dialog. + answered = len(pinged) + len(failed) msg = _("Ping Will-Executors:") + msg += " {}/{} ({}s / {}s)".format( + answered, total, + min(int(time.time() - ping_start), ping_deadline), + ping_deadline, + ) msg += "\n\n" for url in wes: urlstr = "{:<50}: ".format(url[:50]) @@ -1066,7 +1151,18 @@ class BalWindow: except Exception: pass - Willexecutors.ping_servers_parallel(wes, on_each=on_each) + # Refresh the elapsed-seconds counter while the (blocking) parallel ping + # runs. The tick is driven from THIS thread by ping_servers_parallel, + # the same thread that drives on_each, so the dialog repaint is reliable. + def on_tick(): + if getattr(self.waiting_dialog, "_stopping", False): + return + try: + self.waiting_dialog.update(get_title()) + except Exception: + pass + + Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick) def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None): def on_success(result): diff --git a/tests/parallel_ping_test.py b/tests/parallel_ping_test.py index d1a6734..6041f27 100644 --- a/tests/parallel_ping_test.py +++ b/tests/parallel_ping_test.py @@ -20,6 +20,7 @@ Run with: """ import importlib import sys +import threading import time PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal" @@ -122,6 +123,167 @@ def main(): finally: W.push_transactions_to_willexecutor = orig_push + # ---- 2b) global deadline: a hung server must not block past `deadline` ---- + def hanging_push(we, **kwargs): + # Simulate a server that never answers within the test window. + time.sleep(10) + return True + + orig_push2 = W.push_transactions_to_willexecutor + W.push_transactions_to_willexecutor = staticmethod(hanging_push) + try: + wes = { + "https://fast.example": { + "url": "https://fast.example", "txs": "x", "txsids": ["a"], + }, + "https://hang.example": { + "url": "https://hang.example", "txs": "y", "txsids": ["b"], + }, + } + # fast one answers quickly, hang one never does within the deadline + def fast_or_hang(we, **kwargs): + if "fast" in we["url"]: + return True + time.sleep(10) + return True + W.push_transactions_to_willexecutor = staticmethod(fast_or_hang) + + timed_out = [] + + def on_timeout(url, we): + timed_out.append(url) + + start = time.time() + W.push_transactions_parallel( + wes, max_workers=2, deadline=1.0, on_timeout=on_timeout + ) + elapsed = time.time() - start + assert elapsed < 3.0, f"deadline not enforced: waited {elapsed:.1f}s" + assert "https://hang.example" in timed_out, timed_out + print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, " + f"hung server reported via on_timeout") + finally: + W.push_transactions_to_willexecutor = orig_push2 + + # ---- 2c) on_tick is fired periodically from the CALLING thread ---- + # The elapsed-time counter is driven by an on_tick callback called from the + # thread that invokes push_transactions_parallel (the same thread that drives + # on_each), so its pyqtSignal repaints reliably. Assert the callback runs + # roughly once per tick_interval while the push is in flight, and that it + # runs on the calling thread (not on a worker/heartbeat thread). + def slow_push2(we, **kwargs): + time.sleep(SLOW * 6) # ~3s, long enough for several ticks + return True + + orig_push3 = W.push_transactions_to_willexecutor + W.push_transactions_to_willexecutor = staticmethod(slow_push2) + try: + wes = { + "https://tick.example": { + "url": "https://tick.example", "txs": "x", "txsids": ["a"], + }, + } + ticks = [] + caller_thread = threading.current_thread() + tick_threads = set() + + def on_tick(): + ticks.append(time.time()) + tick_threads.add(threading.current_thread()) + + W.push_transactions_parallel( + wes, max_workers=1, on_tick=on_tick, tick_interval=0.5 + ) + # ~3s push with 0.5s ticks => at least a few ticks. + assert len(ticks) >= 3, f"on_tick fired too few times: {len(ticks)}" + assert tick_threads == {caller_thread}, ( + "on_tick must run on the calling thread, got " + f"{[t.name for t in tick_threads]}" + ) + print(f"[OK] on_tick fired {len(ticks)} times from the calling thread") + finally: + W.push_transactions_to_willexecutor = orig_push3 + + # ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ---- + # Pressing "Check" verifies each will-executor still holds its tx. This used + # to be a sequential loop with default (~140s) timeouts, freezing the + # "checking transaction" dialog on a dead server. It must now run in + # parallel, enforce a global deadline, and drive an on_tick counter from the + # calling thread. + def slow_check(txid, url, **kwargs): + time.sleep(SLOW) + return {"tx": "ok"} if "good" in url else None + + orig_check = W.check_transaction + W.check_transaction = staticmethod(slow_check) + try: + targets = [] + for i in range(N): + kind = "good" if i % 2 else "bad" + targets.append((f"id{i}", f"https://{kind}-{i}.example")) + + checked = [] + + def on_each_check(wid, url, res, exc): + checked.append((wid, res)) + + start = time.time() + results = W.check_transactions_parallel( + targets, on_each=on_each_check, max_workers=N + ) + elapsed = time.time() - start + sequential = N * SLOW + assert elapsed < sequential * 0.6, ( + f"check not parallel: {elapsed:.2f}s vs {sequential:.2f}s") + assert len(results) == N, results + print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers " + f"(sequential would be ~{sequential:.2f}s)") + finally: + W.check_transaction = orig_check + + # 2d-bis) global deadline + on_tick from the calling thread + def hanging_check(txid, url, **kwargs): + if "fast" in url: + return {"tx": "ok"} + time.sleep(10) + return {"tx": "ok"} + + orig_check2 = W.check_transaction + W.check_transaction = staticmethod(hanging_check) + try: + targets = [ + ("idf", "https://fast.example"), + ("idh", "https://hang.example"), + ] + timed_out = [] + ticks = [] + caller_thread = threading.current_thread() + tick_threads = set() + + def on_timeout_check(wid, url): + timed_out.append(wid) + + def on_tick_check(): + ticks.append(time.time()) + tick_threads.add(threading.current_thread()) + + start = time.time() + W.check_transactions_parallel( + targets, max_workers=2, deadline=2.0, + on_timeout=on_timeout_check, on_tick=on_tick_check, + tick_interval=0.5, + ) + elapsed = time.time() - start + assert elapsed < 4.0, f"check deadline not enforced: {elapsed:.1f}s" + assert "idh" in timed_out, timed_out + assert len(ticks) >= 2, f"check on_tick fired too few times: {len(ticks)}" + assert tick_threads == {caller_thread}, ( + "check on_tick must run on the calling thread") + print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick " + f"fired {len(ticks)}x from the calling thread") + finally: + W.check_transaction = orig_check2 + # ---- 3) the wizard's loop_push must use the parallel helper ---- # The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push. # It previously looped over servers sequentially (one @@ -141,6 +303,48 @@ def main(): "wizard loop_push must not push to servers in a sequential loop") print("[OK] wizard loop_push uses push_transactions_parallel (not sequential)") + # The wizard counter must be driven via on_tick from the calling thread, NOT + # via a separate heartbeat thread (whose pyqtSignal emissions never + # repainted the dialog -> the counter was invisible during "Broadcasting"). + assert "on_tick" in code, ( + "wizard loop_push must drive the counter via on_tick (calling thread)") + assert "threading.Thread" not in code, ( + "wizard loop_push must not use a heartbeat thread for the counter " + "(its pyqtSignal emissions are not marshalled / never repaint)") + print("[OK] wizard loop_push drives the counter via on_tick (no heartbeat " + "thread)") + + # The counter must show the maximum wait too ("Xs / DEADLINEs"), so the user + # knows when the wizard will give up waiting, not just an open-ended number. + assert "PUSH_GLOBAL_DEADLINE" in code, ( + "wizard counter must reference the global deadline so it can show " + "'Xs / DEADLINEs'") + assert "{}s / {}s" in code or "s / {}s" in code, ( + "wizard counter must render the elapsed time AND the deadline " + "(e.g. '3s / 30s')") + print("[OK] wizard counter shows elapsed time AND the max deadline " + "(Xs / 30s)") + + # ---- 4) the "Check" dialog must use check_transactions_parallel ---- + # Pressing "Check" runs BalWindow.check_transactions_task. It used to loop + # over will-items sequentially calling check_transaction (default ~140s + # timeouts), freezing the "checking transaction" dialog. It must now use the + # parallel helper and show the elapsed-time counter. + window_mod = importlib.import_module(f"{PKG}.gui.qt.window") + check_src = inspect.getsource(window_mod.BalWindow.check_transactions_task) + check_code = "\n".join( + line for line in check_src.splitlines() + if not line.lstrip().startswith("#") + ) + assert "check_transactions_parallel" in check_code, ( + "check_transactions_task must use check_transactions_parallel") + assert "on_tick" in check_code, ( + "check dialog must drive its counter via on_tick (calling thread)") + assert "{}s / {}s" in check_code, ( + "check dialog counter must render elapsed time AND the deadline") + print("[OK] check_transactions_task uses check_transactions_parallel " + "with on_tick counter (Xs / 30s)") + print(f"\n[OK] parallel networking test passed for package {PKG!r}") return 0