fix(l1): p2p sync stall fixes and discovery hardening#6394
Conversation
🤖 Kimi Code ReviewThis PR introduces a UDP rate limiter and discovery traffic shaping to avoid hosting provider scan detection. The implementation is generally sound but has a few issues: Critical Issues1. Resource leak in rate limiter background task The replenishment task runs indefinitely even when all tokio::spawn(async move {
let mut ticker = time::interval(interval);
loop {
ticker.tick().await;
if sem.available_permits() < MAX_PACKETS_PER_SECOND as usize {
sem.add_permits(1);
}
}
});Fix: Use a 2. Logic error in peer table pruning The comment states "Remove contacts that have been repeatedly failing ... or that are disposable" but the code uses (c.disposable && c.failed_attempts >= MAX_FAILED_ATTEMPTS_BEFORE_PRUNE)This only prunes disposable contacts that have failed 5+ times, leaving other disposable contacts in the table indefinitely (contrary to the previous behavior and comment). Fix: Change to Minor Issues3. Division by zero risk let interval = Duration::from_micros(1_000_000 / MAX_PACKETS_PER_SECOND as u64);If 4. Unnecessary double iteration
Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
Lines of code reportTotal lines added: Detailed view |
🤖 Codex Code Review
I couldn’t run the Rust tests in this sandbox because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewNow I have enough context for a thorough review. Review: fix(l1): p2p rate limit outgoing (PR #6394)The motivation is solid and the PR description is excellent — the root-cause analysis is unusually thorough. Most of the individual fixes are correct. A few issues worth addressing before merge. Critical: Disposable contacts are never pruned (memory leak)
(c.disposable && c.failed_attempts >= MAX_FAILED_ATTEMPTS_BEFORE_PRUNE)
.then_some(*c_id)But Before this PR, every disposable contact was removed on the next 5-second prune cycle. Now they stay in the table forever (up to // prune if disposable regardless of failure count,
// OR if too many failures even without being flagged disposable
(c.disposable || c.failed_attempts >= MAX_FAILED_ATTEMPTS_BEFORE_PRUNE)
.then_some(*c_id)Minor: Background replenishment task leaks on socket drop
let sem = semaphore.clone(); // strong clone
tokio::spawn(async move {
loop { ticker.tick().await; sem.add_permits(1); }
});When all Use a weak reference so the task can exit cleanly: let sem = Arc::downgrade(&semaphore);
tokio::spawn(async move {
let mut ticker = time::interval(interval);
loop {
ticker.tick().await;
let Some(sem) = sem.upgrade() else { break };
if sem.available_permits() < MAX_PACKETS_PER_SECOND as usize {
sem.add_permits(1);
}
}
});Minor: Non-random
|
🤖 Codex Code ReviewFindings
Assumptions
Testing gaps
Summary Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR addresses a real production issue where ethrex nodes on Hetzner were generating ~29k outgoing UDP packets/second on port 30303, triggering abuse detection. The fix is multi-faceted and well-reasoned: it eliminates the root causes rather than adding a rate limiter. Key changes:
Minor notes:
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| crates/networking/p2p/peer_table.rs | Core change: adds exponential backoff (failed_attempts, last_failure) to Contact, reduces TARGET_CONTACTS from 100k to 5k, caps the revalidation batch at 20, bounds the discard set at 10k with LRU eviction, and fixes the sent_ping_is_stale logic to gate on has_pending_ping(). Timeout-detection first-pass in get_contacts_to_revalidate and record_success() on pong receipt are well-placed. Minor: .take(20) on insertion-ordered iterator may starve newer contacts; get_contact_for_enr_lookup lacks the backoff guard present in other selectors. |
| crates/networking/p2p/discv4/server.rs | Removes the per-neighbor immediate ping loop in handle_neighbors (eliminating 1:16 UDP amplification), reduces REVALIDATION_CHECK_INTERVAL from 12h to 1h (so it's meaningfully shorter than the 12h stale threshold), and cuts the lookup interval from 100ms/600ms to 1s/3s. Change is clean and intentional. |
| crates/networking/p2p/discv5/server.rs | Slows revalidation check from 10s→30s and stale threshold from 30s→5min, and halves lookup rates from 100ms/600ms to 1s/3s. Straightforward constant changes aligned with the PR's goal of reducing burst traffic. |
Sequence Diagram
sequenceDiagram
participant RV as RevalidationLoop
participant PT as PeerTable
participant Net as UDP Socket
participant Remote as Remote Node
Note over RV,PT: Every 30s (discv5) / 1h (discv4)
RV->>PT: get_contacts_to_revalidate()
PT->>PT: Pass 1 – detect timed-out pings (>30s)<br/>record_failure(), clear ping_id
PT->>PT: Pass 2 – collect stale contacts<br/>skip is_in_backoff(), take(20)
PT-->>RV: [up to 20 contacts]
loop for each contact
RV->>Net: send_ping()
Net->>Remote: PING
alt Pong received in time
Remote->>Net: PONG
Net->>PT: record_pong_received()<br/>record_success() → reset failed_attempts
else Timeout (>30s, next cycle)
PT->>PT: record_failure()<br/>failed_attempts++, backoff = 2^(n-1)s
Note over PT: If failed_attempts >= 5 → prune
end
end
Note over PT: SetDisposable (send error)<br/>record_failure() + disposable=true<br/>→ pruned next PRUNE_INTERVAL (5s)
Note over PT: discarded_contacts capped at 10k<br/>oldest evicted → re-discoverable
Comments Outside Diff (1)
-
crates/networking/p2p/peer_table.rs, line 908-921 (link)get_contact_for_enr_lookupdoesn't filter onis_in_backoff()get_contact_for_lookupandget_contacts_to_revalidateboth skip contacts in backoff, butget_contact_for_enr_lookupdoes not. While ENR requests are infrequent, contacts that failed pings and are in exponential backoff will still receive ENR requests. For consistency and to fully honour the backoff semantics, consider adding the same guard here:Prompt To Fix With AI
This is a comment left during a code review. Path: crates/networking/p2p/peer_table.rs Line: 908-921 Comment: **`get_contact_for_enr_lookup` doesn't filter on `is_in_backoff()`** `get_contact_for_lookup` and `get_contacts_to_revalidate` both skip contacts in backoff, but `get_contact_for_enr_lookup` does not. While ENR requests are infrequent, contacts that failed pings and are in exponential backoff will still receive ENR requests. For consistency and to fully honour the backoff semantics, consider adding the same guard here: How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/networking/p2p/peer_table.rs
Line: 956-965
Comment:
**Revalidation batch may always favour earliest-inserted contacts**
`.take(20)` operates on an `IndexMap` iterated in insertion order, so the same 20 oldest-inserted contacts are always selected for revalidation until they fail and are pruned. With 5,000 contacts, recently-discovered nodes near the tail of the map could go unvalidated for many cycles.
Consider randomising the selection — e.g. shuffle the filtered iterator or use `choose_multiple` — so newer contacts also get a chance to be pinged:
```rust
// Second pass: collect contacts that need revalidation and aren't in backoff
let mut candidates: Vec<Contact> = self
.contacts
.values()
.filter(|c| {
c.supports_protocol(protocol)
&& Self::is_validation_needed(c, revalidation_interval)
&& !c.is_in_backoff()
})
.cloned()
.collect();
candidates.shuffle(&mut rand::rngs::OsRng);
candidates.truncate(MAX_REVALIDATION_BATCH);
candidates
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/networking/p2p/peer_table.rs
Line: 908-921
Comment:
**`get_contact_for_enr_lookup` doesn't filter on `is_in_backoff()`**
`get_contact_for_lookup` and `get_contacts_to_revalidate` both skip contacts in backoff, but `get_contact_for_enr_lookup` does not. While ENR requests are infrequent, contacts that failed pings and are in exponential backoff will still receive ENR requests. For consistency and to fully honour the backoff semantics, consider adding the same guard here:
```suggestion
fn get_contact_for_enr_lookup(&mut self) -> Option<Contact> {
self.contacts
.values()
.filter(|c| {
c.is_discv4
&& c.was_validated()
&& !c.has_pending_enr_request()
&& c.record.is_none()
&& !c.disposable
&& !c.is_in_backoff()
})
.collect::<Vec<_>>()
.choose(&mut rand::rngs::OsRng)
.cloned()
.cloned()
}
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(l1): clear validation on ping timeou..." | Re-trigger Greptile
| } | ||
|
|
||
| // Second pass: collect contacts that need revalidation and aren't in backoff. | ||
| // Shuffle before truncating so we don't always pick the same contacts | ||
| // (IndexMap iterates in insertion order). | ||
| let mut candidates: Vec<Contact> = self | ||
| .contacts | ||
| .values() | ||
| .filter(|c| { | ||
| c.supports_protocol(protocol) |
There was a problem hiding this comment.
Revalidation batch may always favour earliest-inserted contacts
.take(20) operates on an IndexMap iterated in insertion order, so the same 20 oldest-inserted contacts are always selected for revalidation until they fail and are pruned. With 5,000 contacts, recently-discovered nodes near the tail of the map could go unvalidated for many cycles.
Consider randomising the selection — e.g. shuffle the filtered iterator or use choose_multiple — so newer contacts also get a chance to be pinged:
// Second pass: collect contacts that need revalidation and aren't in backoff
let mut candidates: Vec<Contact> = self
.contacts
.values()
.filter(|c| {
c.supports_protocol(protocol)
&& Self::is_validation_needed(c, revalidation_interval)
&& !c.is_in_backoff()
})
.cloned()
.collect();
candidates.shuffle(&mut rand::rngs::OsRng);
candidates.truncate(MAX_REVALIDATION_BATCH);
candidatesPrompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/p2p/peer_table.rs
Line: 956-965
Comment:
**Revalidation batch may always favour earliest-inserted contacts**
`.take(20)` operates on an `IndexMap` iterated in insertion order, so the same 20 oldest-inserted contacts are always selected for revalidation until they fail and are pruned. With 5,000 contacts, recently-discovered nodes near the tail of the map could go unvalidated for many cycles.
Consider randomising the selection — e.g. shuffle the filtered iterator or use `choose_multiple` — so newer contacts also get a chance to be pinged:
```rust
// Second pass: collect contacts that need revalidation and aren't in backoff
let mut candidates: Vec<Contact> = self
.contacts
.values()
.filter(|c| {
c.supports_protocol(protocol)
&& Self::is_validation_needed(c, revalidation_interval)
&& !c.is_in_backoff()
})
.cloned()
.collect();
candidates.shuffle(&mut rand::rngs::OsRng);
candidates.truncate(MAX_REVALIDATION_BATCH);
candidates
```
How can I resolve this? If you propose a fix, please make it concise.
🤖 Claude Code ReviewNow I have enough context to write the review. Review:
|
Arkenan
left a comment
There was a problem hiding this comment.
There's a single change I would do. The rest looks perfect. I'm only marking it as a request for changes as I don't think it wise to merge without addressing it.
Arkenan
left a comment
There was a problem hiding this comment.
LGTM thanks for the changes
ElFantasma
left a comment
There was a problem hiding this comment.
I have a concern regarding the speed that peers will be discovered. Lookup interval of 1 second seems too slow. That interval is used for FindNode messages on discv4 and discv5, and also to try to establish RLPx connections with new peers.
Also that interval is configurable via CLI, if it is too agressive a user could change it in the command line.
Also discv4 seems to slow to start sending pings to found neighbors. This should be tested with discv5 disabled and see how discv4 alone behaves (and maybe a test with discv5 alone too)
Discovery: - Remove discv4 ping amplification: handle_neighbors no longer pings every returned node (was up to 16 PINGs per FindNode) - Drop unsolicited Neighbors messages using pending_find_node tracking Snap sync pivot: - Exponential backoff on pivot retry (1s/2s/4s/.../30s) - Cap at 3 retries per peer, 15 total before bailing out - Reset backoff when a different peer is found Header download: - Reserve request slots before tokio::spawn to prevent flooding a single peer with dozens of concurrent tasks - Workers call outgoing_request directly (not make_request) to avoid double inc_requests - Cap sync head resolution to 5 peers per retry, 3 inner retries, 5 outer attempts, 5s delay between retries - Reduce MAX_HEADER_FETCH_ATTEMPTS from 100 to 5
Required companion to the ping amplification removal: without immediate pings in handle_neighbors, new contacts need the revalidation loop to validate them. At 12h interval they were stuck unvalidated and invisible to lookups.
5d869a7 to
0c5c897
Compare
ElFantasma
left a comment
There was a problem hiding this comment.
It LGTM now. It should be tested to ensure peers are still discovered at an appropriate speed, but the changes seem OK.
In any case, incoming and outgoing packet rates should be fine-tuned after working on #5990
Tested on mainned, reached 100 peers in 5-6 minutes like main |
## Summary Fixes sync stalls and discovery issues found while testing on mainnet-7. ### discv4: remove ping amplification and drop unsolicited Neighbors (`discv4/server.rs`) `handle_neighbors` was pinging every returned node immediately -- up to 16 PINGs per FindNode response (1:16 amplification). Removed the ping loop; new contacts are now validated by the revalidation loop instead. Added `pending_find_node` tracking so only Neighbors responses to our own FindNode requests are accepted, preventing unsolicited table injection. Reduced `REVALIDATION_CHECK_INTERVAL` from 12h to 30s as a required companion: without immediate pings, new contacts need the revalidation loop to validate them before they become usable for lookups. ### snap sync: exponential backoff on pivot retry (`sync/snap_sync.rs`) `update_pivot` was retrying the same peer thousands of times with no delay, tanking its score from 50 to -50. Now uses exponential backoff (1s/2s/4s/.../30s), caps at 3 retries per peer, resets backoff when a different peer is found, and bails out after 15 total failures. ### download tasks: reserve request slots before spawning (`peer_handler.rs`, `snap/client.rs`) Header/account/storage/bytecode download loops spawned tasks without incrementing the peer's request count first. Since `inc_requests` only ran inside the spawned task, `get_best_peer` kept returning the same peer, flooding it with dozens of concurrent requests in a single tick. Workers now call `outgoing_request` directly instead of `make_request` to avoid double `inc_requests`. ### sync head resolution: prevent multi-hour stall (`peer_handler.rs`, `snap/constants.rs`, `sync/full.rs`) `request_block_headers` asked all peers sequentially (42 peers x 15s timeout = 10+ min per retry) with 10 inner retries and 100 outer attempts. When no peer recognized the sync head hash, the node stalled for hours at "0 headers/s". Fixed by capping to 5 peers per retry, 3 inner retries, 5 outer attempts with 5s delay between them. `MAX_HEADER_FETCH_ATTEMPTS` reduced from 100 to 5. ## Testing Deployed to mainnet-7. Reaches 100 peers in ~6 minutes (same as main), headers download at 12,354/s, sync completes all phases without stalling. ## Test plan - [x] Deployed to mainnet-7, verified peer discovery speed matches main - [ ] `cargo test -p ethrex-p2p` # Conflicts: # crates/networking/p2p/snap/client.rs
Fixes sync stalls and discovery issues found while testing on mainnet-7. (`discv4/server.rs`) `handle_neighbors` was pinging every returned node immediately -- up to 16 PINGs per FindNode response (1:16 amplification). Removed the ping loop; new contacts are now validated by the revalidation loop instead. Added `pending_find_node` tracking so only Neighbors responses to our own FindNode requests are accepted, preventing unsolicited table injection. Reduced `REVALIDATION_CHECK_INTERVAL` from 12h to 30s as a required companion: without immediate pings, new contacts need the revalidation loop to validate them before they become usable for lookups. `update_pivot` was retrying the same peer thousands of times with no delay, tanking its score from 50 to -50. Now uses exponential backoff (1s/2s/4s/.../30s), caps at 3 retries per peer, resets backoff when a different peer is found, and bails out after 15 total failures. (`peer_handler.rs`, `snap/client.rs`) Header/account/storage/bytecode download loops spawned tasks without incrementing the peer's request count first. Since `inc_requests` only ran inside the spawned task, `get_best_peer` kept returning the same peer, flooding it with dozens of concurrent requests in a single tick. Workers now call `outgoing_request` directly instead of `make_request` to avoid double `inc_requests`. `snap/constants.rs`, `sync/full.rs`) `request_block_headers` asked all peers sequentially (42 peers x 15s timeout = 10+ min per retry) with 10 inner retries and 100 outer attempts. When no peer recognized the sync head hash, the node stalled for hours at "0 headers/s". Fixed by capping to 5 peers per retry, 3 inner retries, 5 outer attempts with 5s delay between them. `MAX_HEADER_FETCH_ATTEMPTS` reduced from 100 to 5. Deployed to mainnet-7. Reaches 100 peers in ~6 minutes (same as main), headers download at 12,354/s, sync completes all phases without stalling. - [x] Deployed to mainnet-7, verified peer discovery speed matches main - [ ] `cargo test -p ethrex-p2p`
Summary
Fixes sync stalls and discovery issues found while testing on mainnet-7.
discv4: remove ping amplification and drop unsolicited Neighbors (
discv4/server.rs)handle_neighborswas pinging every returned node immediately -- up to 16 PINGs per FindNode response (1:16 amplification). Removed the ping loop; new contacts are now validated by the revalidation loop instead.Added
pending_find_nodetracking so only Neighbors responses to our own FindNode requests are accepted, preventing unsolicited table injection.Reduced
REVALIDATION_CHECK_INTERVALfrom 12h to 30s as a required companion: without immediate pings, new contacts need the revalidation loop to validate them before they become usable for lookups.snap sync: exponential backoff on pivot retry (
sync/snap_sync.rs)update_pivotwas retrying the same peer thousands of times with no delay, tanking its score from 50 to -50. Now uses exponential backoff (1s/2s/4s/.../30s), caps at 3 retries per peer, resets backoff when a different peer is found, and bails out after 15 total failures.download tasks: reserve request slots before spawning (
peer_handler.rs,snap/client.rs)Header/account/storage/bytecode download loops spawned tasks without incrementing the peer's request count first. Since
inc_requestsonly ran inside the spawned task,get_best_peerkept returning the same peer, flooding it with dozens of concurrent requests in a single tick. Workers now calloutgoing_requestdirectly instead ofmake_requestto avoid doubleinc_requests.sync head resolution: prevent multi-hour stall (
peer_handler.rs,snap/constants.rs,sync/full.rs)request_block_headersasked all peers sequentially (42 peers x 15s timeout = 10+ min per retry) with 10 inner retries and 100 outer attempts. When no peer recognized the sync head hash, the node stalled for hours at "0 headers/s". Fixed by capping to 5 peers per retry, 3 inner retries, 5 outer attempts with 5s delay between them.MAX_HEADER_FETCH_ATTEMPTSreduced from 100 to 5.Testing
Deployed to mainnet-7. Reaches 100 peers in ~6 minutes (same as main), headers download at 12,354/s, sync completes all phases without stalling.
Test plan
cargo test -p ethrex-p2p