Skip to content

Make BlockStager the single staged-body store - #1156

Open
metaphorics wants to merge 2 commits into
decomplexify/T00from
decomplexify/R01
Open

metaphorics wants to merge 2 commits into
decomplexify/T00from
decomplexify/R01

Conversation

@metaphorics

@metaphorics metaphorics commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

What and why

The block download window kept its own copy of every staged body: a
received map with a byte total and a stored height per body. The
BlockStager already held the same bodies. About fourteen update sites
kept the two in step, and two repair passes fixed the copy afterwards.

The copy caused a live defect. A body that arrived before its header, or
that no connection had requested, was recorded at height 0. When that entry
was pruned, evicted, or escalated, the retry path lowered the request cursor
to min(cursor, 0), so the cursor collapsed to genesis.

This PR makes BlockStager the only staged-body store and the block tree the
only source of a staged body's height. It also closes three sync findings.
Each is a place where this node differed from Bitcoin Core 31.1:

  • BLK-06: an unrequested block body always staged, and Core stages an
    unrequested body only when AcceptBlock accepts it with
    fRequested == false (src/validation.cpp:4306-4357).
  • BLK-07: staging an unrequested body could evict bodies that were
    requested and still in flight, and Core never lets an unrequested block
    displace requested work (src/validation.cpp:4327-4353).
  • BLK-08: a branch retarget released the losing branch's pending
    requests but kept its staged bodies, and Core requests only blocks on the
    active branch (src/net_processing.cpp:3104-3135).

Commits

  1. Make BlockStager the single staged-body store
  2. Gate unrequested block bodies like Core

Each commit builds, passes clippy with -D warnings, and passes the full
bitcoin-rs-p2p test suite.

Demolition manifest

Removed from crates/p2p/src/download_window.rs:

  • the received: HashMap<Hash256, ReceivedBlock> field, the
    ReceivedBlock { height, bytes } struct, and the received_bytes field
  • received_len, received_height, update_received_height,
    reconcile_received_heights, mark_received_applied,
    drop_received_for_retry, drop_for_retry, discard_received,
    remove_received, mark_received, and the test helper mark_applied
  • the 0-height write for a delivery with no pending request

Removed from the sync executor:

  • reconcile_staged_received_heights and both of its call sites
    (sync/headers.rs, sync/receive.rs)
  • the mark_received_applied loop in sync/commit.rs, the height-update
    prune loop and the stored-height pin in sync/receive.rs, and the
    window-mirror calls in sync/branches.rs and sync/peers.rs

No re-export, alias, forwarding function, or compatibility path keeps any
removed symbol.

Replacement

  • BlockStager::staged_hashes() yields the identity of every staged body
    and no heights.
  • The window's capacity, headroom, scan-limit, request, stall, and retarget
    methods take &BlockStager (or &mut BlockStager at the retarget) and
    read staged count, bytes, and membership from it.
  • DownloadWindow::requeue_for_retry(hash, height: Option<u32>) replaces
    the drop paths. It releases the pending and lowers the cursor to the lower
    of the given tree height and the height the released pending recorded.
    With neither known, it does not move the cursor.
  • The stall predicate's "delivered successor waiting" term now asks the
    tree for each staged body's height.

Behavior changes

  1. Cursor collapse fixed. A staged body of unknown height that is
    pruned, evicted, or escalated no longer rewinds the request cursor.
    Pinned by unsolicited_staged_body_never_rewinds_request_cursor.
  2. Retarget purges staged bodies. A retarget discards every staged body
    that is not on the new request branch. Before, only the window's copy was
    dropped and the bodies stayed until they expired. Pinned by
    retarget_purges_staged_off_branch_bodies.
  3. Unrequested bodies are gated. A body that no connection has in flight
    stages only when it is on the header tip's branch, has at least the
    applied tip's chainwork, the header tip meets the network's minimum chain
    work, and it is at most 288 blocks above the applied tip. A body whose
    header is not yet known keeps the existing missing-header path. Pinned by
    unrequested_body_admission_matches_core_acceptance.

Base failure and head pass for the three new tests, run at the base commit
with the tests added unchanged and one test-only cursor accessor:

