fix(p2p): never leave a block root pending without an outcome - #608
Conversation
A root sits in `pending_root_requests` from the moment a BlocksByRoot request is sent until something retires it, and the `FetchBlock` handler deduplicates every later fetch against that table. A root that is never retired is therefore unreachable for the life of the process: the chain actor can ask for it any number of times and no request reaches the wire, with no error to show for it. Three paths ended an attempt without retiring the root: - An error response for a root request re-inserted the request id into `outbound_requests` and returned. `handle_fetch_failure` never reads that map, and an error response completes the exchange, so no `OutboundFailure` followed to pick the root up. - A non-empty response whose blocks all failed the requested-root check fell out of the loop having removed nothing. - libp2p drops a request whose dial it rejects with `DialError::DialPeerConditionFalse` and emits no event at all, so nothing downstream could react. Route the first two through `handle_fetch_failure`, and arm a watchdog at send time for anything libp2p never reports on. The watchdog is pinned above the req/resp timeout, now set explicitly rather than inherited from libp2p's default, so a request that did reach a connection still fails through libp2p's own path first. Split the retry accounting out of `handle_fetch_failure` so it can be tested without a live actor, and make the response path pick the one block that can answer a single-root request instead of looping.
🤖 Kimi Code ReviewI'll review this PR which addresses a critical bug where Overall AssessmentThe PR correctly identifies and fixes a serious liveness bug. The structure is good with clear separation between testable pure logic and async effects. However, I found several issues ranging from correctness to maintainability concerns. Critical Issues1. Race Condition: Watchdog Can Fire After Successful ResponseFile: The watchdog is armed unconditionally, but if a response arrives and succeeds before the watchdog fires, the let attempt = server
.pending_root_requests
.entry(root)
.or_insert(PendingRequest { attempts: 1, ... })
.attempts; // This reads attempt N
// ... later, on success ...
pending.attempts += 1; // This increments to N+1 in record_fetch_failureWait—actually on success, If the old watchdog (with However, there's a worse case: what if the response arrives, calls But what if Actually, re-reading: the success path removes the entry entirely. The retry path increments attempts. The watchdog check Wait—I found it. Consider: response arrives, But what about the new request? Actually, I think there's a different bug. Let me re-check... In let attempt = server
.pending_root_requests
.entry(root)
.or_insert(PendingRequest { attempts: 1, ... })
.attempts;This uses I think the race is actually OK due to the attempt counter check. But this relies on a subtle invariant. A comment explaining this would help, or better: cancel the watchdog explicitly. Recommendation: Add a 2.
|
| Priority | Issue | Location |
|---|---|---|
| Medium | Replace watchdog ordering test with compile-time assert | handlers.rs:800-803 |
| Medium | Clarify MAX_FETCH_RETRIES semantics (attempts vs retries) |
handlers.rs:604-628 |
| Low | Elevate log level for unsolicited blocks | handlers.rs:317 |
| Low | Document race safety of attempt-counter watchdog cancellation | lib.rs:595-625 or handlers.rs:480-496 |
The core fix is correct and well-implemented. The watchdog approach properly handles the libp2p bug without introducing new races. The PR should be approved with minor cleanups.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewNo material findings in the touched code. The new Residual risk: I could not execute Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewNow I have everything needed to write the review. Code Review: PR 608 —
|
`matching_block` had one caller and read better in place, so inline it. Pin the watchdog-outlasts-timeout relationship with a `const` assert beside the two constants instead of a unit test. It is a property of the values themselves, so it belongs where they are declared and should fail the build rather than a test run.
Attempt numbers restart at 1 for every fetch cycle, so a watchdog that outlived its own cycle matched a fresh attempt for the same root and failed it. A give-up finishes ten attempts in about 2.6s while the watchdog runs for 15s, so the stale timer easily outlives the cycle that armed it, and the chain re-asks for a root as soon as another orphan referencing it arrives. Test the request id instead. Every outcome path retires the id from `outbound_requests`, so an id still present is exactly a request libp2p never reported on, and ids are unique across cycles.
The watchdog covered a case I could not show to be reachable here. For libp2p to drop a request silently, `send_request_with_protocol` has to take its dial branch and the swarm has to reject that dial with `DialPeerConditionFalse`, which needs either a concurrent dial or an existing connection: - A concurrent dial resolves either way, and both outcomes drain the peer's parked requests: success through `preload_new_handler`, failure through `on_dial_failure` with a real error. That is the invariant upstream PR 6000 relies on, and it holds. - An existing connection would strand the request, but it requires the behaviour's connection map to disagree with the pool. The swarm adapter runs one task selecting between swarm events and commands, so a command never observes a half-updated swarm. The reported symptom agrees: the request-size histogram last moved an hour before the stall, so the stranding request did reach the wire. A dial-path strand never reaches the codec and would not have moved it at all. That leaves the two response paths, which need no race and are fixed directly. Also restores the req/resp config to `Default::default()`, since naming the timeout explicitly only existed to anchor the watchdog against it.
`record_fetch_failure` and its `FetchFailure` outcome existed to make the accounting reachable from a test without a live actor. Reading it back, the indirection cost more than it bought: the logic is a dozen lines that read fine in place. Its three tests go with it, since they called it directly. The one thing they documented that the code did not is now a comment: a failure for a root nobody is waiting on must stay a no-op, or a late or duplicate event could resurrect a root that already succeeded.
🤖 Kimi Code ReviewI'll review this PR focusing on the request-response handling changes for block-by-root fetching. The changes appear to be a bugfix for a request tracking/deduplication issue. Overall AssessmentThis is a correctness fix for a serious bug: previously, error responses to Detailed Review
|
| Issue | Severity | Notes |
|---|---|---|
| Permanent block unavailability (fixed) | Critical | Original bug could permanently prevent fetching certain blocks |
| No penalty for unsolicited blocks | Low-Medium | Bandwidth waste, no DoS vector beyond existing |
| Single peer can fail all retries | Medium | failed_peers tracking is per-root, but retry logic not shown in diff |
Suggested Improvements
1. Add peer scoring for malformed responses (line 291-302)
// After .find(), if received > 1 or any non-matching block exists:
if received > 1 || blocks.iter().any(|b| b.message.hash_tree_root() != requested_root) {
// Increment peer score for protocol violation
server.peer_scores.entry(peer).and_modify(|s| *s += 1).or_insert(1);
}2. Verify spec compliance on response ordering
Check if the consensus spec requires the requested block to be first in the response. If so, use .next() instead of .find():
let mut blocks = blocks.into_iter();
let answer = blocks.next().filter(|b| b.message.hash_tree_root() == requested_root);
// Then verify blocks.next() is None or handle as violation3. Defensive assertion in handle_fetch_failure
The early return on missing root is correct but consider a debug_assert! or metric to catch unexpected double-removals in testing:
let Some(pending) = server.pending_root_requests.get_mut(&root) else {
trace!(%root, "handle_fetch_failure called for unknown/already-removed root");
return;
};Positive Acknowledgments
- Excellent comment at lines 100-104 — clearly explains why the fix is necessary
- Function-level docstring on
handle_fetch_failure(lines 552-560) — documents the critical invariant that all paths must retire roots - Early return pattern with
let-else— idiomatic Rust, clean control flow - Removal of
request_idparameter — simplifies API after removing re-insertion logic
Summary
| Category | Verdict |
|---|---|
| Bug fix correctness | ✅ Correct — fixes root leak |
| Remaining DoS/attack surface | |
| Memory safety | ✅ No issues |
| Async safety | ✅ No obvious races |
| Spec compliance | ❓ Verify response ordering requirement |
Recommend merging with follow-up for peer scoring.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Claude Code ReviewReview: PR 608 —
|
🤖 Codex Code ReviewNo blocking findings. The change in handlers.rs and handlers.rs looks correct: Residual risk: this behavior is not obviously covered by tests, and it is easy to regress because block-protocol error chunks are still decoded as I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🗒️ Description / Motivation
A root sits in
pending_root_requestsfrom the moment aBlocksByRootrequest is sent until something retires it, and theFetchBlockhandler deduplicates every later fetch against that table. A root that is never retired is unreachable for the life of the process: the chain actor can ask for it any number of times, no request reaches the wire, and nothing is logged abovedebugto say so.Two response paths ended an attempt without retiring the root. Neither needs a race; both are reachable with an ordinary peer on the other end.
1. An error response for a root request. The arm removed the id from
outbound_requests, put it straight back, and returned:handle_fetch_failurenever readsoutbound_requests, and an error response completes the exchange, so noOutboundFailurefollows to pick the root up. Our own by-root responder never sends an error code, but other clients do.2. A response whose blocks all fail the requested-root check. Every block hit
continue, sopending_root_requests.removewas never reached and the function returned having retired nothing.outbound_requestshad already been drained at the dispatch site, so no later event could recover it either.Both leave the root pending with no retry scheduled, which is why the symptom is an indefinite silence rather than the
error!("...giving up")you would expect from a fetch that ran out of attempts.A leak alongside them. That re-insert appears twice on
main: in the error arm above, and in the empty-response path, where a comment states the premise outright — "Re-insert so failure handling can find it".handle_fetch_failuretakesrootandpeeras arguments and has never readoutbound_requests, so it bought nothing either time. It also leaked: the dispatch site has already removed the id, the exchange is over, and no later libp2p event carries that id, so nothing removes it a second time. Every empty or errorBlocksByRootresponse left one dead entry in the map for the life of the process. The empty-response path did fail the root correctly, so the leak was its only symptom.What Changed
All in
crates/net/p2p/src/req_resp/handlers.rs.handle_fetch_failure, which is the single funnel that retires an attempt.request_idparameter and the leaking re-insert it existed for are both gone.Correctness / Behavior Guarantees
MAX_FETCH_RETRIESattempts with doubling backoff, then the entry is dropped and anerror!is logged. Dropping the entry means the P2P side forgets the root, not that it is blacklisted; the chain starts a fresh cycle the next time an orphan referencing that root arrives.outbound_requestsis leak-free in the ordinary case: two insert sites at send, three remove sites covering every terminal event, and a retry inserts a fresh id only after the failure that triggered it removed the old one. That bound rests on every request eventually producing a terminal event, the same assumption behind dropping the watchdog below; if it is ever wrong, this map leaks alongside the stranded root.Tests Added / Run
No new tests. Both funnels sit behind an async handler taking
&mut P2PServerand a liveContext, which the crate has no harness for; earlier revisions of this branch reached the retry accounting by extracting it into a free function, and that indirection was not worth keeping for its own sake. Worth knowing that the existing suite did not catch either hole.All green: 658 tests, 0 failures.
A third path that was considered and rejected
Earlier revisions added a watchdog for a request libp2p never reports an outcome for, because
libp2p-request-responsedrops a request whose dial the swarm rejects withDialError::DialPeerConditionFalse(upstream PR 6000, in our 0.30.0 pin). That case is not reachable here, so it was removed in 3f67c80:preload_new_handleroron_dial_failure. That is the invariant PR 6000 relies on.swarm_adapter.rsruns one task selecting between swarm events and commands, so a command never observes a half-updated swarm.The reported symptom agrees. The request-size histogram last moved an hour before the stall, so the stranding request did reach the wire; a dial-path strand never reaches the codec and would not have moved it at all.
Not in scope
BlocksByRangehas the same shape, and a worse failure mode: a stranded range request leavesrange_sync_state.in_flight = trueforever, wedging sync rather than losing one block.crates/net/p2p/src/sync.rs(record_root_fetch_failure, keyed byRootKey).✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(cargo test --workspace --profile release-fast) — all passing