Skip to content

feat(dash-spv): expose per-peer connection stats (ping RTT, bytes received)#860

Open
QuantumExplorer wants to merge 1 commit into
devfrom
claude/modest-sinoussi-db08c7
Open

feat(dash-spv): expose per-peer connection stats (ping RTT, bytes received)#860
QuantumExplorer wants to merge 1 commit into
devfrom
claude/modest-sinoussi-db08c7

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Adds a PeerStatsSnapshot surface so client consumers can measure how well individual peers serve data (built for an external SPV network-health dashboard, but generally useful):

  • Peer now counts bytes_received at the socket read, closing the long-standing TODO: Track bytes received in Peer::stats(). Bytes are counted even when a frame later fails to parse — the peer still served them.
  • Ping RTTs are recorded (last + running average) in handle_pong; previously the RTT was computed and only logged.
  • Initial ping after handshake: the manager pings a peer immediately after the handshake completes, so latency is measured within the first round-trip instead of waiting for the first 10 s maintenance tick. should_ping() naturally suppresses re-pinging within PING_INTERVAL.
  • New API: NetworkManager::peer_stats() -> Vec<PeerStatsSnapshot> (default empty impl keeps mock managers source-compatible), implemented by PeerNetworkManager over the peer pool, and exposed as DashSpvClient::peer_stats().

Also fixes contrib/setup-dashd.py on Python < 3.9.17, where tarfile.extractall() does not accept the filter kwarg (macOS system Python is 3.9.6).

Testing

  • New unit test pong_records_rtt_and_snapshot_averages covers RTT recording, averaging, and unknown-nonce rejection.
  • Full suite green: 470 lib unit tests, all integration binaries, and the dashd-backed suites (dashd_masternode 10/10, dashd_sync 29/29) via contrib/setup-dashd.py.
  • Exercised live on mainnet by a single-peer SPV client: measured pings 70–534 ms and ~1 MB received per bounded sync, matching expectations.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added peer connection statistics, including connection status, advertised height, ping latency, average RTT, and data received.
    • Added access to per-peer statistics through the SPV client.
    • Network connections now begin latency tracking immediately after connecting.
  • Bug Fixes

    • Improved received-data accounting, including data from malformed or incomplete messages.
    • Updated archive extraction to work with older Python versions.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c8555e49-f876-4221-a3e3-e02cd4f47e60

📥 Commits

Reviewing files that changed from the base of the PR and between 3a9a020 and 088b252.

📒 Files selected for processing (5)
  • contrib/setup-dashd.py
  • dash-spv/src/client/queries.rs
  • dash-spv/src/network/manager.rs
  • dash-spv/src/network/mod.rs
  • dash-spv/src/network/peer.rs
📝 Walkthrough

Walkthrough

Changes

Peer statistics

Layer / File(s) Summary
Peer measurement and snapshots
dash-spv/src/network/peer.rs
Peers now track received bytes and RTT samples, expose connection statistics snapshots, and test RTT averaging and nonce handling.
Network and client statistics API
dash-spv/src/network/mod.rs, dash-spv/src/network/manager.rs, dash-spv/src/client/queries.rs
Per-peer snapshots are collected by the network manager and exposed through DashSpvClient::peer_stats.
Connection-time ping initialization
dash-spv/src/network/manager.rs
Outbound connections send an immediate ping after GetAddr succeeds and warn on failure.

Archive extraction compatibility

Layer / File(s) Summary
Tar extraction fallback
contrib/setup-dashd.py
Tar extraction retries without the filter argument when older Python versions reject it.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: xdustinface

Sequence Diagram(s)

sequenceDiagram
  participant DashSpvClient
  participant PeerNetworkManager
  participant PeerPool
  participant Peer
  DashSpvClient->>PeerNetworkManager: peer_stats()
  PeerNetworkManager->>PeerPool: iterate connected peers
  PeerPool->>Peer: stats_snapshot()
  Peer-->>PeerNetworkManager: PeerStatsSnapshot
  PeerNetworkManager-->>DashSpvClient: vector of snapshots
Loading
🚥 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 change: exposing per-peer connection stats, including RTT and bytes received.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/modest-sinoussi-db08c7

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 3

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/peer.rs (1)

415-428: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make byte accounting cancellation-safe.

receive_message() stores successful reads in a local counter and commits it only after the async body completes. However, PeerNetworkManager::start_peer_reader() can cancel this future via tokio::select!; bytes already buffered then remain in framing_buffer but are never counted on a later call. Persist or commit the count immediately after each successful socket read, and add a regression test for partial-frame cancellation.

As per coding guidelines, new Rust functionality must have unit coverage.

Also applies to: 484-484, 503-503, 551-551, 642-645