Test Base Head
unsolicited_staged_body_never_rewinds_request_cursor FAIL: cursor 0, expected 5 pass
retarget_purges_staged_off_branch_bodies FAIL: 1 losing body still staged pass
unrequested_body_admission_matches_core_acceptance FAIL: the body 290 blocks ahead staged pass

The request path already clamps each scan at the next required height, so
at the base the collapsed cursor could not make a getdata name an
already-applied block. The defect is the cursor state itself, and the test
pins that state through a test-only accessor.

Persisted data, outputs, and configuration

  • Schema bumps: none. No persisted format changes.
  • Output changes: none. All node.sync.* metric names and gauge sources stay
    the same (telemetry.rs already read the stager), and RPC and wire
    responses are unchanged.
  • Configuration changes: none.

Tests

  • New: the three tests above.
  • Rewritten to the stager: every window unit test that seeded the mirror
    now stages a real body in a paired stager. The stall fixtures
    window_blocked_on_staller and limit_cycle_window_state now return that
    stager. Test hashes resolve through a 256-header test chain, so the tree
    can place every staged test body.
  • Deleted: assertions that only checked the mirror
    (witness_staging_gate.rs, validation_1.rs, head_sync.rs,
    behavior_2.rs, behavior_5.rs) and the height-0 pin
    untracked_delivery_records_tree_height, which the new cursor test
    replaces.
  • tick_caps_requests_at_staged_byte_headroom now skips header traffic,
    because its staged fixture bodies carry headers with unknown parents and
    the tick also sends a recovery getheaders.

Documentation

  • The process-level e2e tests live_head_carried_e2e.rs and
    head_sync_e2e.rs keep their wire assertions. Their comments no longer
    describe a height-0 sentinel or a repair pass.

Verification

Lane exit codes at the head commit:

Lane Exit code
fmt 0
clippy 0
test-crates 0
test-binary 0
test-workspace 0

Benchmarks: sync_pipeline apply proxies (cargo bench -p bitcoin-rs-node --bench sync_pipeline --features kernel), 12 alternating runs of the parent commit 4fa20314c5 and this
head 94c530887c. Median (min to max), in ms:

Proxy Parent Head Median change
sync_pipeline_apply_proxy 170.1 (56.1 to 209.9) 172.1 (50.5 to 199.2) +1.2%
sync_pipeline_apply_signed_spend_proxy 844.8 (394.0 to 921.3) 845.0 (388.0 to 941.5) +0.0%
sync_pipeline_apply_spend_heavy_proxy 772.6 (343.1 to 853.0) 800.2 (344.9 to 896.9) +3.6%

The host was not idle: parallel builds ran during the runs, and the
slowest run of a proxy took more than four times as long as its fastest. At
that noise level the medians cannot show a regression or its absence.

The criterion group deterministic_initial_sync_proxy_deep_headers_pure_128_blocks
panics at crates/node/benches/sync_pipeline.rs:754 ("expected getdata:
receiving on an empty channel") at this head, at the parent, and at
origin/main 748b784, so it has no numbers. The panic is not caused by
this change.

Grep gates: every removed name returns zero hits in crates/p2p/src and
bin/bitcoin-rs/tests. The self.received_bytes pattern still matches 10
lines, all in block_stager.rs. That is the stager's own byte total, and it
has the same count at the base.

CONSTRAINTS.md rows

  • CL-08 (single owners): true. The stager is the only staged-body store.
  • CL-13 (no old readers or shims): true. The removed set is listed above.
  • CL-14 (bounded budgets): true. The stager's count and byte budgets bound
    every staged body, and the admission gate only removes staging paths.
  • CL-19 (performance gain): no gain is claimed.
  • CL-20 (no regression): UNMEASURED. The benchmark host was not idle, and the spread is far above
    the 3% cap. An idle-host rerun is owed before this row can be true.
  • CL-21 (no unapproved surface): true. The changed public signatures are in
    bitcoin-rs-p2p, and every consumer is in this repository and migrated.
  • CL-23 (honest failure): true. Unmeasured cells are marked UNMEASURED.

Summary by cubic

Makes BlockStager the single store for staged bodies and the block tree the only source of a staged body's height, fixing a sync bug where a body of unknown height could rewind the request cursor to genesis.

Bug Fixes

  • A pruned, evicted, or escalated body of unknown height no longer collapses the request cursor to genesis.
  • A branch retarget now discards staged bodies that are not on the new branch instead of leaving them until expiry.
  • New tests pin the cursor fix, the retarget purge, and the admission gate.

Refactors

  • The download window dropped its received mirror and now reads staged count, bytes, and membership from the stager.
  • requeue_for_retry replaces the old drop-and-reconcile paths.
  • Unrequested bodies now stage only when Core's AcceptBlock would process them (active branch, at least the applied tip's chainwork, within 288 blocks of it); requested bodies are unaffected.

