Skip to content

fix(dash-spv): reliable peer selection and stall handling during sync - #943

Merged
ZocoLini merged 14 commits into
devfrom
fix/spv-sync-improvements
Aug 11, 2026
Merged

fix(dash-spv): reliable peer selection and stall handling during sync#943
ZocoLini merged 14 commits into
devfrom
fix/spv-sync-improvements

Conversation

@xdustinface

@xdustinface xdustinface commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

Peer selection was blind round-robin over whatever peers connected first. Slow or stalling peers received as much sync work as good ones, could hide behind fast request kinds, and were never evicted, so a single bad peer degraded or stalled the whole sync. Deterministic initial selection also herded fresh clients onto the same low-IP nodes.

Fix

  • Initial peer selection is randomized instead of deterministic.
  • Peers earn a reputation score from real responsiveness, timed per request kind and with measured latency, and decay while offline.
  • Requests are routed toward well-scoring peers instead of round-robin, stalls on small sync responses are detected within 10s and penalized, and the worst stalling peer is evicted under a guard so sync cannot stay stuck.
  • Routing and reward updates use the reputation read lock, keeping the hot path off the write lock under disconnect floods.

Covered by new tests for scoring, biased routing, and guarded eviction. All dashd integration tests pass.

Summary by CodeRabbit

  • New Features
    • Improved peer selection by favoring responsive peers while preserving fair distribution.
    • Added tracking for response times and stalled requests.
    • Added safeguards to remove consistently poor-performing peers when suitable replacements are available.
    • Improved peer reputation handling, including offline score decay and protection against negative scores.
  • Bug Fixes
    • Prevented peer selection from consistently favoring the same addresses.
    • Ensured timeout handling does not incorrectly reduce peer reputation.
    • Reduced block download timeout handling to recover faster from unresponsive peers.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The network layer adds latency-aware peer selection, request-stall detection, guarded peer eviction, and revised reputation persistence. Peer discovery and equal-score selection now randomize candidates. Tests cover timing, routing, reputation, persistence, eviction, and filter lookahead behavior.

Changes

Peer reputation model

Layer / File(s) Summary
Reputation scoring and persistence
dash-spv/src/network/reputation.rs, dash-spv/src/network/reputation_tests.rs
Reputation now scores misbehavior only. Scores are clamped, persisted with last_seen, decayed during offline periods, and exposed through scores_for. Equal-score candidates are randomized. Tests cover timeout penalties, score lookup, clamping, and decay.

Latency-aware peer management

Layer / File(s) Summary
Peer latency model
dash-spv/src/network/latency.rs, dash-spv/src/network/mod.rs
PeerLatency records blended response times, removes disconnected peers, and keeps unmeasured or expired peers eligible.
Request tracking and stall maintenance
dash-spv/src/network/manager.rs, dash-spv/src/network/tests.rs
The manager classifies request types, tracks timers, records response latency, penalizes stalled peers, handles suspend/resume gaps, and conditionally evicts a sufficiently worse peer.
Latency-aware routing and validation
dash-spv/src/network/discovery.rs, dash-spv/src/network/manager.rs, dash-spv/src/network/tests.rs, dash-spv/src/sync/blocks/pipeline.rs, dash-spv/src/sync/filters/manager.rs
Peer selection uses latency-filtered round-robin routing. Discovery and equal-score selection randomize candidates. Block timeout and lookahead tests now cover stalled downloads and batch limits.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PeerNetworkManager
  participant PeerReader
  participant PeerLatency
  participant PeerReputationManager
  PeerNetworkManager->>PeerReader: send classified request
  PeerReader->>PeerNetworkManager: return response
  PeerNetworkManager->>PeerLatency: record response latency
  PeerNetworkManager->>PeerReputationManager: record request timeout
  PeerNetworkManager->>PeerNetworkManager: route or evict peer
Loading

Possibly related PRs

  • dashpay/rust-dashcore#902: This PR extends the refactored PeerNetworkManager with latency routing, stall tracking, and reputation-based eviction.

Suggested labels: ready-for-review

