Repository navigation
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 |
…ambdaclass#9) * feat(blockchain): drive the beacon chain from BlockChainServer The actor was the last lean-only layer in a stack that has already unified types, storage, the state transition, fork choice, p2p dispatch and startup: ChainSetup.chain was None on mainnet, so run_node spawned nothing and gossip blocks were decoded only to be dropped. Nothing is wired up here. Feeding the actor beacon gossip and spawning it from run_node are later slices, so this changes no binary behaviour and the beacon arm is exercised by tests rather than by a running node. Blocks now cross the actor boundary as SignedBeaconBlock. Its Lean variant already exists and its message accessors already answer for it, so widening P2PToBlockChain::new_block and Store::insert_pending_block makes the whole pending-parent cascade chain-generic instead of duplicated, and removes two expect_lean assertions that held only because of a chain tag elsewhere. ChainDuties moves the seven lean-only fields behind a Lean variant, so they are unreachable on the beacon arm by construction rather than sitting inert there. The store, the pending maps, the sync-status tracker and the event bus stay shared. An early beacon block is deferred rather than dropped. Beacon's on_block requires a block's slot to be in the past, and the specification's own instruction above that assertion is that such a block's consideration "must be delayed until they are in the past". One dropped early block froze the live mainnet follower permanently on 2026-09-03: it arrived 273ms before its slot, and every later block was then an orphan of a root the node would never obtain. The window is wider than any clock-disparity tolerance, since the beacon store clock advances only in the tick handler and a multi-second import blocks that same thread, so the rule is really "before the first tick at or after the slot boundary". Deferral is bounded by BEACON_DEFERRAL_HORIZON_SLOTS: it persists the block, so deferring on "is in the future" alone would let one peer spend this node's disk on slots years away. The lean arm keeps its existing whole-slot margin and is not touched. It cannot be shared either way: beacon carries a specification assertion that the fork-choice fixture suite tests. Beacon ticks once per slot boundary. on_tick_per_slot resets proposer boost at a slot boundary and pulls up unrealized checkpoints at an epoch boundary, and does nothing between them; Config's attestation_due_bps and its siblings are validator duty deadlines that a follower never reads. Chain events needed fixing to get here. diff_and_emit and checkpoint_state_root both read through Store::get_block_header, which decodes a lean BlockHeader and so panics on a beacon directory, where that table holds the whole signed block. Both wanted the same two fields, so Store::block_slot_and_state_root answers for either chain in one read, keeping a slot and a state root from ever being paired across two blocks. Known gaps, all p2p-side and all deliberate: there is no beacon by-root fetch, so request_missing_block is a logged no-op on beacon and a block with a missing parent pends indefinitely. This arm can follow a contiguous run and nothing else until that lands. * feat(beacon): follow mainnet, from the anchor to the tip The wire and the chain actor existed and did not touch each other. The mainnet arm spawned no actor, gossip blocks were decoded and dropped, fetched blocks were validated and dropped, both request senders were exported and called from nowhere, and Status advertised zeroes so peers had no reason to ask us for anything. Connect them: - `run_node` spawns a chain actor on both arms. `ChainSetup.chain` becomes a `ChainActor` enum rather than a lean-shaped Option, so the InitP2P/InitBlockChain wiring and `wait_for_shutdown` now cover the beacon follower too. - Beacon gossip blocks go to `new_block` with `BlockSource::Gossip`. Aggregates still do not: that topic carries ~1000 per slot at ~30ms each in `on_attestation`, more work per slot than a slot lasts. - Fetched blocks go to `new_block` with `BlockSource::Sync`, keeping every per-block range and root check. - A peer's Status starts range sync, keyed off a new `beacon_fetched_through` watermark rather than the store's head. The head lags a delivered batch by the whole actor mailbox, which is what once cost 11,213 blocks off the wire to import 100. - `fetch_block_from_peer` picks its protocol from the wire, so `BlockChainToP2P::fetch_block` stays one chain-agnostic method and the actor's `request_missing_block` no longer no-ops on beacon. - `build_status` reads the store: head, finalized checkpoint, and an `earliest_available_slot` of the anchor rather than 0, since this node cannot serve genesis. Two of the three defects that froze the 2026-09-03 follower are fixed here. A by-root fetch now arms its own deadline, `RootFetchTimeout`, because `pending_root_requests` otherwise clears only on a libp2p outcome and a request drawing none locks that root for the life of the process. A finished range session now clears back to None instead of disabling the resync path forever. Verified against mainnet, and the wire half works end to end: anchored at slot 15171200 (fulu, 2,361,891 validators), Status drove range sync, 70 blocks came back on beacon_blocks_by_range_v2 and 2 on beacon_blocks_by_root_v2, gossip blocks reached the actor and pended on their missing parent, which then asked for it. It does not catch up yet, for a reason outside this change: one import at that validator count ran over ten minutes at 100% CPU, and a stack sample puts it in `process_attestation` calling `get_total_active_balance` and `get_active_validator_indices`, both of which walk the whole registry per attestation. Because the actor is single-threaded, that also stops its ticks, so `store.time()` freezes and head recomputation never runs. Making those accessors cheap is its own change. * fix(beacon): feed a block's own attestations to fork choice The follower imported blocks and computed nothing from them. Measured on ethlambda-4 against mainnet: a contiguous chain from slot 15171489 to 15171523 landed off the anchor at 15171488, while head, justified and finalized all stayed pinned at the anchor and only the wall-clock slot advanced. `fork_choice::on_block` does not iterate a block's attestations, since the specification leaves that to the caller, and the actor did not either. So the latest-messages map was permanently empty, every block weighed nothing, and `get_head` never descended past the justified root. Gossip aggregates are deliberately not forwarded, that topic being ~1000 aggregates per slot at ~30ms each, so block bodies are the only vote source there is meant to be. This makes that true. `on_block_attestation` takes the carrying block's post-state as the committee source rather than each attestation's target checkpoint state. The two name the same committees, and asking the checkpoint instead is the expensive way to reach a verdict this node already has: `process_attestation` ran the same `is_valid_indexed_attestation` on the way to producing the block, and once a target's post-state falls out of the recency cache, rebuilding it replays every block since the last pinned boundary. On the earlier follower that was one import at 78.9s against a 3.6s steady state. `on_attestation` is untouched, so the spec-conformance fixtures keep exercising the verifying path. A body item that cannot be evaluated is not an import failure. The block is in the store whatever the loop returns, and returning `Err` would make the caller stop the pending-block cascade, leaving held descendants stuck behind a block that did import. A checkpoint-synced follower reaches this legitimately: an attestation may name a target up to `SLOTS_PER_EPOCH` slots back, and for the first epochs after the anchor that target is below it and was never fetched, so `validate_on_attestation` rejects it. Each call therefore logs at trace and continues. Ported from `feat/mainnet-network`, without that branch's `fork_choice::block_state` helper or its block-root-returning `on_block`: the actor already holds the root, and the post-state was written under it by the import a moment earlier, so `Store::get_state` is a direct hit. * refactor(beacon): hold an early block in a timer, not in a map A beacon block that arrives before its own slot starts is held rather than dropped, but the hold cost the follower a map and the DB a write: the root went into `BeaconFollower::deferred_blocks`, the block into the pending-block table, and every tick ran a range query to find what had become replayable. The actor can wait on its own. `process_or_pend_block` now hands an early block back to `run_import_cascade`, which re-delivers it to this same actor with `send_after`, timed to the start of the block's own slot. The block rides in the message, so nothing is written for it and nothing is left to reconcile if the process stops before its slot arrives. `BEACON_DEFERRAL_HORIZON_SLOTS` still bounds what one peer can make this node hold, in memory now rather than on disk. The re-delivery handler runs the slot's tick before importing. Its timer is set off the wall clock, but every slot comparison the import makes reads the store clock, which only the tick advances, and that tick is armed for the same instant. `on_beacon_tick` is idempotent through its own store-clock guard, so running it here settles the ordering rather than betting on which message the mailbox delivers first. `BeaconFollower` held nothing else, so `ChainDuties::Beacon` becomes a unit variant and the beacon tick loses its replay pass. The unit tests that asserted on the map's contents go with it. * refactor(p2p): drop the by-root fetch deadline Every by-root fetch armed a `RootFetchTimeout` self-message as a backstop for a request that drew neither a response nor an `OutboundFailure`, and so left its `pending_root_requests` entry stranded for the life of the process. Nothing is left for it to catch. The response paths that ended an attempt without retiring the root were fixed in lambdaclass#608, and a request that draws no reply at all is already bounded by the request-response behaviour's own 10s `request_timeout`, which arrives as `OutboundFailure::Timeout` and goes through `handle_fetch_failure` like any other failure. What remains is a second mechanism over the same entry, and a `ctx` parameter that `fetch_block_from_peer` carried for nothing else. * refactor(beacon): re-deliver a held block as a NewBlock, not a message of its own Holding an early block until its slot needs no protocol message of its own. The actor already has one for "here is a block": `NewBlock` carries a `BlockSource`, and what a deferral changes is the source, not the question being asked. So `BlockSource` grows a `Deferred` variant, the one the p2p layer never sends, and `defer_early_block` arms its timer with `NewBlock` instead. `Handler<NewBlock>` turns its `if source == Gossip` into a match, so every source now has to be answered for: gossip keeps the `ChainEvent::BlockGossip` emission and the arrival histogram, sync keeps skipping both, and deferred skips them because the block's first arrival is what they already described. The tick that has to run before a deferred import, and the reason it does, move into that arm with it. * refactor(beacon): decide an early block at arrival, against the wall clock The hold for a block whose slot has not started was decided a whole slot wide and against the store clock, inside the import cascade. Both halves were larger than they needed to be. The window is now `MAXIMUM_GOSSIP_CLOCK_DISPARITY`, 500ms, the phase0 p2p-interface constant that already says how far apart two honest clocks may read. A block early by more than that is not early, it is wrong, and a whole slot of headroom was only ever a bound on how much memory one peer could make this node hold. The decision moves to `Handler<NewBlock>`, where it is one comparison of the block's own slot-start timestamp against the wall clock. Arrival is the only place a hold is still possible anyway: by the time the cascade has the block, its caller is a loop with nowhere to put it back, which is what the `Option<SignedBeaconBlock>` return and the `ctx` threaded through `on_block` and `run_import_cascade` were paying for. All three go back to their previous shapes. Reading the wall clock costs one thing the store clock gave for free. The old comparison also covered a tick that had not run yet, since an unticked slot simply deferred the block again; a wall-clock comparison would instead hand it to `on_block` and have the spec assertion reject it. So the admit path catches the store clock up first, and only when it still reads a slot below the block's, which keeps sync backfill out of it entirely. * refactor(blockchain): one tick pipeline for both chains `on_lean_tick` and `on_beacon_tick` were two functions because their duties differ, but a tick is the same four steps on either chain: decode the clock, advance the chain, run this chain's duties, refresh the gauges. Only the middle two actually differ, and both were carrying their own copy of the other two. `on_tick` is now that sequence, and each step answers for the chain it runs on. `begin_tick` decodes the wall clock onto the chain's grid and drops a tick the store clock has already covered, returning a `Tick` that every later step reads instead of deriving a slot of its own. `advance_chain` moves the store clock: `store::on_tick` on lean, which carries its own fork-choice work, and `fork_choice::on_tick` plus `get_head` on beacon, where nothing else records a head. `run_duties` is lean's interval grid, and returns immediately on a chain whose tick carries no interval. `observe_tick` refreshes the head, justified and finalized gauges, and lean's safe target. Only the chain advance stays inside the event-diff window. A lean duty can import a block, and `process_block` diffs and emits around its own import, so a window stretched over the duty phase would emit that block's head and finality moves a second time. That constraint is why beacon's `get_head` belongs to the advance rather than to the duties. Three orderings change, all of them for the better: lean's sync status and current-slot metric now read post-promote state rather than the state before its own store tick, beacon's sync status reads the head `get_head` just computed, and lean refreshes the justified and finalized gauges every tick as beacon already did, rather than only on import. The interval match, the aggregator snapshot and the XMSS key advance move verbatim. * refactor: simplify new block handler guard * refactor(blockchain): keep the tick the function it already was The previous commit unified the two ticks by inventing a shape for them: a `Tick` value and four phases to pass it through. One tick pipeline was the right call, but the shape was not the one this actor already had, and the interval grid is easier to follow written out in the order it happens than split across `begin_tick`, `advance_chain`, `run_duties` and `observe_tick`. So `on_tick` goes back to the function main has, and beacon is woven into it in place. Six edits carry it: the idempotency guard expresses this tick's position in whichever unit the store clock keeps (intervals on lean, Unix seconds on beacon); the empty-validator fail-fast asks first whether this is a lean store, since `head_state` panics on the other one; the sync-status and head-metric reads go through a `head_slot` accessor that decodes the right table; the aggregator flag goes through an `is_aggregator` accessor that answers false for a follower; the one `store::on_tick` line becomes a match, lean's call on one arm and beacon's `fork_choice::on_tick` plus `get_head` on the other; and the duty match is wrapped in a lean guard, arms untouched. The justified and finalized gauges stay in the tail for both chains. Beacon needs them there: its clock advance can pull up unrealized checkpoints at an epoch boundary, moving both without importing anything. Two properties of the code already here are why the rest needed no edit at all. `get_our_proposer` answers `None` on a follower, so the `is_proposer` line is correct unchanged, and a beacon tick fires on slot boundaries, so `interval` resolves to `BlockPublication`, whose arm is empty. The guard on the match is for the tick that lands late. * refactor(blockchain): give the interval duties their own function Weaving beacon into `on_tick` left the interval match nested inside a lean guard, indented a level away from where it reads best and sitting in the middle of a function that is otherwise a sequence of one-line steps. It is the one part of the tick that is a chain's duties rather than every chain's bookkeeping, so it moves out. `run_interval_duties` takes the interval, the slot and the aggregator flag the tick already resolved, and asks the lean question itself, so the call site stays the single line the match displaced. Its arms are unchanged and back at their original indentation. The safe-target metric stays in `on_tick`. It reports what `store::on_tick` did at interval 3 rather than anything a duty did, so it keeps its place after the call, with the chain guard the surrounding `if` used to give it. One thing improves in passing: the `let ChainDuties::Lean(lean) = .. else { return }` guards inside the arms now return from the duties function rather than from the whole tick, so a miss (unreachable, since the function checked already) can no longer skip the head, justified and finalized gauges or the key advance behind them. * docs(beacon): say that the follower now imports what it fetches The base branch's notes describe a wire that asks and advertises honestly but drops what it fetches, since it has no actor. Both are true there and neither is true here: fetched blocks reach the actor as `BlockSource::Sync`, and the by-root path is what resolves a gossiped block's missing parent rather than an unexercised guard. * refactor: simplify * perf(beacon): skip a block whose post-state the store already holds `fork_choice::on_block` does not short-circuit on a known root. It goes from cloning the parent state straight into `state_transition`, so every re-delivery of a block the store already has pays the whole import a second time. Measured on a mainnet follower 2026-09-08: 116 imports produced 79 distinct blocks, so 37 of them were duplicates, a third of the actor's import budget spent recomputing post-states it already held. The duplicate gaps were indistinguishable from first-pass gaps (6.1 to 7.7 seconds), which is how you tell a real re-import from a cheap no-op. Re-deliveries are ordinary, not pathological: a block reaches the actor from gossip, from a `BlocksByRange` response, and from the backward `BlocksByRoot` walk an orphan starts, and nothing upstream dedupes. Check for the post-state before anything else in `process_or_pend_block` and return, so a re-delivery costs a lookup. Its pending children are still collected: the root did import, whether or not this delivery is what imported it. Beacon-only, because lean's `store::on_block` has its own already-imported early return; the comment in `process_block` that claimed both did is corrected here too, since it is what made this look like a solved problem. * fix(beacon): recompute the head when a block arrives, not only on the tick `lean_head_slot` sat flat at the checkpoint-sync anchor for a follower's entire catch-up, while imports landed every few seconds. That is what "the head is not advancing" looked like on the dashboard, and it was not a reporting artifact: the head really was not moving. `fork_choice::on_block` computes no head, and `Store::beacon_head` only reads back whatever the last `fork_choice::get_head` recorded. So an import adds a block and a post-state and moves no head. The sole caller of `get_head` was the tick, which is one message per slot in the same mailbox as every arriving block; a follower catching up imports back-to-back and never drains that mailbox, so the tick did not run and the recorded head stayed at the anchor. `process_block` refreshed the gauge after every import, but from that same stale recorded value. Re-run fork choice at the end of `on_block`, the arrival boundary the store clock is already brought up to real time at, and republish the head there. Measured at ~96ms per head computation against imports costing seconds, and it is once per arrival rather than once per block in the cascade, matching how that boundary already treats the clock. The tick keeps a call of its own, which is still needed for a slot in which nothing arrived; both now go through one function. Publish `lean_current_slot` from the same place, since it had the same single writer: with both gauges only written by a starved tick, `lean_current_slot - lean_head_slot` was a difference between two stale numbers rather than the head lag every panel and alert reads it as. It comes from the wall clock, since the store clock only moves when the tick or an early arrival moves it; `process_block` already needed that reading for its head-recency gate, so the two now share `wall_clock_slot`. Observed on a mainnet follower on the previous two commits: 29 blocks imported to slot 15178365, head gauge still reading the anchor 15178336, current-slot gauge 6 slots behind wall clock, and zero blocks rejected. * Update crates/net/p2p/src/lib.rs * style: rustfmt
🗒️ 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