Written for commit 94c5308. Summary will update on new commits.

Review in cubic

The download window kept a shadow copy of every staged body: a
received map with its own byte total and a stored height. A body
that arrived before its header, or without a pending request, was
recorded at height 0. When that entry was later pruned, evicted, or
escalated, the retry path lowered the request cursor to 0, so the
cursor collapsed to genesis. Fourteen update sites kept the mirror in
step with the stager, and two repair passes rewrote the 0 heights
once the tree could resolve them.

Delete the mirror. The window now reads staged identity, count, and
bytes from the BlockStager it is handed, and the block tree is the
only source of a staged body's height. requeue_for_retry replaces
the drop paths: it releases the pending and lowers the cursor only to
a real tree height, or to the height the released pending recorded,
so a body of unknown height never moves the cursor.

A new regression test delivers an unsolicited body the tree cannot
place, lets expiry prune it, and asserts the cursor did not move.
Two staging defects remained once the stager owned every staged body.

A branch retarget released the losing branch's pending requests but
left its staged bodies in the stager until they expired, so the freed
capacity was not real, and a late delivery from the old branch staged
again beside them.

Any delivered body staged, requested or not. An unrequested body far
ahead of the chain, or on a side branch, could fill the stager and
evict bodies that were requested and in flight.

The retarget now discards every staged body that is not on the new
request branch. A body that no connection has in flight now stages
only when Core's AcceptBlock would process it with fRequested false
(validation.cpp:4327-4353): it is on the header tip's branch, has at
least the applied tip's work, the header tip meets the minimum chain
work, and it is at most 288 blocks above the applied tip. A body whose
header is not yet known keeps the existing missing-header path.
@coderabbitai

coderabbitai Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: eb56991a-fc20-46f9-b4cf-1ec32d95a399

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-24T07:25:54.238455Z 94c5308 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR successfully consolidates block body management by making BlockStager the single source of truth for staged bodies, removing the redundant received map and associated height tracking from DownloadWindow. The refactoring resolves the cursor collapse defect and aligns unrequested body admission with Bitcoin Core 31.1.

The changes are well-structured with:

  • Clear separation of concerns: BlockStager owns staging, BlockTree owns heights
  • Comprehensive test coverage including three new tests pinning the fixed behaviors
  • Detailed migration path with all removed symbols documented
  • Proper lock ordering maintained (tree → scheduler)

The implementation correctly handles the tree-owned height resolution pattern throughout, with appropriate fallback behavior when heights cannot be resolved (no cursor rewind instead of collapse to genesis).

No blocking issues identified.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 23 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/p2p/src/sync/commit.rs">

<violation number="1" location="crates/p2p/src/sync/commit.rs:209">
P1: Permanent window failures invalidate the failed block itself, but this unconditional retry requeues it at its tree height after `purge_invalidated` intentionally avoided a cursor rewind. That moves `next_request_height` back onto a permanently invalid branch and can repeatedly rescan or stall there; only requeue retryable failure dispositions.</violation>
</file>

<file name="crates/p2p/src/sync/tests/head_sync.rs">

<violation number="1" location="crates/p2p/src/sync/tests/head_sync.rs:270">
P3: The assertion was correctly removed, but the comment directly above it still claims "the window's delivery record must be dropped outright too: keeping it would re-queue a body whose header can never admit (C19)." The download window no longer keeps any delivery/received record (there is no `window.received_len()` anymore), so this half of the comment now describes removed behavior and will mislead future readers. Trim the comment to only the staged-body discard rationale.</violation>
</file>

