Skip to content

feat(p2p): per-peer fork download budget - #2133

Open
nullPointerEnjoyer wants to merge 11 commits into
masterfrom
feature/fork-download-budget
Open

nullPointerEnjoyer wants to merge 11 commits into
masterfrom
feature/fork-download-budget

Conversation

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a per-peer token budget limiting how many headers a peer can have us process per refill interval (post-IBD). Exhaustion defers processing: no ban, no disconnect, no storage drop; a scheduled header request retry (budget-subject, earliest-deadline, issued at most once per refill interval) recovers deferred data. Deferred lists also clear the stalling expectation, so a deferring peer is never punished for latency.
  • New ProtocolConfig knobs (node-local, not transmitted): max_fork_downloads_per_peer (default 2000), fork_download_refill_interval (default 600s). Setting the limit very high restores pre-change behavior.
  • Bounds per-peer header-processing work regardless of what a peer announces, while keeping all announced data fetchable. No wire change, no compatibility impact for older peers, no consensus surface.

Test plan

  • cargo test -p p2p: 323 tests pass, including 3 new network-sync tests (deferral without punishment + refill recovery, bounded variant announcements, IBD exemption)
  • 45+ serial runs of the new tests with 0 failures (retry robustness: loop-body deadline check, earliest-deadline re-arm, retry kept armed across unrelated successful lists)
  • ./do_checks.sh green; OCR review clean

Downloading and validating the blocks behind announced headers is an
expensive operation. A peer could previously make us perform an
unbounded amount of such work over time by repeatedly announcing
distinct header chains that don't extend our tip.

Introduce a token-bucket budget (capacity + refill interval, both
configurable via the p2p protocol config) that every non-empty header
list will be charged against; header lists received during the initial
block download are exempt so that node bootstrap can't be stalled.

No wire-level changes: the budget only gates our own block requests.
Charge every non-empty header list (including tip-anchored ones: a
miner can produce an unlimited number of distinct valid children of
our tip, so exempting them would leave the cost attack open) against
the peer's budget before downloading the announced blocks.

An exhausted budget defers the list: the peer is not punished (no ban
score, no disconnect; it hasn't violated the protocol), the list is
expected to arrive later via a scheduled header request retry that
fires once per refill interval and is subject to the budget itself.

Also stop requesting more headers from a peer that repeatedly sends
already-known header lists while claiming it may have more of them,
which would otherwise keep us issuing header requests forever at no
cost to it.
Cover the three behaviors of the budget: deferral without punishment
followed by recovery after a refill, budget-limited tip-anchored fork
variants, and the initial block download exemption.
Addresses an OCR review finding: keep the pre-existing ProtocolConfig
test literals stable so that adding a config field in the future
doesn't require touching them again.
Addresses an OCR review finding: keep the helper consistent with the
existing make_new_blocks helper style by holding the previous block as
a borrow instead of cloning it on every iteration.
The budget retry timer runs on the real tokio clock while refills are
driven by the mocked one; a long interval leaves the retry pending for
a long real time, which can interleave with the sync loops when the
test machine is under load.
Addresses OCR review findings: merge the duplicated refill-interval
rationale into one accurate comment and describe the actual fork
topology of the tip-anchored variants test, including the guarantee it
pins down (the budget is charged before the anchoring check, so lists
anchored at unknown blocks are deferred rather than punished).
The fork download budget retry had two weaknesses:

- The retry slept inside a select! branch with a variable duration,
  so the sleep was recreated on every loop iteration and could be
  postponed indefinitely by message traffic. Move the retry deadline
  check into the loop body and add an unconditional fixed-interval
  tick arm to guarantee wakeups when the peer is idle.

- A repeated deferral re-scheduled the retry further into the future,
  so announcements that arrive late could keep postponing the fetch.
  Keep the earliest scheduled retry instead, so a new deferral never
  postpones a retry the budget may already have recovered for.
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 2 issue(s) in this PR.

  • ✅ Successfully posted inline: 0 comment(s)
  • 📋 Routed to summary by policy: 2 comment(s)

performance · low

📄 p2p/src/sync/peer/block_manager.rs (L745-L745)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category performance)

This chainstate round-trip (is_initial_block_download) runs on every incoming header list, even when the budget is full and the check result is unchanged. Since headers arrive frequently during normal sync, consider caching the IBD flag locally (e.g. refreshed on sync-status changes already handled via handle_sync_status_change) or short-circuiting when fork_download_budget still has full capacity. The existing chainstate_handle.is_initial_block_download() in handle_header_request shows this pattern is already used elsewhere, so it's consistent — but here it's on the hottest inbound path.

💡 Suggested Change

Before:

if !self.chainstate_handle.call(|c| Ok(c.is_initial_block_download())).await? {

After:

// Cheap guard: skip the chainstate round-trip while the budget has full capacity.
if self.fork_download_budget.has_capacity()
    || !self.chainstate_handle.call(|c| Ok(c.is_initial_block_download())).await?
{

bug · low

📄 p2p/src/sync/peer/fork_download_budget.rs (L65-L71)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category bug)

A sub-second refill interval (e.g. Duration::from_millis(500)) is silently misinterpreted: as_secs() truncates to 0, which hits the degenerate-configuration branch and refills the bucket to full capacity on every call, effectively disabling the budget. Either round the interval up to at least one second (cmp::max(Duration::from_secs(1), self.refill_interval)) or compute fractional rates in milliseconds/sub-second units so short intervals still enforce a bounded rate.

💡 Suggested Change

Before:

        let interval_secs = self.refill_interval.as_secs();
        if interval_secs == 0 {
            // Degenerate configuration: refill instantly.
            self.tokens = self.capacity;
            self.last_refill = now;
            return;
        }

After:

        let interval_secs = self
            .refill_interval
            .max(Duration::from_secs(1))
            .as_secs();
        if interval_secs == 0 {
            // Degenerate configuration: refill instantly.
            self.tokens = self.capacity;
            self.last_refill = now;
            return;
        }

Comment thread p2p/src/sync/peer/block_manager.rs Outdated
The retry poll tick in the block sync manager event loop ran
unconditionally, waking the loop every second even when no fork budget
retry was pending. That exposed a pre-existing latent issue under mocked
clocks: stalling checks then ran on a clock that jumped forward, e.g. in
the ping functional test, disconnecting a peer on a stalling deadline no
real activity would have triggered. Arm the tick branch only while a
retry is pending, mirroring the existing stalling select arm, so the
loop's wake behavior is unchanged otherwise.

Also charge at most the configured fork download limit for one header
list: a list larger than the budget capacity could never be paid for by
even a full bucket and would be deferred forever. A capacity of 0 now
disables the budget (the charge is always payable).
Comment thread p2p/src/sync/peer/block_manager.rs Outdated
A pending fork budget retry whose deadline has passed is only cleared
when the node provides the Blocks service to the peer. When it doesn't,
the schedule stayed armed forever, making the retry tick wake the event
loop every poll interval without any progress. Clear the schedule in
that case too; a future deferral re-arms it, and the deferred data is
recovered when the peer re-announces it, as before the retry mechanism
existed.
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