Resubscribe concurrently and in the background on reconnect (B-31)
_run_once awaited _subscribe_all_users() inline, before starting the header/scripthash consumer tasks, and that method subscribed one user at a time. At thousands of users that's thousands of sequential round-trips during which nothing else ran: tip_height was frozen and an in-flight draw's _wait_for_next_block made zero progress for the entire resubscribe - a reconnect (which the listener already treats as routine, not exceptional) could stall the lottery for minutes. _subscribe_all_users now fans out with bounded concurrency (asyncio.Semaphore, 20 at a time) instead of a serial loop, and one user's failure no longer stops the rest. _run_once now starts it as its own background task, created after the consumer tasks rather than awaited before them, so tip updates and already-subscribed users' notifications keep flowing throughout - its own completion is deliberately not raced against the session-ending tasks (unlike them, it's expected to finish normally), and its failure is logged the same way address_for_new_user's background task is (B-30). Left out: decoupling the listunspent refresh from the subscribe call itself (the third part of the proposed fix) - the periodic DepositReconciler (B-30) already provides a backstop for a slow or delayed initial refresh, so the added complexity wasn't worth it here. Suite grows from 182 to 185 tests, including an end-to-end test against _run_once proving a new tip is processed while a slow resubscribe is still in flight. BUGS.md moves B-31 to Previously fixed.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
# Known bugs
|
||||
|
||||
A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high,
|
||||
7 medium, 8 low), listed below as B-31 … B-49. B-25 through B-30 are fixed (see "Previously
|
||||
fixed" below) — no Critical-severity finding remains open; the other 19 are High/Medium/Low.
|
||||
7 medium, 8 low), listed below as B-32 … B-49. B-25 through B-31 are fixed (see "Previously
|
||||
fixed" below) — no Critical-severity finding remains open; the other 18 are High/Medium/Low.
|
||||
The 139-test suite was green at the time of the audit, so none of these were caught by existing
|
||||
coverage — every fix lands with a regression test (the six fixes so far brought the suite from
|
||||
139 to 182).
|
||||
coverage — every fix lands with a regression test (the seven fixes so far brought the suite
|
||||
from 139 to 185).
|
||||
|
||||
The recurring pattern across the open findings is worth stating once: the code is rigorous
|
||||
about the failure modes that have actually been hit, and silent about the ones that have not.
|
||||
@@ -20,24 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD
|
||||
|
||||
## High
|
||||
|
||||
### B-31 — Reconnect costs O(users) sequential round-trips and stalls the draw
|
||||
|
||||
In `_run_once` the order is: subscribe headers → `_subscribe_all_users()` → *then* start the
|
||||
consumer tasks (`electrum/listener.py:118-134`). `_subscribe_all_users` iterates users
|
||||
**sequentially**, and each iteration is a subscribe plus a `listunspent` plus a DB write
|
||||
(`:154-165`).
|
||||
|
||||
At 5.000 users that is 10.000 serialized round-trips (15s timeout each). Throughout,
|
||||
`_consume_headers` is not running, so `tip_height` is frozen and `_wait_for_next_block` makes
|
||||
no progress: **a reconnect stalls an in-flight draw** for the entire resubscribe. And since
|
||||
registration has no rate limiting, the user count is attacker-controlled.
|
||||
|
||||
**Proposed fix.** Start the consumer tasks (headers especially) *before* resubscribing, so tip
|
||||
updates keep flowing during the sweep. Batch the resubscribe with bounded concurrency
|
||||
(e.g. `asyncio.Semaphore(20)` over `asyncio.gather`) instead of a serial loop, and decouple
|
||||
the `listunspent` refresh from the subscribe so the initial refresh can proceed in the
|
||||
background.
|
||||
|
||||
### B-32 — `bump_fee` can loop forever on rebroadcasts the node always rejects
|
||||
|
||||
`tx/broadcast.py:87-88` forces `fee_delta = 1` when `fee_delta <= 0`. A **one-satoshi** total
|
||||
@@ -265,9 +247,10 @@ already does.
|
||||
- **B-28** — a hostile Electrum server (or a MITM) could single-handedly pick the round's winner
|
||||
- **B-29** — a UTXO absent from one server's `listunspent` was marked spent immediately, irreversibly, on a single unauthenticated reply
|
||||
- **B-30** — a lost scripthash subscription meant a user's deposits were never credited, with no periodic safety net
|
||||
- **B-31** — resubscribing on reconnect ran serially before anything else started, freezing the chain tip (and so an in-flight draw) for the whole sweep
|
||||
|
||||
See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the
|
||||
B-28/B-29/B-30 fixes). Suite grew from 139 to 182 tests over the six.
|
||||
B-28/B-29/B-30/B-31 fixes). Suite grew from 139 to 185 tests over the seven.
|
||||
|
||||
A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python
|
||||
module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical,
|
||||
|
||||
Reference in New Issue
Block a user