<file name="crates/p2p/src/sync/tests.rs">

<violation number="1" location="crates/p2p/src/sync/tests.rs:1266">
P2: This regression test does not cover the documented chainwork gates: Regtest makes the minimum-work check vacuous, and every active-branch candidate is above the applied tip. Add cases with a nonzero minimum-work floor and a candidate whose work is below the applied tip so those clauses cannot regress silently.</violation>
</file>

<file name="crates/p2p/src/sync/tests/transitions_2.rs">

<violation number="1" location="crates/p2p/src/sync/tests/transitions_2.rs:174">
P3: The late-delivery call's return value is unchecked. `buffer_received_block_chunk` returns the count of bodies staged, found already staged, or rejected for a failed body/header binding; unrequested bodies that fail admission are discarded and not counted. Asserting `0` here would pin that the off-branch body was discarded rather than merely asserting the observable stager state stayed empty.</violation>
</file>

<file name="crates/p2p/src/sync/tests/transitions_4.rs">

<violation number="1" location="crates/p2p/src/sync/tests/transitions_4.rs:325">
P3: The test acquires the scheduler lock before the block-tree read lock, inverting the lock order used in production. `sync/peers.rs` (lines ~194-196) documents "Lock order tree -> scheduler" and takes `block_tree().read()` first because the stall predicate resolves staged hashes against tree heights. Swap the two lines to acquire the tree guard first, so the test mirrors the production convention and stays safe if the tree ever gains a concurrent writer.</violation>
</file>

<file name="crates/p2p/src/sync/receive.rs">

<violation number="1" location="crates/p2p/src/sync/receive.rs:69">
P3: This prune path re-acquires the block-tree read lock once per dropped body. Take the guard once above the loop and resolve all heights inside it, as the previous code did.</violation>

<violation number="2" location="crates/p2p/src/sync/receive.rs:480">
P3: The byte-budget-refused arm passes `None` to `requeue_for_retry` without consulting the tree, unlike the count-eviction arm which requeues at the tree-resolved height. A refused unsolicited body therefore has no pending, no staged entry, and no cursor move, so it is never fetched again unless stall machinery later rewinds the cursor beneath it. If that body is the canonical next chain, this just delays progress; if the permanent drop is intended, the inconsistency with the Memory arm is worth a comment, otherwise resolve the tree height here as well.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

self.scheduler
.lock()
.window
.requeue_for_retry(&hash, failed_height);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Permanent window failures invalidate the failed block itself, but this unconditional retry requeues it at its tree height after purge_invalidated intentionally avoided a cursor rewind. That moves next_request_height back onto a permanently invalid branch and can repeatedly rescan or stall there; only requeue retryable failure dispositions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/commit.rs, line 209:

<comment>Permanent window failures invalidate the failed block itself, but this unconditional retry requeues it at its tree height after `purge_invalidated` intentionally avoided a cursor rewind. That moves `next_request_height` back onto a permanently invalid branch and can repeatedly rescan or stall there; only requeue retryable failure dispositions.</comment>

<file context>
@@ -195,15 +195,18 @@ impl BlockSync {
+                self.scheduler
+                    .lock()
+                    .window
+                    .requeue_for_retry(&hash, failed_height);
             }
             self.advance_expected_apply_cache(&applied_hashes, failed_hash.is_some());
</file context>