🤖 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/peer.rs` around lines 415 - 428, Make receive_message()
cancellation-safe by committing byte counts to the peer’s persistent accounting
immediately after every successful read, including reads in all referenced
paths, instead of relying on the local bytes_read total after the async body
completes. Update the relevant read/accounting logic in receive_message() and
add unit coverage that cancels a partial-frame read, then verifies the buffered
bytes are counted on the subsequent call.

Source: Coding guidelines

🧹 Nitpick comments (1)
contrib/setup-dashd.py (1)

89-94: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Fallback to unfiltered extraction is acceptable for trusted sources, but consider tightening the exception handling.

The try/except TypeError approach correctly handles Python versions that don't support the filter kwarg. The archives are downloaded from trusted GitHub release URLs over HTTPS, so the path-traversal risk flagged by static analysis on the fallback tf.extractall(dest_dir) is low in practice.

One minor note: except TypeError is broad. If extractall were to raise TypeError for an unrelated reason, the script would silently fall back to unfiltered extraction instead of surfacing the error. A more defensive pattern would check the Python/tarfile version explicitly, though the current approach is pragmatic and readable for a test-setup script.

As per path instructions, the PR title should use a fix prefix since this is a compatibility fix — ensure the actual PR title follows this.

Optional: version-gated approach instead of broad except
             try:
                 tf.extractall(dest_dir, filter="data")
             except TypeError:
-                # The `filter` kwarg needs Python 3.9.17+/3.10.12+/3.11.4+;
-                # fall back for older interpreters (e.g. macOS system 3.9.6).
-                tf.extractall(dest_dir)
+                # The `filter` kwarg needs Python 3.9.17+/3.10.12+/3.11.4+;
+                # fall back for older interpreters (e.g. macOS system 3.9.6).
+                if "filter" in str(sys.exc_info()[1]):
+                    tf.extractall(dest_dir)
+                else:
+                    raise
🤖 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 `@contrib/setup-dashd.py` around lines 89 - 94, Use an explicit Python/tarfile
capability or version check before calling TarFile.extractall with the filter
argument, instead of broadly catching TypeError; retain the unfiltered fallback
only for interpreters that lack filter support, while allowing unrelated
extraction TypeErrors to surface. No code change is required for the
trusted-source path concern, but ensure the PR title uses the fix prefix.

Sources: Path instructions, Linters/SAST tools

🤖 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 313-319: When the initial `Peer::send_ping()` in the connection
setup path fails, stop setup immediately instead of continuing as if connected.
Remove the connecting marker, clean up the failed peer/connection state, and
return before recording the connection, adding the disconnected peer,
incrementing counts, or emitting `PeerConnected`; update the `if let Err(e)`
branch in the connection manager accordingly.

In `@dash-spv/src/network/peer.rs`:
- Around line 55-64: Reset all session-scoped ping state in the public
Peer::connect_instance() path when replacing the socket: clear pending_pings,
set last_ping_rtt and last_pong_received to None, and reset ping_rtt_sum and
ping_rtt_samples. Ensure reconnect behavior matches the initialization performed
by Peer::connect().
- Around line 905-908: Replace the hardcoded `127.0.0.1:9999` in
`pong_records_rtt_and_snapshot_averages` with the shared test helper that
creates a localhost address using an ephemeral port; reuse the helper’s returned
`SocketAddr` when constructing `Peer::dummy`.

---

Outside diff comments:
In `@dash-spv/src/network/peer.rs`:
- Around line 415-428: Make receive_message() cancellation-safe by committing
byte counts to the peer’s persistent accounting immediately after every
successful read, including reads in all referenced paths, instead of relying on
the local bytes_read total after the async body completes. Update the relevant
read/accounting logic in receive_message() and add unit coverage that cancels a
partial-frame read, then verifies the buffered bytes are counted on the
subsequent call.

---

Nitpick comments:
In `@contrib/setup-dashd.py`:
- Around line 89-94: Use an explicit Python/tarfile capability or version check
before calling TarFile.extractall with the filter argument, instead of broadly
catching TypeError; retain the unfiltered fallback only for interpreters that
lack filter support, while allowing unrelated extraction TypeErrors to surface.
No code change is required for the trusted-source path concern, but ensure the
PR title uses the fix prefix.
🪄 Autofix (Beta)

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

Run ID: 406c0ce5-6d1f-46f1-b6b0-773e1115e2ad

📥 Commits

Reviewing files that changed from the base of the PR and between 1860089 and 3a9a020.

📒 Files selected for processing (5)
  • contrib/setup-dashd.py
  • dash-spv/src/client/queries.rs
  • dash-spv/src/network/manager.rs
  • dash-spv/src/network/mod.rs
  • dash-spv/src/network/peer.rs

Comment on lines +313 to +319
// Ping immediately so latency stats are available
// right away instead of after the first
// maintenance tick.
if let Err(e) = peer.send_ping().await {
tracing::warn!("Failed to send initial ping to {}: {}", addr, e);
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Abort connection setup when the initial ping fails.

Peer::send_message() clears state and connected_at on a write failure, but this branch only logs the error and continues to record a successful connection, add the disconnected peer, increment the count, and emit PeerConnected. This can expose a false connection event and transient disconnected snapshot.

Remove the connecting marker and return on failure instead of adding the peer.

🤖 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 313 - 319, When the initial
`Peer::send_ping()` in the connection setup path fails, stop setup immediately
instead of continuing as if connected. Remove the connecting marker, clean up
the failed peer/connection state, and return before recording the connection,
adding the disconnected peer, incrementing counts, or emitting `PeerConnected`;
update the `if let Err(e)` branch in the connection manager accordingly.

Comment on lines +55 to +64
bytes_received: u64,
network: Network,
// Ping/pong state
last_ping_sent: Option<SystemTime>,
last_pong_received: Option<SystemTime>,
pending_pings: HashMap<u64, SystemTime>, // nonce -> sent_time
// Measured ping round-trips (running sum/count for the average)
last_ping_rtt: Option<Duration>,
ping_rtt_sum: Duration,
ping_rtt_samples: u32,

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset session-scoped ping state on connect_instance().

Peer::connect() initializes the RTT fields, but the public connect_instance() path only replaces the socket and timestamp. Reconnecting a Peer can therefore retain pending_pings, last_ping_rtt, and the RTT accumulator from the previous session, producing incorrect snapshots.

Also applies to: 138-145

🤖 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/peer.rs` around lines 55 - 64, Reset all session-scoped
ping state in the public Peer::connect_instance() path when replacing the
socket: clear pending_pings, set last_ping_rtt and last_pong_received to None,
and reset ping_rtt_sum and ping_rtt_samples. Ensure reconnect behavior matches
the initialization performed by Peer::connect().

