Skip to content

fix(consensus): recover from Finalize-stall when a FinalizeBlock guard aborts apply#817

Draft
satyakwok wants to merge 2 commits into
mainfrom
fix/finalize-stall-recovery
Draft

fix(consensus): recover from Finalize-stall when a FinalizeBlock guard aborts apply#817
satyakwok wants to merge 2 commits into
mainfrom
fix/finalize-stall-recovery

Conversation

@satyakwok

@satyakwok satyakwok commented Jun 16, 2026

Copy link
Copy Markdown
Member

Draft — consensus-path liveness change. Needs review + a real testnet bake before deploy. No regression test yet (see below).

Problem

When the BFT engine reaches FinalizeBlock for height N, the driver normally applies block N, which advances current_height to N and lets need_new_round (bft.height() <= current_height) start the next round. But the FinalizeBlock arms are guard-heavy — split-brain, hash-mismatch, and add_block errors can break out without applying block N. The engine is then parked at height N, phase=Finalize, while the chain stays at N-1:

  • need_new_round can't reset it (bft.height()(N) > current_height(N-1))
  • Finalize has no timeout (step returns a zero-duration → BftAction::Wait)

So the engine spins on phase timeout finalize indefinitely, only un-sticking when a peer's block N arrives via gossip and advances the chain. When the gossip mesh is degraded that wait is minutes — the burst-then-stall cadence.

Fix

At the need_new_round site, detect the stuck state — engine height > current_height and phase == Finalize and stuck longer than FINALIZE_STALL_RESET (5s) — and call new_height(next_height, …) to re-run consensus for the unapplied height.

Adds BftEngine::phase_elapsed() (the engine already tracks phase_start) for the detection.

Safety

  • No double-apply — the existing bc.height() >= height skip-guard in the FinalizeBlock arms no-ops a re-finalize if the block already landed via gossip.
  • No fork — a re-proposal still requires 2/3 precommits; this changes liveness only, not vote/commit rules.
  • Won't fire on healthy finalizes — the 5s + Finalize + ahead-of-chain gate only matches a genuine stall (normal finalize completes in ms).

Not done / caveats

  • No regression test yet. The recovery lives in the async driver loop; a proper test needs an integration harness that forces a finalize-abort and asserts the engine advances within a round instead of parking. That's follow-up before merge.
  • The 5s threshold is a starting value to tune against live behavior.
  • This is a recovery/mitigation — it un-sticks the deadlock but does not address why finalize aborts (determinism + guard hits); pairs with the separate gossip-mesh work.

Summary by CodeRabbit

  • Bug Fixes

    • Added recovery mechanism to prevent the BFT consensus engine from remaining stuck in the finalize phase for extended periods. When detected, the engine now automatically reinitializes consensus to continue processing.
  • Tests

    • Added unit test to verify phase timing tracking is accurate after consensus initialization.

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…d aborts apply

A FinalizeBlock guard (split-brain / hash-mismatch / add_block error) can break
out without applying block N, parking the engine at height N phase=Finalize while
the chain stays at N-1. need_new_round cannot reset it (bft.height N >
current_height N-1) and Finalize has no timeout, so the engine spins on
"phase timeout finalize" until a peer's block N arrives via gossip and advances
the chain — minutes when the mesh is degraded.

Detect the engine parked in Finalize one height ahead of the chain past a 5s
threshold and reset it via new_height() to re-run consensus for the unapplied
height. Safe: the bc.height() >= height skip-guard in the FinalizeBlock arms
no-ops a re-finalize if the block already landed (no double-apply), and a
re-proposal still needs 2/3 precommits, so safety is preserved.

Adds BftEngine::phase_elapsed() for the stall detection.
phase_elapsed feeds the finalize-stall recovery in main.rs. Add a deterministic
(no-sleep) unit test asserting it is tiny right after construction and after
new_height, which also satisfies the codecov patch gate (the recovery block in
main.rs is under the bin/ codecov-ignore).
@satyakwok satyakwok force-pushed the fix/finalize-stall-recovery branch from a3df02e to 3487641 Compare June 21, 2026 18:33
@satyakwok satyakwok marked this pull request as ready for review June 21, 2026 18:34
@github-actions github-actions Bot enabled auto-merge (squash) June 21, 2026 18:34
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