/// 288 blocks above the applied tip. Requested bodies are not gated.
#[test]
fn unrequested_body_admission_matches_core_acceptance() -> Result<(), Box<dyn std::error::Error>> {
let (mut tree, blocks) = mined_chain(300, 0)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This regression test does not cover the documented chainwork gates: Regtest makes the minimum-work check vacuous, and every active-branch candidate is above the applied tip. Add cases with a nonzero minimum-work floor and a candidate whose work is below the applied tip so those clauses cannot regress silently.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/tests.rs, line 1266:

<comment>This regression test does not cover the documented chainwork gates: Regtest makes the minimum-work check vacuous, and every active-branch candidate is above the applied tip. Add cases with a nonzero minimum-work floor and a candidate whose work is below the applied tip so those clauses cannot regress silently.</comment>

<file context>
@@ -1244,6 +1257,66 @@ fn apply_fixture_block(sync: &BlockSync, block: Block) -> Result<(), Box<dyn std
+/// 288 blocks above the applied tip. Requested bodies are not gated.
+#[test]
+fn unrequested_body_admission_matches_core_acceptance() -> Result<(), Box<dyn std::error::Error>> {
+    let (mut tree, blocks) = mined_chain(300, 0)?;
+    let fork_parent = tree
+        .lookup(Hash256::from(blocks[4].block_hash()))
</file context>

"the inadmissible body must be discarded, not retried"
);
assert_eq!(
sync.scheduler.lock().window.received_len(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The assertion was correctly removed, but the comment directly above it still claims "the window's delivery record must be dropped outright too: keeping it would re-queue a body whose header can never admit (C19)." The download window no longer keeps any delivery/received record (there is no window.received_len() anymore), so this half of the comment now describes removed behavior and will mislead future readers. Trim the comment to only the staged-body discard rationale.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/tests/head_sync.rs, line 270:

<comment>The assertion was correctly removed, but the comment directly above it still claims "the window's delivery record must be dropped outright too: keeping it would re-queue a body whose header can never admit (C19)." The download window no longer keeps any delivery/received record (there is no `window.received_len()` anymore), so this half of the comment now describes removed behavior and will mislead future readers. Trim the comment to only the staged-body discard rationale.</comment>

<file context>
@@ -266,11 +266,6 @@ fn staged_body_with_permanently_inadmissible_header_is_discarded()
-        "the discarded body's window record must not linger and re-queue"
-    );
     Ok(())
 }
 
</file context>

);

let mut late = vec![crate::InboundBlock::from_decoded(losing2)];
sync.buffer_received_block_chunk(&mut late, None);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The late-delivery call's return value is unchecked. buffer_received_block_chunk returns the count of bodies staged, found already staged, or rejected for a failed body/header binding; unrequested bodies that fail admission are discarded and not counted. Asserting 0 here would pin that the off-branch body was discarded rather than merely asserting the observable stager state stayed empty.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/tests/transitions_2.rs, line 174:

<comment>The late-delivery call's return value is unchecked. `buffer_received_block_chunk` returns the count of bodies staged, found already staged, or rejected for a failed body/header binding; unrequested bodies that fail admission are discarded and not counted. Asserting `0` here would pin that the off-branch body was discarded rather than merely asserting the observable stager state stayed empty.</comment>

