Repository navigation
fix(fork_choice): walk the filtered block tree with an explicit stack - #758
MegaRedHand wants to merge 5 commits into
Conversation
…ead of scanning the whole block index at every node of filter_block_tree. The scan ran once per node of the justified subtree over the full live window since finality, so the head walk was quadratic in the number of unfinalized blocks, exactly what a finality stall or a range catch-up grows. walk_head and invalidate_subtree already build the map this way; this brings the filter in line with them. A new test keeps the spec-literal scan as a reference and checks the two agree on random trees, including ones where a branch is filtered out.
filter_block_tree recursed once per block below the justified checkpoint. That depth grows by a block every slot for as long as the chain does not justify, and get_head runs on the chain actor's thread, which spawned-concurrency starts with std::thread::spawn and so the default 2 MiB stack. On that stack the recursive walk overflowed between 5,500 and 6,000 blocks of depth (release-fast): about 18 to 20 hours of a stall with no new justification. A stack overflow aborts the process, and the next head computation after a restart walks the same tree. The walk now keeps its own stack of the path from the justified block to the block in hand. It visits the same blocks in the same order, asserts `block_root in store.blocks` at the same points and returns the same tree. The leaf check moved unchanged into is_viable_leaf, which returns the verdict and leaves the insertion to the walk.
🤖 Kimi Code ReviewI'll review this PR which converts a recursive SummaryThe PR correctly addresses a critical stack overflow vulnerability in the fork choice Critical Issues1. Potential Logic Bug:
|
| Aspect | Rating | Notes |
|---|---|---|
| Correctness | ✅ Good | Iterative transformation preserves semantics |
| Security | ✅ Improved | Eliminates DoS via stack overflow |
| Performance | ✅ Neutral | Same complexity, heap instead of stack |
| Safety | ✅ Good | No unsafe, proper error handling |
| Maintainability | Frame struct is clear; comments could be sharper |
|
| Testing | ✅ Good | Deep chain test validates the fix |
Overall: Approve with minor suggestions. The core fix is correct and important. Address the comment clarity and consider the test stack size adjustment.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewAutomated review by OpenAI Codex |
🤖 Claude Code ReviewReview:
|
#746 was squash-merged, so this branch's copy of its commits no longer matches main. The tree is main plus only this branch's own commit.
…#758) filter_block_tree recursed once per block below the justified checkpoint. That depth grows by a block every slot for as long as the chain does not justify, and get_head runs on the chain actor's thread, which spawned-concurrency starts with std::thread::spawn and so the default 2 MiB stack. On that stack the recursive walk overflowed between 5,500 and 6,000 blocks of depth (release-fast): about 18 to 20 hours of a stall with no new justification. A stack overflow aborts the process, and the next head computation after a restart walks the same tree. The walk now keeps its own stack of the path from the justified block to the block in hand. It visits the same blocks in the same order, asserts `block_root in store.blocks` at the same points and returns the same tree. The leaf check moved unchanged into is_viable_leaf, which returns the verdict and leaves the insertion to the walk. Cherry-picked from fork-choice-iterative-filter at de7bb98 (b102e35; the branch's other commits are #746, already on nightly, and merges of main). Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
🗒️ Description / Motivation
filter_block_treerecursed once per block below the justified checkpoint. While the chain does not justify, that depth grows by one block every slot, andget_headruns on the chain actor's thread: spawned-concurrency'sBackend::Threadstarts it withstd::thread::spawn, so it gets the default 2 MiB stack (nothing in the node sets a stack size).On that stack the recursive walk overflowed between 5,500 and 6,000 blocks of depth (
release-fast, measured on an M3 Pro and on a Ryzen 7 9700X). On mainnet that is about 18 to 20 hours of a stall in which nothing new is justified. A stack overflow aborts the process, and the next head computation after a restart walks the same tree.Found while timing #746 on synthetic stall trees (results in a comment there). #746 kept the recursion; its parent-to-children map is what makes each step of the explicit stack a lookup rather than an index scan.
What Changed
crates/blockchain/state_transition/src/beacon/fork_choice.rsfilter_block_treekeeps its own stack of the path fromblock_rootto the block in hand (one frame per block: its index entry, its children, the next child to visit, and whether any child so far is viable) instead of recursing. Same signature.is_viable_leaf, which returns the verdict and leaves the insertion to the walk.the_head_walk_does_not_overflow_a_default_stack_on_a_stall_deep_chain.Correctness / Behavior Guarantees
children_ofslice order), so the same filtered tree and return value.block_root in store.blocksassertion runs when a block is entered, where each recursive call made it, so a pruned justified root still fails the walk with the same error (update_head_from_fork_choicelogs it and keeps the head).Vecwith one small frame per level of depth, instead of one stack frame per level.Tests Added / Run
the_head_walk_does_not_overflow_a_default_stack_on_a_stall_deep_chain. It builds a 20,000-block chain with nothing justified past the anchor and runsget_filtered_block_treeandget_head_nodeon a thread with an explicit 2 MiB stack. With perf(fork_choice): build the parent-to-children map once in get_filtered_block_tree #746's recursive walk it aborts with a stack overflow; with this change it passes in 0.07 s. A stack overflow takes down the whole test binary rather than failing one test, which the test's doc comment says.the_filtered_tree_matches_the_spec_literal_children_scan_on_random_treescompares the walk with the recursive spec-literal reference on 100 random trees, including trees where branches are filtered out.make fmt,make lint: cleanmake test-consensus: 1,325 passed, 0 failed--test beacon_spec_tests --features beacon-spec-tests -- fork_choice): 206 passedNot checked: the overflow depth under the
releaseprofile, where LTO can change frame sizes.Related Issues / PRs
✅ Verification Checklist
make fmt: cleanmake lint(clippy with-D warnings): cleanmake test(test-consensusplustest-node, atrelease-fast):test-consensusrun locally;test-nodeleft to CI (unrelated to this change)