A finalize-stall liveness recovery mechanism is added to the BFT consensus engine. A new public phase_elapsed() method is exposed on BftEngine, returning the wall-clock Duration since the engine entered its current phase. In the validator main loop, a new constant FINALIZE_STALL_TIMEOUT (5 seconds) and a guard block are introduced: when the engine is not already reinitializing, is ahead of the chain height, is parked in BftPhase::Finalize, and has exceeded the timeout, it logs a warning and calls bft.new_height(next_height, total_active_stake) to force consensus re-execution for the unapplied height.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • sentrix-labs/sentrix#769: Modifies the same BFT consensus loop in bin/sentrix/src/main.rs around the FinalizeBlock path, including changes to speculative N+1 proposal building and sync triggering that interact with the same height-tracking logic this PR guards against stalling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description provides comprehensive context but deviates significantly from the repository template, which is Solidity/Forge-focused, not applicable to this Rust consensus engine change. Clarify whether the repository template applies to this consensus crate. If not, the current description is detailed and acceptable; if the template is mandatory, align with its structure.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: a fix for a Finalize-stall recovery mechanism triggered when FinalizeBlock guards abort block application.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/finalize-stall-recovery

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 and usage tips.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bin/sentrix/src/main.rs`:
- Around line 1970-1987: The finalize-stall recovery path calls
bft.new_height(next_height, total_active_stake) while already at that height,
which bypasses the normal new-height initialization logic that would clear
proposed_block, last_signed_proposal, rebroadcast state, and advance past
last_sign_guard::current_state(). Since validators in Finalize phase may have
already signed prevote/precommit at the current (height, round), resetting
without proper cleanup risks equivocation (signing twice at the same height,
round, step with different LastSignBytes). Instead of directly calling
new_height on the bft_engine, either route recovery through the existing
initialization/resume logic path, or minimally capture the stalled round,
explicitly clear the transient proposal state fields, advance the sign guard
past any previously signed/stalled round, and only then proceed with resetting
the engine to ensure no new sign attempts occur at the same (height, round,
step) as previous signatures.

In `@crates/sentrix-bft/src/engine.rs`:
- Around line 3507-3518: The test function
phase_elapsed_small_after_construct_and_new_height does not follow the
established naming convention used throughout the codebase. Rename the function
to test_phase_elapsed_small_after_construct_and_new_height to add the test_
prefix that is consistently used for all other test functions in this file like
test_new_engine and test_advance_round. This ensures consistency with the
codebase naming standards.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 344dfee7-b2e1-43a9-85ba-7a83cdc128ad

📥 Commits

Reviewing files that changed from the base of the PR and between e78ee3d and 3487641.

📒 Files selected for processing (2)
  • bin/sentrix/src/main.rs
  • crates/sentrix-bft/src/engine.rs

Comment thread bin/sentrix/src/main.rs
Comment on lines +1970 to +1987
if !need_new_round
&& let Some(bft) = bft_engine.as_mut()
&& bft.height() > current_height
&& bft.phase() == BftPhase::Finalize
&& bft.phase_elapsed() >= FINALIZE_STALL_RESET
{
tracing::warn!(
target: "bft::engine",
"BFT finalize-stall recovery: engine parked at height {} \
round {} phase=Finalize for {:?} while chain at {} — \
resetting to re-run consensus for the unapplied height",
bft.height(),
bft.round(),
bft.phase_elapsed(),
current_height,
);
bft.new_height(next_height, total_active_stake);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Do not reset the same height without reusing the signed-round resume path.

This watchdog calls new_height(next_height, ...) while the engine is already at that same unapplied height, but it bypasses the normal new-height block that advances past last_sign_guard::current_state() and clears proposed_block / last_signed_proposal / rebroadcast state. In Finalize, this validator may already have signed prevote/precommit for the current (height, round): with the guard enabled, the re-run can get stuck on guard refusals; with it disabled, the reset can permit another sign attempt at the same (height, round, step).

Please route recovery through the existing initialization/resume logic, or minimally capture the stalled round, clear the transient proposal state, and advance the reset engine past any previously signed/stalled round before any new proposal/vote can be signed. As per coding guidelines, bin/sentrix/src/main.rs is “CONSENSUS-CRITICAL” and equivocation prevention requires “never sign two messages at the same (height, round, step) with different LastSignBytes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/sentrix/src/main.rs` around lines 1970 - 1987, The finalize-stall
recovery path calls bft.new_height(next_height, total_active_stake) while
already at that height, which bypasses the normal new-height initialization
logic that would clear proposed_block, last_signed_proposal, rebroadcast state,
and advance past last_sign_guard::current_state(). Since validators in Finalize
phase may have already signed prevote/precommit at the current (height, round),
resetting without proper cleanup risks equivocation (signing twice at the same
height, round, step with different LastSignBytes). Instead of directly calling
new_height on the bft_engine, either route recovery through the existing
initialization/resume logic path, or minimally capture the stalled round,
explicitly clear the transient proposal state fields, advance the sign guard
past any previously signed/stalled round, and only then proceed with resetting
the engine to ensure no new sign attempts occur at the same (height, round,
step) as previous signatures.

Source: Coding guidelines

Comment on lines +3507 to +3518

#[test]
fn phase_elapsed_small_after_construct_and_new_height() {
// phase_elapsed feeds the finalize-stall recovery in main.rs: it reports
// how long the engine has sat in the current phase. Construction and
// new_height both re-arm phase_start, so right after either it must be
// tiny. No sleep, so the test stays deterministic.
let mut engine = BftEngine::new(100, "0xval".into(), 1000);
assert!(engine.phase_elapsed() < std::time::Duration::from_secs(1));
engine.new_height(101, 1000);
assert!(engine.phase_elapsed() < std::time::Duration::from_secs(1));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Minor: test name should follow codebase convention.

Most test functions in this file use the test_ prefix (e.g., test_new_engine, test_advance_round, test_new_height). Consider renaming to test_phase_elapsed_small_after_construct_and_new_height for consistency.

The test logic is sound: deterministic, well-commented, and the 1-second threshold provides an appropriate safety margin for the sanity check.

♻️ Proposed renaming for consistency
-    fn phase_elapsed_small_after_construct_and_new_height() {
+    fn test_phase_elapsed_small_after_construct_and_new_height() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sentrix-bft/src/engine.rs` around lines 3507 - 3518, The test function
phase_elapsed_small_after_construct_and_new_height does not follow the
established naming convention used throughout the codebase. Rename the function
to test_phase_elapsed_small_after_construct_and_new_height to add the test_
prefix that is consistently used for all other test functions in this file like
test_new_engine and test_advance_round. This ensures consistency with the
codebase naming standards.

@satyakwok satyakwok disabled auto-merge June 21, 2026 18:41
@satyakwok satyakwok marked this pull request as draft June 21, 2026 18:43
@satyakwok satyakwok force-pushed the fix/finalize-stall-recovery branch from 95ba9c6 to 3487641 Compare June 23, 2026 12:40
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