<file context>
@@ -102,6 +136,57 @@ fn retargeting_pending_requests_drops_losing_branch_hashes()
+    );
+
+    let mut late = vec![crate::InboundBlock::from_decoded(losing2)];
+    sync.buffer_received_block_chunk(&mut late, None);
+    let scheduler = sync.scheduler.lock();
+    assert_eq!(
</file context>
Suggested change
sync.buffer_received_block_chunk(&mut late, None);
assert_eq!(
sync.buffer_received_block_chunk(&mut late, None),
0,
"the late losing-branch delivery must be discarded, not staged"
);

Comment on lines +325 to +326
let mut scheduler = sync.scheduler.lock();
let tree = sync.chain.block_tree().read();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The test acquires the scheduler lock before the block-tree read lock, inverting the lock order used in production. sync/peers.rs (lines ~194-196) documents "Lock order tree -> scheduler" and takes block_tree().read() first because the stall predicate resolves staged hashes against tree heights. Swap the two lines to acquire the tree guard first, so the test mirrors the production convention and stays safe if the tree ever gains a concurrent writer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/tests/transitions_4.rs, line 325:

<comment>The test acquires the scheduler lock before the block-tree read lock, inverting the lock order used in production. `sync/peers.rs` (lines ~194-196) documents "Lock order tree -> scheduler" and takes `block_tree().read()` first because the stall predicate resolves staged hashes against tree heights. Swap the two lines to acquire the tree guard first, so the test mirrors the production convention and stays safe if the tree ever gains a concurrent writer.</comment>

<file context>
@@ -321,11 +321,18 @@ fn stall_eviction_does_not_disconnect_replacement_connection()
-        Instant::now() + Duration::from_millis(150),
-    );
+    let selected = {
+        let mut scheduler = sync.scheduler.lock();
+        let tree = sync.chain.block_tree().read();
+        let state = &mut *scheduler;
</file context>
Suggested change
let mut scheduler = sync.scheduler.lock();
let tree = sync.chain.block_tree().read();
let tree = sync.chain.block_tree().read();
let mut scheduler = sync.scheduler.lock();

Comment on lines +69 to 79
let requeues: Vec<(Hash256, Option<u32>)> = dropped
.iter()
.filter_map(|dropped| {
let node_id = tree.lookup(dropped.hash)?;
tree.node(node_id)
.ok()
.map(|node| (dropped.hash, node.height))
.map(|dropped| {
let height = {
let tree = self.chain.block_tree().read();
tree.lookup(dropped.hash)
.and_then(|node_id| tree.node(node_id).ok())
.map(|node| node.height)
};
(dropped.hash, height)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This prune path re-acquires the block-tree read lock once per dropped body. Take the guard once above the loop and resolve all heights inside it, as the previous code did.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/receive.rs, line 69:

<comment>This prune path re-acquires the block-tree read lock once per dropped body. Take the guard once above the loop and resolve all heights inside it, as the previous code did.</comment>

<file context>
@@ -62,24 +63,24 @@ impl BlockSync {
+            // The tree owns heights: a pruned body requeues at its tree
+            // height, or without a cursor move when the tree cannot resolve
+            // it (the old 0-sentinel rewind to genesis is unrepresentable).
+            let requeues: Vec<(Hash256, Option<u32>)> = dropped
                 .iter()
-                .filter_map(|dropped| {
</file context>
Suggested change
let requeues: Vec<(Hash256, Option<u32>)> = dropped
.iter()
.filter_map(|dropped| {
let node_id = tree.lookup(dropped.hash)?;
tree.node(node_id)
.ok()
.map(|node| (dropped.hash, node.height))
.map(|dropped| {
let height = {
let tree = self.chain.block_tree().read();
tree.lookup(dropped.hash)
.and_then(|node_id| tree.node(node_id).ok())
.map(|node| node.height)
};
(dropped.hash, height)
})
let requeues: Vec<(Hash256, Option<u32>)> = {
let tree = self.chain.block_tree().read();
dropped
.iter()
.map(|dropped| {
let height = tree
.lookup(dropped.hash)
.and_then(|node_id| tree.node(node_id).ok())
.map(|node| node.height);
(dropped.hash, height)
})
.collect()
};

window.drop_for_retry(&dropped.hash);
// Count-evicted before staging: release what the
// window holds without a cursor rewind.
window.requeue_for_retry(&dropped.hash, None);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The byte-budget-refused arm passes None to requeue_for_retry without consulting the tree, unlike the count-eviction arm which requeues at the tree-resolved height. A refused unsolicited body therefore has no pending, no staged entry, and no cursor move, so it is never fetched again unless stall machinery later rewinds the cursor beneath it. If that body is the canonical next chain, this just delays progress; if the permanent drop is intended, the inconsistency with the Memory arm is worth a comment, otherwise resolve the tree height here as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/p2p/src/sync/receive.rs, line 480:

<comment>The byte-budget-refused arm passes `None` to `requeue_for_retry` without consulting the tree, unlike the count-eviction arm which requeues at the tree-resolved height. A refused unsolicited body therefore has no pending, no staged entry, and no cursor move, so it is never fetched again unless stall machinery later rewinds the cursor beneath it. If that body is the canonical next chain, this just delays progress; if the permanent drop is intended, the inconsistency with the Memory arm is worth a comment, otherwise resolve the tree height here as well.</comment>

<file context>
@@ -450,15 +470,14 @@ impl BlockSync {
-                        window.drop_for_retry(&dropped.hash);
+                        // Count-evicted before staging: release what the
+                        // window holds without a cursor rewind.
+                        window.requeue_for_retry(&dropped.hash, None);
                         retry_count = retry_count.saturating_add(1);
                         tracing::warn!(%hash, "block sync: received block buffer full; dropping block for retry");
</file context>

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