diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md index 87322ce..ecb1694 100644 --- a/CHANGELOG_REFACTOR.md +++ b/CHANGELOG_REFACTOR.md @@ -398,3 +398,60 @@ Suddivisione del vecchio `qt.py` (3777 righe) nei moduli GUI: vita (Fase A). - **`dd6f677`** (PR **#2**, squash) — correzioni GUI **B1-B10** + fix download lista will-executor + `window_utils.py` + test di regressione (sezioni §9-§10). +- **PR #3** — fix **OverflowError su Windows (anno 2038)** che rompeva le schede + Will/Heirs e la voce di menu (sezione §13). + +--- + +## 13. CORREZIONE BUG: OverflowError su Windows (limite anno 2038) + +### Sintomo (Windows 11) +Dopo aver **riavviato Electrum** o **cambiato wallet**, le schede **Will** e +**Heirs** sparivano e compariva una **voce di menu condensata/illeggibile** +(icona + testo sovrapposti) sotto il logo di Electrum, accanto a *Portafogli*. +Su Linux il problema non si manifestava. + +### Causa vera (dal log di Electrum dell'utente) +``` +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) +``` + +- `NLOCKTIME_MAX = 2**32 - 1 = 4294967295` viene usato come locktime di + **default/sentinella**. +- Su **Windows** `time_t` è a **32 bit**, quindi `datetime.fromtimestamp(ts)` + solleva **`OverflowError`** per qualsiasi timestamp oltre il **2038**. +- Su **Linux 64-bit** la stessa chiamata **funziona**: ecco perché 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 metà + costruzione** → l'elemento grafico condensato/illeggibile sotto il logo. + +> Nota: i due primi tentativi di correzione (status-bar no-op e idempotenza di +> `init_menubar_tools`) **non** centravano la causa; sono stati comunque +> mantenuti perché innocui e leggermente migliorativi, ma il vero colpevole era +> questo crash a monte. + +### Fix (comportamento invariato per tutti i valori normali) +- **`BalTimestamp._safe_fromtimestamp()`**: `datetime.fromtimestamp` con + **clamp a INT32_MAX** (anno 2038) in caso di `OverflowError`/`OSError`/ + `ValueError`, **esattamente** come la funzione `get_max_allowed_timestamp()` + dell'originale (workaround per Electrum issue **#6170**). +- Usato in `to_date` / `to_timestamp` / `__str__` / `__repr__` di + `BalTimestamp`. +- `gui/qt/widgets.py` (`set_value`): usa il converter sicuro. +- `core/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 **con** il fix passa. + Verificato anche che il test **fallisce** senza il fix. + +Confermato dall'utente: **"si ora funziona"**. diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 0391beb..9de6dd9 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -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}" diff --git a/bal/core/util.py b/bal/core/util.py index d5eff97..2ff6749 100644 --- a/bal/core/util.py +++ b/bal/core/util.py @@ -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: diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index aa0c257..8f445d3 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -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): diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 3a2f4ac..6c68079 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -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 diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index f83c039..5a2005c 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -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): diff --git a/tests/gui_fixes_test.py b/tests/gui_fixes_test.py index 01b195e..92da482 100644 --- a/tests/gui_fixes_test.py +++ b/tests/gui_fixes_test.py @@ -98,6 +98,40 @@ def main(pkg: str) -> int: "BalDialog.hideEvent must not stop the thread (drops download result)") print("[OK] BalDialog.closeEvent/hideEvent do not kill the task thread") + # REGRESSION: init_menubar_tools must be idempotent. Electrum can invoke + # both the init_menubar hook and the hot-init path (init_qt -> _setup_window) + # for the same window (e.g. on restart with the plugin already enabled); + # wiring the tabs/menu actions twice produces a garbled, condensed menu + # entry under the Electrum logo. Verify the guard flag is in place. + bal_window_cls = win_mod.BalWindow + menubar_src = inspect.getsource(bal_window_cls.init_menubar_tools) + assert "_menubar_initialized" in menubar_src, ( + "init_menubar_tools must guard against double initialisation") + init_src = inspect.getsource(bal_window_cls.__init__) + assert "_menubar_initialized" in init_src, ( + "_menubar_initialized must be initialised in BalWindow.__init__") + onclose_src = inspect.getsource(bal_window_cls.on_close) + assert "_menubar_initialized" in onclose_src, ( + "on_close must reset _menubar_initialized so the window can be reused") + print("[OK] init_menubar_tools is idempotent (no duplicate tabs/menu)") + + # REGRESSION: create_status_bar must stay a no-op, like the original plugin + # (whose body had an early ``return`` before building the StatusBarButton). + # Re-adding the status-bar button made a stray condensed icon+text element + # appear in the wrong place after restart / wallet switch. + csb_src = inspect.getsource(plugin_mod.Plugin.create_status_bar) + csb_active = _active_source_without_strings(plugin_mod) # whole module sans strings + csb_body = inspect.getsource(plugin_mod.Plugin.create_status_bar) + # The executable body must not add a permanent widget / build the button. + # Strip comments to avoid matching the explanatory note. + csb_code = "\n".join( + line for line in csb_body.splitlines() + if not line.lstrip().startswith("#") + ) + assert "addPermanentWidget" not in csb_code, ( + "create_status_bar must not add a status-bar widget (original is a no-op)") + print("[OK] create_status_bar is a no-op (matches original)") + print(f"\n[OK] all GUI-fix checks passed for package {pkg!r}") return 0 diff --git a/tests/windows_overflow_test.py b/tests/windows_overflow_test.py new file mode 100644 index 0000000..8582cf9 --- /dev/null +++ b/tests/windows_overflow_test.py @@ -0,0 +1,97 @@ +""" +Regression test for the Windows year-2038 OverflowError crash. + +Background +---------- +On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp(ts)`` raises +``OverflowError: Python int too large to convert to C int`` for any timestamp +past 2038 (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, used as the default/sentinel +locktime). On 64-bit Linux the same call succeeds, which is why the bug only +showed up on the user's Windows build: ``BalWindow.__init__`` -> +``create_heirs_tab`` -> ``WillSettingsWidget`` -> ``on_locktime_change`` -> +``BalTimestamp.to_date`` -> ``datetime.fromtimestamp(NLOCKTIME_MAX)`` crashed, +which aborted ``init_menubar`` / ``load_wallet`` and left the Will/Heirs tabs +and the menu entry half-built (the garbled/condensed element under the logo). + +This test forces ``datetime.fromtimestamp`` to behave like the Windows 32-bit +implementation, then exercises ``BalTimestamp`` with NLOCKTIME_MAX to prove the +overflow-safe conversion no longer raises and clamps to INT32_MAX. + +Run with: + QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 tests/windows_overflow_test.py +""" +import datetime as _datetime_mod +import importlib +import sys + +PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal" + +INT32_MAX = 2 ** 31 - 1 +NLOCKTIME_MAX = 2 ** 32 - 1 # 4294967295, the value seen in the crash log + +_real_datetime = _datetime_mod.datetime + + +class _WindowsLikeDatetime(_real_datetime): + """A datetime subclass whose fromtimestamp emulates Windows' 32-bit limit.""" + + @classmethod + def fromtimestamp(cls, ts, tz=None): + if tz is None and (ts > INT32_MAX or ts < 0): + raise OverflowError("Python int too large to convert to C int") + return _real_datetime.fromtimestamp(ts, tz) + + +def main(): + plugin_base = importlib.import_module(f"{PKG}.core.plugin_base") + BalTimestamp = plugin_base.BalTimestamp + + # 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already. + bt = BalTimestamp(NLOCKTIME_MAX) + d = bt.to_date() + assert isinstance(d, _real_datetime), d + print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform") + + # 2) Now emulate Windows: patch datetime in the plugin_base module so that + # fromtimestamp raises OverflowError past 2038, exactly like Windows. + original = plugin_base.datetime + plugin_base.datetime = _WindowsLikeDatetime + try: + # 2a) Absolute sentinel timestamp (the exact crash path from the log). + bt = BalTimestamp(NLOCKTIME_MAX) + d = bt.to_date() # must NOT raise OverflowError anymore + assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}" + print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)") + + # 2b) to_timestamp must also be safe. + ts = bt.to_timestamp() + assert ts <= INT32_MAX, ts + print("[OK] to_timestamp(NLOCKTIME_MAX) clamped & safe") + + # 2c) __str__ / __repr__ must not raise either. + _ = str(bt) + _ = repr(bt) + print("[OK] str()/repr() on out-of-range timestamp are safe") + + # 2d) Relative durations that overflow when added (e.g. huge 'd'). + bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow + d2 = bt_rel.to_date() + assert d2 is not None + print("[OK] huge relative duration no longer raises") + + # 2e) Normal values are unchanged (behaviour-preserving check). + bt_norm = BalTimestamp("90d") + d3 = bt_norm.to_date() + # 90 days from now, normalised to midnight + assert d3.hour == 0 and d3.minute == 0 and d3.second == 0 + print("[OK] normal '90d' value still resolves to a midnight datetime") + finally: + plugin_base.datetime = original + + print(f"\n[OK] Windows overflow regression passed for package {PKG!r}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())