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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user