Skip to content

Fix "Save settings" crash on disabled port; show plugin version in tab - #4

Merged
nothing-stops-this-train merged 1 commit into
mainfrom
fix/settings-save-crash-and-version-display
Jul 28, 2026
Merged

Fix "Save settings" crash on disabled port; show plugin version in tab#4
nothing-stops-this-train merged 1 commit into
mainfrom
fix/settings-save-crash-and-version-display

Conversation

@nothing-stops-this-train

Copy link
Copy Markdown
Owner

1. Fix: "Save settings" crashes when the HTTP port is disabled

Saving settings with the HTTP port set to 0 ("disabled") crashed with:

File ".../swapserver_gui/qt.py", line 223, in on_save
    self.config.SWAPSERVER_PORT = new_port
...
File ".../electrum/simple_config.py", line 316, in delete_key
    d.pop(key, None)
AttributeError: 'NoneType' object has no attribute 'pop'

Root cause

on_save turned a spinbox value of 0 into None. SWAPSERVER_PORT is registered by Electrum's bundled swapserver plugin as the dotted key plugins.swapserver.port, so assigning None routes into SimpleConfig's key-deletion branch, whose recursion walks the dotted path without checking that each intermediate dict exists:

# electrum/simple_config.py
prefix, suffix = key.split('.', 1)
d2 = d.get(prefix)          # None when 'plugins.swapserver' was never written
empty = delete_key(d2, suffix)

This plugin only ever writes plugins.swapserver_gui.*, so unless the user has separately enabled and configured the bundled swapserver plugin, plugins.swapserver does not exist and the delete always raises. Reproduced against a real SimpleConfig:

user_config state result
{} (fresh config) AttributeError: ... has no attribute 'get'
{'plugins': {'swapserver_gui': {...}}} ... has no attribute 'pop' ← the reported traceback
{'plugins': {'swapserver': {'port': 5455}}} OK
{'plugins': {'swapserver': {}}} OK

Because the port write came first, its crash also silently discarded the fee, PoW-target and relay writes — nothing was saved at all.

Fix

A disabled port is now stored as 0, never None. Every reader tests the key for truthiness (electrum/submarine_swaps.py, electrum/plugins/swapserver/server.py, and this plugin's can_run / start_server / status), so 0 is behaviourally identical but takes the ordinary write path. Configs already holding None still load correctly.

This is an upstream Electrum bug; the workaround belongs here because users run released AppImages we cannot patch.

Two supporting changes:

  • The writes moved into a GUI-agnostic swapserver_gui.save_settings(). PyQt6 is unavailable in CI, so anything reachable only through qt.py is effectively untestable — which is exactly why the existing suite (built on an attribute-bag config fake) never caught this.
  • on_save now catches save failures, logs them, and reports them via show_error instead of letting them escape into the Qt event loop as an unhandled-exception traceback.

2. Feature: plugin version in the tab header

The tab header now shows the installed version, dimmed, between the status text and the enable/disable button:

┌──────────────────────────────────────────────┐
│ Swap server: running    v0.2.1  [ Disable ]  │
├──────────────────────────────────────────────┤

The git tag is the single source of truth. _version.py is committed with the placeholder dev; contrib/make_zip.sh stamps the real value into a staging copy while building the archive, so the working tree is never modified. CI stamps ${GITHUB_REF_NAME#v} on v* tags and dev-g<sha7> otherwise, then verifies the stamp actually landed in the zip.

So a release asset can never disagree with the tag it was built from, a branch/PR zip is always traceable to its commit, and cutting a release needs no in-tree bump — just push the tag. Release builds render v0.2.1; dev stamps render verbatim (vdev would read as a release).

Tests

83 pass (python3 -m unittest discover -s tests). New coverage:

  • tests/test_settings_save.py — drives a real SimpleConfig (not the fake) across all four user_config shapes above; asserts later writes survive a disabled port, values persist across a simulated restart, and plugins.swapserver_gui.enabled is not collaterally pruned by upstream's empty-parent-dict cleanup (that would uninstall the plugin on save). Plus a static AST guard against direct SWAPSERVER_PORT assignment in qt.py — verified to fire on the pre-fix code at line 223, the exact line from the traceback.
  • tests/test_version.py — version formatting, and the stamping contract make_zip.sh depends on (drift there would silently ship dev in a release).
  • tests/test_zip_plugin_load.py — end-to-end: a stamped version reaches the running module when the zip is loaded the way Electrum loads it; unstamped builds keep the placeholder; the working tree stays clean; malformed version strings fail the build rather than emitting a broken _version.py.

Not covered

The QLabel rendering and the show_error dialog itself cannot be exercised — PyQt6 is not installable in this environment or in CI. The logic behind both (version formatting, the persist function and its failure mode) is covered; only the widget wiring is not.

🤖 Generated with Claude Code

https://claude.ai/code/session_013zETFC6MhnsCuxvQDGxqV5

Two changes.

1. Saving settings with the HTTP port set to 0 ("disabled") crashed with

     File ".../swapserver_gui/qt.py", line 223, in on_save
       self.config.SWAPSERVER_PORT = new_port
     ...
     File ".../electrum/simple_config.py", line 316, in delete_key
       d.pop(key, None)
   AttributeError: 'NoneType' object has no attribute 'pop'

   on_save turned a spinbox value of 0 into None. SWAPSERVER_PORT is the
   dotted key 'plugins.swapserver.port', so assigning None routes into
   SimpleConfig's key-*deletion* branch, whose recursion dereferences the
   intermediate 'plugins.swapserver' dict without checking it exists. This
   plugin only ever writes 'plugins.swapserver_gui.*', so that dict normally
   does not exist and the delete always raised. Since the port write came
   first, its crash also silently discarded the fee, PoW and relay writes.

   A disabled port is now stored as 0. Every reader tests the key for
   truthiness (submarine_swaps.py, swapserver/server.py, and this plugin's
   can_run/start_server/status), so 0 is behaviourally identical but takes
   the ordinary write path. This is an upstream Electrum bug; the workaround
   belongs here because users run released AppImages we cannot patch.

   The writes moved into a GUI-agnostic swapserver_gui.save_settings() so
   they are testable at all -- PyQt6 is unavailable in CI, which is why the
   existing suite (an attribute-bag config fake) never caught this. on_save
   now also catches save failures and reports them via show_error instead of
   letting them escape into the Qt event loop.

2. The tab header shows the installed plugin version, so users can tell
   which build they are on. _version.py is committed as the placeholder
   'dev' and stamped by contrib/make_zip.sh into a staging copy at build
   time, leaving the working tree untouched. CI stamps ${GITHUB_REF_NAME#v}
   on v* tags and dev-g<sha7> otherwise, then verifies the stamp landed in
   the zip -- so a release asset can never disagree with its tag.

Tests: settings persistence against a real SimpleConfig across the four
user_config shapes that reproduce (and don't reproduce) the crash, that
later writes survive a disabled port, that plugins.swapserver_gui.enabled
is not collaterally pruned, a static guard against direct SWAPSERVER_PORT
assignment in qt.py, version formatting, and an end-to-end check that a
stamped version reads back when the zip is loaded the way Electrum loads
it. 83 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zETFC6MhnsCuxvQDGxqV5
@nothing-stops-this-train
nothing-stops-this-train merged commit 42f26d8 into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant