fix(p2p): never leave a block root pending without an outcome - #608
fix(p2p): never leave a block root pending without an outcome#608MegaRedHand wants to merge 1 commit into
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 —
|
🗒️ 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.Three paths ended an attempt without retiring the root.
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.3. libp2p never reporting an outcome.
libp2p-request-responsedeliberately drops a request whose dial the swarm rejects withDialError::DialPeerConditionFalse:That is upstream PR 6000, shipped in 0.29.0; our pin is 0.30.0. The request stays in the behaviour's
pending_outbound_requestsand is drained only bypreload_new_handleron the next connection to that peer, so noOutboundFailureis emitted and nothing downstream can react.What Changed
crates/net/p2p/src/req_resp/handlers.rshandle_fetch_failure, which is the single funnel that retires an attempt.matching_block()picks the one block that can answer a single-root request, replacing the loop. It subsumes the old empty-response special case, which is why therequest_idparameter and the re-insert it existed for are gone.record_fetch_failure()holds the retry accounting (attempt count, failed-peer set, backoff, give-up) and returns aFetchFailureoutcome.handle_fetch_failureis now the logging and scheduling shell around it.fetch_block_from_peertakes aContextand arms a watchdog for the attempt it just sent.crates/net/p2p/src/lib.rsBlockFetchTimeout { root, peer, request_id, attempt }protocol message and its handler. It fires intohandle_fetch_failureonly when the pending entry is still on the sameattempt, so a settled or superseded attempt leaves it a no-op.REQ_RESP_TIMEOUTis now passed explicitly torequest_response::Configinstead of being inherited fromDefault::default(). Same value libp2p defaulted to, so no behavior change, but the watchdog's ordering constraint is now stated in code rather than assumed.ROOT_FETCH_WATCHDOGsits above it, so a request that did reach a connection still fails through libp2p's own path first.Correctness / Behavior Guarantees
pending_root_requestshas an outstanding timer or an in-flight request that will retire it. Previously the table's only exits were a matching response, anOutboundFailurechain, and a failed retry send.ROOT_FETCH_WATCHDOG > REQ_RESP_TIMEOUTis asserted in a test.attempts, and the timer carries the attempt it was armed for.record_fetch_failurereturnsNotPendingfor an untracked root, so a late or duplicate failure cannot resurrect a root that already succeeded.MAX_FETCH_RETRIESis unchanged.Tests Added / Run
Five tests in
req_resp::handlers::tests:matching_block_rejects_a_response_that_answers_a_different_root— the regression for hole 2.record_fetch_failure_ignores_an_untracked_rootrecord_fetch_failure_backs_off_and_excludes_the_failing_peer— attempt counting, doubling backoff, failed-peer exclusion.record_fetch_failure_clears_the_root_when_it_gives_upthe_fetch_watchdog_outlasts_the_libp2p_request_timeoutAll green: 663 tests, 0 failures.
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).Related Issues / PRs
DisconnectedAndNotDialingcondition)✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(cargo test --workspace --profile release-fast) — all passing