Comment on lines +905 to +908
#[test]
fn pong_records_rtt_and_snapshot_averages() {
let addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let mut peer = Peer::dummy(addr);

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid the fixed test address and port.

Use a shared test helper with an ephemeral port instead of 127.0.0.1:9999.

As per coding guidelines, Rust code must never hardcode network parameters, addresses, or keys.

🤖 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/peer.rs` around lines 905 - 908, Replace the hardcoded
`127.0.0.1:9999` in `pong_records_rtt_and_snapshot_averages` with the shared
test helper that creates a localhost address using an ephemeral port; reuse the
helper’s returned `SocketAddr` when constructing `Peer::dummy`.

Source: Coding guidelines

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.61%. Comparing base (0786810) to head (088b252).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
dash-spv/src/network/peer.rs 92.00% 4 Missing ⚠️
dash-spv/src/client/queries.rs 0.00% 3 Missing ⚠️
dash-spv/src/network/manager.rs 40.00% 3 Missing ⚠️
dash-spv/src/network/mod.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #860      +/-   ##
==========================================
- Coverage   73.85%   73.61%   -0.25%     
==========================================
  Files         250      324      +74     
  Lines       53168    73278   +20110     
==========================================
+ Hits        39268    53943   +14675     
- Misses      13900    19335    +5435     
Flag Coverage Δ
core 77.08% <ø> (ø)
ffi 46.08% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 90.95% <80.00%> (-0.16%) ⬇️
wallet 73.05% <ø> (?)
Files with missing lines Coverage Δ
dash-spv/src/network/mod.rs 96.26% <0.00%> (-1.84%) ⬇️
dash-spv/src/client/queries.rs 19.44% <0.00%> (-1.77%) ⬇️
dash-spv/src/network/manager.rs 71.21% <40.00%> (-1.58%) ⬇️
dash-spv/src/network/peer.rs 63.92% <92.00%> (+2.29%) ⬆️

... and 78 files with indirect coverage changes

…eived)

Adds a PeerStatsSnapshot surface so client consumers can measure how well
individual peers serve data:

- Peer now counts bytes_received at the socket read (closing the
  long-standing TODO in Peer::stats()) and records ping round-trips
  (last + running average) in handle_pong.
- The manager sends an initial ping right after the handshake completes,
  so latency is measured within the first round-trip instead of waiting
  for the first 10s maintenance tick.
- New NetworkManager::peer_stats() trait method (default empty impl keeps
  mock managers compiling) implemented by PeerNetworkManager and exposed
  as DashSpvClient::peer_stats().

Also fixes contrib/setup-dashd.py on Python < 3.9.17, where
tarfile.extractall() does not accept the `filter` kwarg.

Verified: 470 lib unit tests, all integration binaries, and the dashd
suites (dashd_masternode 10/10, dashd_sync 29/29) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer force-pushed the claude/modest-sinoussi-db08c7 branch from 3a9a020 to 088b252 Compare July 10, 2026 11:42
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