Skip to content

Commit a3df02e

Browse files
committed
fix(consensus): recover from Finalize-stall when a FinalizeBlock guard 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.
1 parent 65cab1b commit a3df02e

2 files changed

Lines changed: 47 additions & 0 deletions

File tree

bin/sentrix/src/main.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,44 @@ async fn cmd_start(
19481948
None => true,
19491949
Some(bft) => bft.height() <= current_height,
19501950
};
1951+
1952+
// Finalize-stall liveness recovery.
1953+
//
1954+
// A FinalizeBlock guard below (split-brain / hash-mismatch /
1955+
// add_block error) can break out WITHOUT applying block N,
1956+
// parking the engine at height N phase=Finalize while the chain
1957+
// stays at N-1. need_new_round above can't reset it
1958+
// (bft.height N > current_height N-1), and Finalize has no
1959+
// timeout (engine `step` → Wait), so it spins on "phase timeout
1960+
// finalize" until a peer's block N arrives via gossip and
1961+
// advances the chain — minutes when the mesh is degraded. If the
1962+
// engine sits in Finalize one height ahead of the chain past
1963+
// FINALIZE_STALL_RESET, reset it to re-run consensus for N.
1964+
// Safe: the `bc.height() >= height` skip-guard in the
1965+
// FinalizeBlock arms no-ops a re-finalize if the block already
1966+
// landed via gossip (no double-apply), and a re-proposal still
1967+
// needs 2/3 precommits, so safety is preserved.
1968+
const FINALIZE_STALL_RESET: std::time::Duration =
1969+
std::time::Duration::from_secs(5);
1970+
if !need_new_round
1971+
&& let Some(bft) = bft_engine.as_mut()
1972+
&& bft.height() > current_height
1973+
&& bft.phase() == BftPhase::Finalize
1974+
&& bft.phase_elapsed() >= FINALIZE_STALL_RESET
1975+
{
1976+
tracing::warn!(
1977+
target: "bft::engine",
1978+
"BFT finalize-stall recovery: engine parked at height {} \
1979+
round {} phase=Finalize for {:?} while chain at {} — \
1980+
resetting to re-run consensus for the unapplied height",
1981+
bft.height(),
1982+
bft.round(),
1983+
bft.phase_elapsed(),
1984+
current_height,
1985+
);
1986+
bft.new_height(next_height, total_active_stake);
1987+
}
1988+
19511989
if need_new_round {
19521990
// P1: refuse to start a BFT round when the active set is
19531991
// too small for byzantine-fault tolerance. BFT requires

crates/sentrix-bft/src/engine.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,15 @@ impl BftEngine {
11961196
self.state.phase
11971197
}
11981198

1199+
/// Wall-clock time spent in the current phase. The driver uses this to
1200+
/// detect a Finalize stall: a FinalizeBlock guard can abort the local
1201+
/// apply and park the engine one height ahead of the chain, where `step`
1202+
/// returns a zero-duration Finalize timeout and it spins on
1203+
/// `BftAction::Wait`. See the finalize-stall recovery in main.rs.
1204+
pub fn phase_elapsed(&self) -> std::time::Duration {
1205+
self.phase_start.elapsed()
1206+
}
1207+
11991208
/// Called by the driver after a `BroadcastPrevote` action has been
12001209
/// successfully sent on the network. Marks our prevote as cast so the
12011210
/// engine does not re-emit it on the next iteration.

0 commit comments

Comments
 (0)