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:
GenSpark AI Developer
2026-06-15 18:37:24 +00:00
committed by steal
parent fbe94506f8
commit 13259e881d
6 changed files with 689 additions and 58 deletions

View File

@@ -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).

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):