fix: crash GUI su Windows (OverflowError anno 2038) — schede/menu BAL rotti (#3)

* fix(gui): voci di menu BAL duplicate/condensate dopo riavvio o cambio wallet

Sintomo (Windows 11): dopo aver riavviato Electrum o cambiato wallet, le
schede Will/Heirs sparivano dalla tab bar e dal menu, e compariva una voce
di menu condensata/illeggibile (icona + testo sovrapposti) sotto il logo di
Electrum, accanto a 'Portafogli'.

Causa: init_menubar_tools veniva eseguito DUE volte sulla stessa finestra.
Con il plugin gia abilitato, al riavvio Electrum invoca sia l'hook
init_menubar sia il percorso di init a caldo (init_qt -> _setup_window),
entrambi chiamano init_menubar_tools -> addTab/addAction duplicati.
Nell'originale init_qt faceva return (chiedendo il riavvio) e quindi i menu
venivano creati una sola volta; rimuovendo quel return (fix B3) e' emersa la
doppia inizializzazione.

Fix:
- BalWindow._menubar_initialized: guardia di idempotenza.
- init_menubar_tools: se gia inizializzato, esce subito (niente duplicati).
- on_close: resetta il flag dopo aver rimosso tab/azioni, cosi la stessa
  finestra puo essere riusata per un altro wallet.
- tests/gui_fixes_test.py: regressione che verifica la guardia in __init__,
  init_menubar_tools e on_close.

Logica di business invariata (nessuna modifica a bal/core/*).

* fix(gui): ripristina create_status_bar come no-op (come originale)

L'elemento di menu condensato/illeggibile sotto il logo di Electrum era
causato dal StatusBarButton aggiunto da create_status_bar.

Nell'originale Gitea questo hook aveva un 'return' subito dopo il log, PRIMA
di costruire il bottone: era quindi disabilitato di proposito. Durante la
pulizia del 'dead code' nel refactoring quel return era stato rimosso,
riattivando la creazione del bottone -> elemento icona+testo renderizzato
nel punto sbagliato dopo riavvio/cambio wallet.

Fix: create_status_bar torna a essere un no-op (return), fedele all'originale.
Le impostazioni restano raggiungibili da Strumenti -> Plugin.

Regressione: gui_fixes_test verifica che create_status_bar non chiami
addPermanentWidget.

* fix(core): OverflowError su Windows (anno 2038) che rompeva tab/menu BAL

CAUSA VERA (dal log Electrum dell'utente, Windows 11):

  OverflowError: Python int too large to convert to C int
    window.py __init__ -> create_heirs_tab -> WillSettingsWidget
    -> on_locktime_change -> BalTimestamp.to_date
    -> datetime.fromtimestamp(NLOCKTIME_MAX)

Su Windows time_t e' a 32 bit, quindi datetime.fromtimestamp() solleva
OverflowError per qualsiasi timestamp oltre il 2038 (es. NLOCKTIME_MAX =
2**32-1 = 4294967295, usato come locktime di default/sentinella). Su Linux
64-bit la stessa chiamata funziona: per questo il bug si vedeva solo su
Windows e i test su Linux non lo intercettavano.

L'eccezione interrompeva BalWindow.__init__ durante init_menubar/load_wallet,
lasciando le schede Will/Heirs e la voce di menu a meta' costruzione ->
l'elemento grafico condensato/illeggibile sotto il logo di Electrum.

FIX (comportamento invariato per tutti i valori normali):
- BalTimestamp._safe_fromtimestamp(): datetime.fromtimestamp con clamp a
  INT32_MAX in caso di OverflowError/OSError/ValueError, esattamente come la
  funzione get_max_allowed_timestamp() dell'originale (Electrum issue #6170).
- Usato in to_date / to_timestamp / __str__ / __repr__ di BalTimestamp.
- widgets.py set_value: usa il converter sicuro.
- util.py timestamp_minus: stessa protezione inline con clamp a INT32_MAX.

I valori entro il 2038 (date assolute normali, durate relative come 90d/5y)
producono lo stesso identico risultato di prima.

TEST: tests/windows_overflow_test.py riproduce il limite 32-bit di Windows
(monkeypatch di datetime.fromtimestamp) e dimostra che senza il fix si ottiene
lo stesso OverflowError del log, mentre col fix passa. Verificato anche che il
test FALLISCE senza il fix.

* docs(it): documenta il fix OverflowError Windows (anno 2038) nel changelog

Aggiunge la sezione §13 al CHANGELOG_REFACTOR.md che descrive:
- sintomo (schede/menu rotti su Windows dopo riavvio/cambio wallet)
- causa vera dal log (datetime.fromtimestamp(NLOCKTIME_MAX) -> OverflowError
  su time_t 32-bit di Windows)
- fix con _safe_fromtimestamp (clamp a INT32_MAX, come Electrum #6170)
- test di regressione windows_overflow_test.py
Aggiornata anche la cronologia (§12) con PR #3.

---------

Co-authored-by: GenSpark AI Developer <ai@genspark.dev>
This commit is contained in:
genspark-ai-developer[bot]
2026-06-13 16:29:30 +00:00
committed by steal
parent 6bff6760bf
commit b1c8bba9e9
8 changed files with 260 additions and 17 deletions

View File

@@ -315,6 +315,28 @@ class BalTimestamp:
"""Return the duration expressed in days (years are ``*365``)."""
return self.value * 365 if self.unit == 'y' else self.value
@staticmethod
def _safe_fromtimestamp(ts):
"""``datetime.fromtimestamp`` that never raises ``OverflowError``.
On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises
``OverflowError: Python int too large to convert to C int`` for any
timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``,
used as the default/sentinel locktime). On 64-bit Linux the same call
succeeds, which is why this only crashed on the user's Windows build.
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
"""
INT32_MAX = 2 ** 31 - 1
try:
return datetime.fromtimestamp(ts)
except (OSError, OverflowError, ValueError):
try:
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
except (OSError, OverflowError, ValueError):
return datetime.fromtimestamp(INT32_MAX)
def to_date(self, from_date=None, reverse=False):
"""Resolve to a ``datetime``.
@@ -323,16 +345,22 @@ class BalTimestamp:
``from_date`` (defaulting to *now*), normalised to midnight.
"""
if self.unit is None:
return datetime.fromtimestamp(self.value)
return self._safe_fromtimestamp(self.value)
else:
if from_date is None:
from_date = datetime.now()
if isinstance(from_date, (int, float)):
from_date = datetime.fromtimestamp(from_date)
from_date = self._safe_fromtimestamp(from_date)
reverse = 1 if not reverse else -1
return (
from_date + (reverse * timedelta(days=self.duration_to_days()))
).replace(hour=0, minute=0, second=0, microsecond=0)
try:
return (
from_date + (reverse * timedelta(days=self.duration_to_days()))
).replace(hour=0, minute=0, second=0, microsecond=0)
except (OverflowError, OSError, ValueError):
# Duration overflowed datetime's range; clamp to INT32_MAX.
return self._safe_fromtimestamp(2 ** 31 - 1).replace(
hour=0, minute=0, second=0, microsecond=0
)
def to_timestamp(self, from_date=None, reverse=False):
"""Same as :meth:`to_date` but returns a UNIX timestamp."""
@@ -340,12 +368,12 @@ class BalTimestamp:
def __str__(self):
if self.unit is None:
return datetime.fromtimestamp(self.value).isoformat()
return self._safe_fromtimestamp(self.value).isoformat()
else:
return f"{self.value}{self.unit}"
def __repr__(self):
if self.unit is None:
return datetime.fromtimestamp(self.value).to_date().timestamp()
return self._safe_fromtimestamp(self.value).isoformat()
else:
return f"{self.value}{self.unit}"

View File

@@ -363,7 +363,12 @@ class Util:
out = 0
if locktime > LOCKTIME_THRESHOLD:
seconds = blocks * 600 + hours * 3600 + days * 86400
dt = datetime.fromtimestamp(locktime)
# On Windows datetime.fromtimestamp raises OverflowError past 2038
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
try:
dt = datetime.fromtimestamp(locktime)
except (OverflowError, OSError, ValueError):
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
dt -= timedelta(seconds=seconds)
out = dt.timestamp()
else:

View File

@@ -88,14 +88,15 @@ class Plugin(BalPlugin):
@hook
def create_status_bar(self, sb):
_logger.info("HOOK create status bar")
b = StatusBarButton(
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
"Bal " + _("Bitcoin After Life"),
partial(self.settings_dialog, sb),
sb.height(),
)
sb.addPermanentWidget(b)
# NOTE: intentionally a no-op, matching the original plugin. The
# original code had an early ``return`` before building the
# StatusBarButton, i.e. the button was deliberately disabled. Adding
# the button here caused a stray, condensed icon+text element to be
# rendered in the wrong place (near the top, under the Electrum logo)
# after a restart / wallet switch. Settings are already reachable via
# Tools -> Plugins, so we keep the original behaviour.
_logger.info("HOOK create status bar (no-op)")
return
@hook
def init_menubar(self, window):

View File

@@ -404,7 +404,9 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
except Exception as e:
x = QDateTime.currentDateTime().timestamp()
finally:
_dt = datetime.fromtimestamp(x)
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
_dt = BalTimestamp._safe_fromtimestamp(x)
#if self.alarm != dt:
self.setDateTime(_dt)
self.alarm = _dt

View File

@@ -34,6 +34,13 @@ class BalWindow:
self.will_settings = None
self.ok = False
self.disable_plugin = True
# Guard against wiring the menu/tabs more than once for the same window.
# Electrum may invoke both the ``init_menubar`` hook and our hot-init
# path (``init_qt`` -> ``_setup_window``) for the same window, e.g. when
# Electrum restarts with the plugin already enabled. Calling
# ``init_menubar_tools`` twice would add the Heirs/Will tabs and the
# menu actions twice, producing the garbled/condensed menu entry.
self._menubar_initialized = False
self.bal_plugin.get_decimal_point = self.window.get_decimal_point
if self.window.wallet:
@@ -49,6 +56,15 @@ class BalWindow:
self.will_tab.wallet = self.wallet
def init_menubar_tools(self, tools_menu):
# Idempotent: only wire the tabs + menu actions once per window.
# A second call (e.g. init_menubar hook *and* the hot-init path both
# firing) would otherwise duplicate the Heirs/Will tabs and the
# Will-Executors / toggle actions, which Qt renders as a broken,
# condensed menu entry under the Electrum logo.
if self._menubar_initialized:
_logger.info("init_menubar_tools: already initialised, skipping")
return
self._menubar_initialized = True
self.tools_menu = tools_menu
def add_optional_tab(tabs, tab, icon, description):
@@ -707,6 +723,9 @@ class BalWindow:
self.willexecutors = {}
self.disable_plugin = True
self.ok = False
# The tabs/menu actions were removed above; allow init_menubar_tools to
# re-wire them if this same window is reused for another wallet.
self._menubar_initialized = False
def ask_password_and_sign_transactions(self, callback=None):
def on_success(txs):