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
parent 42fc80bb55
commit 4abd2e508f
6 changed files with 689 additions and 58 deletions

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