Rotate away from a connected server that stopped following the chain (B-86, ELC-21, ARC-26)

`server.ping` keeps succeeding on a server that is stalled, and nothing compared its tip
with the others', so it stayed the active server for ever.

- The listener records when the last header was accepted (monotonic clock, a module
  attribute so tests can move it).
- After every successful ping, ElectrumListener._check_not_stalled: once the last accepted
  header is older than 360s the other servers are asked for their tip
  (blockchain.headers.subscribe, ELC-14's timeouts); if any reports a higher height than
  ours the session ends with ServerStalledError and run() rotates to the next server. If
  none does the chain is just quiet and the session goes on.

No existing test changed. ELC-29 and /admin/electrum (the connection state and the age of
the last header, B-92) are untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 16:01:44 +02:00
1 parent 50b68d2957
commit 82d078e2d6
8 files changed
+185 -22

No files matched your search

+40
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import time
from collections import Counter
from collections.abc import Callable
@@ -55,6 +56,14 @@ _RESUBSCRIBE_CONCURRENCY = 20
# a hostile server. Also the deepest fork it will follow.
_HEADER_WINDOW = 10
# B-86 (ELC-21): a connected server whose last accepted header is older than this is asked
# about by comparing its tip with the other servers' at every ping. Three block times of
# 120s: late enough that an ordinary slow stretch of the chain does not trigger the check.
_STALL_SECONDS = 360
# The clock the stall check reads — a module attribute so a test can move time.
_monotonic = time.monotonic
# A reorganisation at least this deep pauses the lottery (B-85, ELC-24): one block is
# ordinary on any chain, two or more is worth a look before a new round opens.
_REORG_PAUSE_DEPTH = 2
@@ -63,6 +72,11 @@ _REORG_PAUSE_DEPTH = 2
_MAX_HEADERS_PER_REQUEST = 2016
class ServerStalledError(Exception):
"""The active server stopped following the chain while others report a higher tip
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
every user's address (plus any address added later via add_address), and
@@ -103,6 +117,8 @@ class ElectrumListener:
# B-74 (ELC-22): height -> header of the last _HEADER_WINDOW accepted headers, tip
# included. Survives reconnections, not restarts.
self._window: dict[int, str] = {}
# B-86: when the last header was accepted (monotonic seconds), None until one is.
self._last_header_at: float | None = None
self.client: ElectrumClient | None = None
@property
@@ -266,6 +282,29 @@ class ElectrumListener:
while True:
await asyncio.sleep(_PING_INTERVAL_SECONDS)
await client.ping() # raises (and so ends the session) on timeout or a dead socket
await self._check_not_stalled()
async def _check_not_stalled(self) -> None:
"""B-86 (ELC-21): `server.ping` keeps succeeding on a server that has stopped
following the chain, so liveness alone never replaced one. When the last accepted
header is older than _STALL_SECONDS, ask every *other* server for its tip
(`blockchain.headers.subscribe`, with ELC-14's timeouts): if any reports a higher one
than we know, this session ends and the listener moves to the next server. If none
does, the network simply produced no block and the session goes on."""
if self._last_header_at is None or _monotonic() - self._last_header_at <= _STALL_SECONDS:
return
async def _tip_height(client: ElectrumClient) -> int | None:
header = await client.subscribe_headers()
height = header.get("height") if isinstance(header, dict) else None
return height if isinstance(height, int) else None
heights = [h for h in await self._ask_other_servers(_tip_height) if h is not None]
if any(height > self.tip_height for height in heights):
raise ServerStalledError(
f"no header accepted for {_monotonic() - self._last_header_at:.0f}s while another server "
f"reports tip {max(heights)} (ours: {self.tip_height})"
)
async def _subscribe_all_users(self) -> None:
"""B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at
@@ -422,6 +461,7 @@ class ElectrumListener:
for old in sorted(self._window)[:-_HEADER_WINDOW]:
del self._window[old]
self.tip_height, self.tip_header_hex = entries[-1]
self._last_header_at = _monotonic()
async def _adopt_first_header(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
"""The process's first header has nothing to join, so it is taken on its work alone