Suggested reviewers: zocolini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: improved peer selection and stall handling during Dash SPV synchronization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/spv-sync-improvements

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
dash-spv/src/network/manager.rs (1)

1172-1191: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Run the stall sweep in exclusive mode as well.

sweep_stalled_peers is the only place that prunes outstanding_requests (line 1024). In exclusive mode the sweep never runs, so two effects follow:

  1. Entries survive a disconnect. Arming uses or_insert_with (line 1494), so a reconnected configured peer keeps its pre-disconnect timestamp. The next matching response records sent.elapsed() measured from before the disconnect, which inflates that peer's latency mean and removes it from routing until SAMPLE_TTL expires.
  2. Configured peers get no stall penalty and no stall latency sample, so latency-aware routing across several configured peers loses its input.

Move the sweep out of the else branch. Keep eviction gated to non-exclusive mode.

🐛 Proposed fix: sweep in both modes, evict only in non-exclusive mode
+        // The sweep is the only place `outstanding_requests` is pruned, so it must
+        // run in exclusive mode too or stale timers survive a reconnect.
+        let grace_tick = self.sweep_stalled_peers().await;
         if self.exclusive_mode {
             // In exclusive mode, only reconnect to originally specified peers
             for addr in self.initial_peers.iter() {
                 if !self.pool.is_connected(addr).await && !self.pool.is_connecting(addr).await {
                     tracing::info!("Reconnecting to exclusive peer: {}", addr);
                     self.connect_to_peer(*addr).await;
                 }
             }
         } else {
-            // Penalize peers stalling on sync requests so they drop out of routing
-            // and become eviction candidates before the top-up below.
-            let grace_tick = self.sweep_stalled_peers().await;
             // Evict peers that lack required services before top-up so replacements
             // can be pulled in during the same tick.
             self.evict_mismatched_peers().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 1172 - 1191, Move the
sweep_stalled_peers call before the exclusive_mode conditional so it runs for
both exclusive and non-exclusive modes. Preserve the returned grace_tick for the
existing non-exclusive eviction logic, while keeping evict_mismatched_peers and
evict_worst_stuck_peer gated to the non-exclusive branch.
🧹 Nitpick comments (3)
dash-spv/src/network/manager.rs (1)

1443-1453: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Remove the panic path from next_peer.

Line 1451 computes % eligible.len(), and line 1452 indexes the vector. If peers is empty, eligible is empty and both operations panic. The current callers check for an empty peer list first, so this is an invariant, not a live defect. The invariant is not enforced by the signature.

Return Option<(SocketAddr, Arc<RwLock<Peer>>)> and let each caller map None to the existing NetworkError::ConnectionFailed("No connected peers"). This also removes the panic risk from the test_next_peer helper at line 1889, which passes the pool contents without an emptiness check.

Based on learnings from the coding guidelines: "Avoid unwrap() and expect() in library code; use proper error types (e.g., via thiserror)" — the same reasoning applies to index and modulo operations that can panic on an empty slice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 1443 - 1453, Change next_peer
to return Option<(SocketAddr, Arc<RwLock<Peer>>)> and return None before
modulo/indexing when eligible is empty; otherwise preserve the round-robin
selection. Update every caller, including test_next_peer, to handle None by
returning the existing NetworkError::ConnectionFailed("No connected peers").

Source: Coding guidelines

dash-spv/src/network/tests.rs (1)

292-346: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the two untested eviction guards.

The new tests cover the pool-floor, replacement, and all-peers-bad guards. Two guards in evict_worst_stuck_peer have no test:

  • EVICTION_COOLDOWN: a second call inside the cooldown window must not evict a second peer.
  • is_sole_service_provider: the worst peer must survive when it is the only connected peer that advertises required_services.

Both are reachable with the existing helpers plus tokio::time::advance.

As per coding guidelines: "Implement comprehensive unit tests in-module for individual components using #[cfg(test)] and integration tests in the tests/ directory".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/tests.rs` around lines 292 - 346, Add tests for the
remaining guards in evict_worst_stuck_peer: verify a second eviction attempt
within EVICTION_COOLDOWN does not remove another peer, using
tokio::time::advance to cover the cooldown boundary, and verify the worst peer
is retained when is_sole_service_provider identifies it as the only connected
peer advertising required_services. Reuse the existing test helpers and keep
both tests in the module’s #[cfg(test)] suite.

Source: Coding guidelines

dash-spv/src/network/latency.rs (1)

9-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use tokio::time::Instant for observation timestamps.

Production calls pass durations from tokio::time::Instant::elapsed(), but PeerLatency stores and checks timestamps with std::time::Instant. tokio::time::advance does not advance the standard clock, so paused-time tests cannot exercise SAMPLE_TTL expiry. Use tokio::time::Instant for Observation, record, eligible, and expiry tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/latency.rs` around lines 9 - 11, Replace
std::time::Instant with tokio::time::Instant throughout PeerLatency, including
the Observation timestamp type and the record, eligible, and expiry-test logic.
Keep Duration imports and existing timestamp comparisons unchanged so durations
from tokio::time::Instant::elapsed() and tokio::time::advance-based expiry tests
use the same clock.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv/src/network/reputation_tests.rs`:
- Around line 74-76: Replace the hard-coded addresses and ports used to
construct reputation-test peers with the shared test-address fixture. Update the
`AddrV2Message` setup for `clean_peer`, `other_clean_peer`, and `bad_peer`, plus
the `peer`, `slight`, `bad`, `unknown`, `near_ban`, and `offline` constructions
in dash-spv/src/network/reputation_tests.rs at lines 74-76, 116, 130-132, and
149-151; preserve each test’s intended peer distinctions while reusing the
fixture.

In `@dash-spv/src/network/reputation.rs`:
- Around line 381-385: Update the reputation refresh flow around the write guard
and save_peers_reputation: set each reputation.last_seen, clone the updated
reputations into a snapshot, explicitly release the reputations write lock, then
await save_peers_reputation using that snapshot. Ensure no storage I/O occurs
while the guard is held, preserving the updated values being persisted.

---

Outside diff comments:
In `@dash-spv/src/network/manager.rs`:
- Around line 1172-1191: Move the sweep_stalled_peers call before the
exclusive_mode conditional so it runs for both exclusive and non-exclusive
modes. Preserve the returned grace_tick for the existing non-exclusive eviction
logic, while keeping evict_mismatched_peers and evict_worst_stuck_peer gated to
the non-exclusive branch.

---

Nitpick comments:
In `@dash-spv/src/network/latency.rs`:
- Around line 9-11: Replace std::time::Instant with tokio::time::Instant
throughout PeerLatency, including the Observation timestamp type and the record,
eligible, and expiry-test logic. Keep Duration imports and existing timestamp
comparisons unchanged so durations from tokio::time::Instant::elapsed() and
tokio::time::advance-based expiry tests use the same clock.

In `@dash-spv/src/network/manager.rs`:
- Around line 1443-1453: Change next_peer to return Option<(SocketAddr,
Arc<RwLock<Peer>>)> and return None before modulo/indexing when eligible is
empty; otherwise preserve the round-robin selection. Update every caller,
including test_next_peer, to handle None by returning the existing
NetworkError::ConnectionFailed("No connected peers").

In `@dash-spv/src/network/tests.rs`:
- Around line 292-346: Add tests for the remaining guards in
evict_worst_stuck_peer: verify a second eviction attempt within
EVICTION_COOLDOWN does not remove another peer, using tokio::time::advance to
cover the cooldown boundary, and verify the worst peer is retained when
is_sole_service_provider identifies it as the only connected peer advertising
required_services. Reuse the existing test helpers and keep both tests in the
module’s #[cfg(test)] suite.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bd318542-0425-4f2e-a893-7abb0a016a4a

📥 Commits

Reviewing files that changed from the base of the PR and between d91ad05 and 7aef3d6.

📒 Files selected for processing (7)
  • dash-spv/src/network/discovery.rs
  • dash-spv/src/network/latency.rs
  • dash-spv/src/network/manager.rs
  • dash-spv/src/network/mod.rs
  • dash-spv/src/network/reputation.rs
  • dash-spv/src/network/reputation_tests.rs
  • dash-spv/src/network/tests.rs

Comment thread dash-spv/src/network/reputation_tests.rs
Comment thread dash-spv/src/network/reputation.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
dash-spv/src/network/manager.rs (1)

2010-2016: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the test helper aligned with the reader path.

test_deliver_response records block-transfer latency. The peer reader records latency only when response_time_is_fair() is true, and that method excludes RequestKind::Blocks. This can conceal a routing regression in tests.

Proposed fix
         if let Some(kind) = timed_response_kind(msg) {
             let sent = self.outstanding_requests.lock().await.remove(&(addr, kind));
             if let Some(sent) = sent {
-                self.latency.lock().await.record(addr, sent.elapsed());
+                if kind.response_time_is_fair() {
+                    self.latency.lock().await.record(addr, sent.elapsed());
+                }
             }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 2010 - 2016, Update
test_deliver_response to match the peer reader’s response_time_is_fair behavior
by excluding RequestKind::Blocks from latency recording. Keep
outstanding-request removal intact, and record latency only for response kinds
accepted by response_time_is_fair.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv/src/network/manager.rs`:
- Around line 105-120: Replace the hardcoded REQUEST_STALL_TIMEOUT and
REQUEST_OWED_TIMEOUT constants in dash-spv/src/network/manager.rs:105-120 with
typed configuration fields and sensible defaults, then inject those values into
request routing. Also move the hardcoded block download timeout in
dash-spv/src/sync/blocks/pipeline.rs:20-25 into the download or client
configuration and use the configured value, ensuring all affected network timing
policy is configurable.

---

Outside diff comments:
In `@dash-spv/src/network/manager.rs`:
- Around line 2010-2016: Update test_deliver_response to match the peer reader’s
response_time_is_fair behavior by excluding RequestKind::Blocks from latency
recording. Keep outstanding-request removal intact, and record latency only for
response kinds accepted by response_time_is_fair.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b52e6af-b79f-47ed-899d-a600405d08f9

📥 Commits

Reviewing files that changed from the base of the PR and between 7aef3d6 and 5d99ca9.

📒 Files selected for processing (4)
  • dash-spv/src/network/manager.rs
  • dash-spv/src/network/tests.rs
  • dash-spv/src/sync/blocks/pipeline.rs
  • dash-spv/src/sync/filters/manager.rs

Comment thread dash-spv/src/network/manager.rs
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.57326% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.55%. Comparing base (47166b5) to head (9b8936a).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
dash-spv/src/network/manager.rs 91.32% 21 Missing ⚠️
dash-spv/src/network/reputation.rs 86.66% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##              dev     #943       +/-   ##
===========================================
+ Coverage   20.00%   75.55%   +55.54%     
===========================================
  Files           4      329      +325     
  Lines        2069    79136    +77067     
===========================================
+ Hits          414    59793    +59379     
- Misses       1655    19343    +17688     
Flag Coverage Δ
core 77.29% <ø> (?)
ffi 49.04% <ø> (?)
rpc 20.00% <ø> (ø)
spv 91.54% <93.57%> (?)
wallet 77.46% <ø> (?)
Files with missing lines Coverage Δ
dash-spv/src/network/discovery.rs 63.79% <100.00%> (ø)
dash-spv/src/network/latency.rs 100.00% <100.00%> (ø)
dash-spv/src/network/mod.rs 98.21% <ø> (ø)
dash-spv/src/sync/blocks/pipeline.rs 97.09% <ø> (ø)
dash-spv/src/sync/filters/manager.rs 98.04% <100.00%> (ø)
dash-spv/src/network/reputation.rs 80.70% <86.66%> (ø)
dash-spv/src/network/manager.rs 74.93% <91.32%> (ø)

... and 318 files with indirect coverage changes

… low-IP nodes

`discover_peers` sorts addresses to dedup them, so every client picked the same lowest-IP peers via the `take`/`truncate` callers, piling testers onto a handful of nodes and slowing sync for everyone. Shuffle the merged masternode/DNS list after dedup so clients fan out across the whole peer set.

Also shuffle score-tied candidates in `select_best_peers` before the stable sort, so fresh installs (every peer at score 0) no longer converge on a fixed address order.
…decay

The reputation score was dominated by noise. `ReadTimeout` fired on every idle socket read on healthy connections, and the only reward, `LongUptime`, was a fake loop-iteration heuristic rather than real elapsed time. Neither reflected whether a peer actually serves our requests.

Remove both. Reward `ResponseDelivered` (-2) whenever a peer returns a substantive response to a sync request (headers, filter headers, filters, blocks, masternode diffs, quorum info), so productive peers rank above idle or stalling ones.

Also harden persistence so stale suspicion cannot lock the client out of known-good peers. Persist a wall-clock `last_seen`, credit decay for the time the client was offline on load, clamp every loaded score to a restart ceiling so no peer returns one strike short of a ban, and drop the previous `.max(50)` bump that ratcheted suspicion the wrong way.
…round-robin

`next_peer` rotated across all connected peers equally, so a slow or flaky peer kept an equal share of every request and could stall a sync phase. Bias selection toward the best reputation scores: peers within `GOOD_BAND_DELTA` of the best eligible score form a good band that still round-robins, so load spreads across the healthy peers while a clearly-worse peer drops out of rotation until decay recovers it.

Add `scores_for` to read decayed scores under the reputation lock alone. The caller already holds owned `Arc`s rather than a pool guard, so this keeps a single lock order and cannot deadlock against the pool lock.
A peer that accepted a sync request and then went silent was invisible to scoring. It kept its slot, kept getting an equal share of requests, and was never blamed, so the client could stay stuck on it. Track the instant each connected peer's oldest unanswered request was sent, keyed in the network manager. Any substantive response clears it.

The maintenance sweep penalizes a peer whose request stayed unanswered past `REQUEST_STALL_TIMEOUT`, which drops it out of the routing good band and makes it an eviction candidate, so retries land on a better-scoring peer.

Guard against false blame. A response of any kind resets the peer's timer, so a peer streaming a large block is not punished for the download taking a while, and a maintenance gap larger than `SUSPEND_GAP` (the shape of a backgrounded iOS app) refreshes all timers and skips penalties for that round instead of blaming every peer at once. The sweep runs only outside exclusive mode, so explicitly configured peers are never scored down and refused.
Bad peers were only removed on hard failures (ping timeout, decode error, capability mismatch). A peer that pongs fine but stalls on sync requests kept its slot forever, so the client stayed stuck on it. Once such a peer accrues stall penalties, drop the single worst-scoring connected peer during maintenance so the existing address-book top-up pulls in a fresh replacement the same tick.

Heavily guarded so a small pool (default `max_peers` 3) can never churn toward zero. Keep a connection floor, only act on a full pool with a replacement candidate ready, skip when every peer is equally bad (a network or device problem rather than this peer), never drop the sole peer providing a required service, evict at most one peer per cooldown, and never on a suspend/resume grace tick.
Add unit coverage for the new peer-selection behavior: the `RequestTimeout` stall penalty and its ban threshold, `scores_for` including the default-zero for an unknown peer, load-time score clamping to the restart ceiling plus offline decay crediting, reputation-biased `next_peer` excluding a low-scoring peer while spreading load across the good band, and every eviction guard (removes the stalling peer, but skips without a replacement, when all peers are equally bad, and when the pool is not full).
Integration testing surfaced a regression. `next_peer` took the reputation write lock via `scores_for` on every send, and the reader rewarded on every inbound message. Under the send flood that follows a mid-sync peer disconnect, those writes saturated the reputation lock and starved the reader loop, so it took seconds instead of milliseconds to notice the closed socket and emit `PeerDisconnected`, which broke reconnection-under-disconnect sync.

Make `scores_for` a read-only lookup (decay still runs in `update_reputation` and the periodic maintenance pass), and reward a peer only when a response actually clears a tracked request. The reputation write lock now stays off both hot paths.
The stall timeout was dictated by the worst case (a multi-megabyte block or masternode diff), so a peer could sit unresponsive for 45s before being blamed. Headers, filter headers and filters are tens of KB and a healthy peer answers in well under a second, so time only those and drop the threshold to 10s. Large payloads are deliberately no longer stall-tracked: a peer sends nothing until such a transfer completes, so timing them would punish honest peers on slow links, and the sync layer's own download timeouts already retry them elsewhere.

Re-arm a stalling peer's timer instead of dropping it. One stall already freezes a peer out of routing via `GOOD_BAND_DELTA`, so it receives no new request to re-arm the timer and could never earn a second strike. That left `STUCK_PEER_EVICTION_SCORE` unreachable and the peer squatting a connection slot while the full pool blocked top-up from replacing it. With re-arming, two consecutive unanswered windows evict and replace it in ~20s.

Stop accruing strikes once a peer already sits at the eviction threshold, so a peer the guards decline to evict (e.g. at the connection floor) cannot ratchet itself to an outright ban.
…pool

A mainnet run routed 98% of its requests to a single peer. The other two were healthy, answering every request they got, but stopped receiving any four seconds in and never recovered for the rest of the run.

`next_peer` ranked peers by the misbehavior score, and `ChangeReason::ResponseDelivered` credited every answer against it. That made the score a ratchet: answering earned a peer more traffic, more traffic earned it more credit, and once it led by more than `GOOD_BAND_DELTA` the rest fell out of the band. A starved peer receives nothing, so it can never answer, so it can never earn its way back. Three answers of head start were enough to make that permanent, which is what the log shows.

The two roles are now separate. `latency` measures how fast each peer actually answers, and routing rotates over every peer within reach of the fastest. The measurement is bounded by what a peer did rather than by how often it was picked, and it expires, so a peer that is out of rotation becomes unmeasured and is retried instead of starved. The `Instant` needed for it was already stored in `outstanding_requests` and previously discarded. A stalling peer is recorded with the time its request has gone unanswered, which keeps its mean both bad and fresh, so it stays out of rotation while it is still failing and is evicted rather than probed.

The score reverts to pure misbehavior, so its floor is now zero. Letting it run negative banked credit a peer could later spend: decay alone carried a long-lived peer toward -50, and it would then need seven stalls rather than two to reach eviction, making the peers most likely to be quietly failing the hardest ones to remove. Scores persisted by earlier versions clamp up quietly, since a peer having behaved well is no reason to warn.

`is_timed_response` drops block, masternode-diff and quorum-info responses to match `is_tracked_request`, which no longer arms a timer for them.
… hide

`outstanding_requests` held one timer per peer and any timed response cleared it, so a peer fast at one request kind concealed being slow at another: its quick responses cleared the timer a slow request had armed, the slow response then found nothing to clear and was never recorded, and the sweep never saw an aged entry to penalize.

A mainnet peer averaging 11.9s on filter headers, with a 32s p90 and a 65s worst case, was measured at 52ms. It took 40% of the traffic and never earned a single stall penalty, holding the filter-header phase at 595s against 274s for the same work on a healthy pool. Only 255 of its 1050 filter-header timers were cleared by an actual `cfheaders`, the other 780 by an unrelated `cfilter` that happened to arrive first.

Timers are now keyed by `(peer, RequestKind)`, so a response only clears a request it answers. Replaying the same log through this finds 6 stalls on that peer where the old code found none, which is past the eviction threshold. A sweep still costs a peer at most one strike: stalling on several kinds at once is one failing peer, not several.

Latency is still averaged per peer rather than per kind, so routing on its own would not have demoted this peer, only eviction removes it. Routing each kind by that kind's measured latency is the remaining gap.
`add_peer` fails in exactly two ways, a full pool and an address already connected, and both are ordinary outcomes of dialling several candidates for one slot. Topping up after an eviction does precisely that, so every eviction logged an `ERROR` for the dials that arrived second.

Nothing was wrong in those runs, but an `ERROR` line is what a tester reports, so the noise costs real triage time on a beta.
…t response

A peer that silently discards a `getdata` is invisible to latency-based routing. Latency is only ever recorded from a response, so a peer that answers nothing is never measured, keeps its turn in the rotation, and then wins the retry of the request it just dropped.

Seen on a mainnet sync: one of three peers dropped every block request it received. Filter batches commit in order and cannot commit while a matched block is outstanding, so the client spent 66% of a 16 minute run frozen, in stalls of exactly one, two and four `BLOCK_TIMEOUT` periods.

Track block `getdata` as a request kind and skip peers that still owe a response of the kind being sent. The bar is what the peer owes rather than what it did wrong, so this needs no judgement about how slow is too slow, and it clears itself the moment the peer answers. Skipping stays a preference: if every candidate owes us something, the full set is used, because refusing to send is worse than sending to a busy peer.

Block timings are tracked but never scored. A block body is megabytes and the peer sends nothing until the transfer completes, so its elapsed time measures the payload rather than the peer. Scoring it would punish an honest peer on a slow link and, because response times feed one shared routing metric, push that peer out of serving the filters and headers it was answering perfectly well.

A `getdata` only arms the block timer when it actually asks for blocks. The mempool sends `getdata` for transactions, which a `block` response would never clear, so timing those would leave a peer owing a response it was never asked for.
A filter batch cannot commit while any block it matched is still outstanding, so this timeout is the price of a single peer dropping a request, and it is paid with filter sync stopped rather than merely slowed.

30s stays well past the point where a peer is plausibly still transferring: it treats a peer that will never answer as merely slow. 15s is still comfortably above the transfer time of a full block on a slow link, and halves the cost of each bad draw.
…stant

`test_max_lookahead_constant` asserted `MAX_LOOKAHEAD_BATCHES == 3`, which only fails when someone edits the constant and tells you nothing about what lookahead does. Replace it with a test that lookahead actually fills to the cap, stops there while the batches are uncommitted, and tiles the range contiguously from the processing head.

Document why the cap cannot simply be raised. Every scanned batch queues its matched blocks into one FIFO download window, so a deeper lookahead fills that window with blocks belonging to batches that cannot commit for a long time, ahead of the blocks the committing batch is waiting on. Raising it to 6 made `test_runtime_add_during_initial_sync` time out with `committed_height` still at 0.
@ZocoLini
ZocoLini force-pushed the fix/spv-sync-improvements branch from 5d99ca9 to 9b8936a Compare August 11, 2026 09:54
@ZocoLini
ZocoLini merged commit 94eba8e into dev Aug 11, 2026
34 checks passed
@ZocoLini
ZocoLini deleted the fix/spv-sync-improvements branch August 11, 2026 09:55
ZocoLini added a commit that referenced this pull request Aug 11, 2026
The old network module gave each sync manager an `on_peer_disconnect`
hook that requeued its own in-flight work, and three separate fixes
landed against it (#941, #943, #953) — one for the
block pipeline, one for progress being discarded along with the requeue,
one for requeued work never being reissued. The broker owns a request
from send to response, so it replaced all three hooks with a single
central requeue, and their regression tests went with the hooks: the
replacement path had no coverage at all, in the area with the worst
track record.

Both callers — the timeout monitor kicking a stalled peer and the pump
seeing a socket close — did this inline and identically, buried in
spawned tasks where nothing could reach them. Lift it into
`requeue_requests_from` and pin the three properties the old tests
guarded:

- a departed peer's requests come back, a healthy peer's do not
- the key stays registered as `Queued`, so a pipeline re-declaring the
  request cannot queue a duplicate on top of the retry
- only the response retires the key, so a requeued request stays owned
  by someone

Checked against injected regressions: dropping the key instead of
requeuing it fails two of the three, and requeuing nothing fails all
three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToH2xGXqVcxiwMkNYaWkh7
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.

2 participants