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:
1 parent
50b68d2957
commit
82d078e2d6
8 files changed
+185
-22
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user