Skip to content

fix(l1): recover instead of stalling when the head's post-state is unreachable - #7196

Merged
ilitteri merged 5 commits into
mainfrom
fix/recover-from-unreachable-head-state
Aug 31, 2026
Merged

fix(l1): recover instead of stalling when the head's post-state is unreachable#7196
ilitteri merged 5 commits into
mainfrom
fix/recover-from-unreachable-head-state

Conversation

@ilitteri

Copy link
Copy Markdown
Collaborator

Motivation

A devnet node stopped following the chain and could not recover, not even across restarts. A deep reorg unwound past the window of retained state and left the canonical chain and the state history pointing at different branches, so no canonical block had a post-state we could still read.

From there three independent paths all declined to make progress:

  • Full sync walked to genesis looking for a stateful parent, found none, and returned Ok to pause "until a reconcilable forkchoice head arrives". That head can never arrive — the state is gone, not merely unreferenced — so the wait never ends.
  • Every forkchoiceUpdated naming a finalized block we had not downloaded was rejected with -38002 through an arm that, unlike Syncing and StateNotReachable, never starts a sync. The blocks we were missing were therefore never fetched, and each later FCU failed identically.
  • On restart, regenerate_head_state runs the same walk and refuses to boot.

The node logged 2836 forkchoice rejections over nine hours while the chain moved ~6500 blocks ahead, then died and crash-looped. The trigger was a CL-side bug producing repeated invalid heads, but the reorg churn only exposed the condition; nothing in the execution client could get out of it.

Description

  • Full sync reports an exhausted walk as SyncError::StateUnrecoverable instead of pausing, and sync_cycle escalates to snap sync — the only in-protocol way to obtain state we do not hold. The guard fires only once the walk has bottomed out with no stateful parent anywhere, so it cannot trigger while a usable base still exists.
  • A safe/finalized block we simply do not have is now treated as missing data: start a sync and answer SYNCING. -38002 is kept for elements we do hold but that are ordered wrongly (Unordered) or sit on a disjoint branch (Disconnected). Without this, the escalation above is unreachable, since no sync is ever started.
  • check_order reports the element the caller actually looked up. It hardcoded Finalized, so a missing safe block was also reported as "Finalized" — meaning those 2836 log lines may not have been about the finalized block at all.
  • Corrected the comment at the deep-reorg cache-edge bail, which claimed apply_fork_choice "should have succeeded as a shallow reorg" when that path having failed is precisely how execution reaches it. The bail itself is right: with an empty overlay range the state is genuinely absent locally, and deferring to sync is the honest answer — it just needed a sync that can recover.

Deliberately out of scope: the boot-time refusal in regenerate_head_state. A node that dies before the escalation completes still needs ethrex removedb, so that path is worth a follow-up.

Tests

Unit tests for check_order cover the missing-element label for both call sites, known blocks in the wrong order, and equal block numbers.

The escalation path itself has no automated coverage — reaching it requires a store whose retained state has been pruned out from under a canonical chain plus live peers to snap-sync from, which the current sync tests cannot set up. It is exercised by cargo clippy/check only. Reproducing it in CI would need a p2p harness that does not exist yet.

When a deep reorg unwinds past the window of retained state, the canonical chain
and the state history can end up on different branches, leaving no canonical
block whose post-state we can still read. Full sync then walked to genesis,
found no stateful parent, and returned Ok to pause "until a reconcilable
forkchoice head arrives" — a head that can never arrive, because the state is
gone rather than merely unreferenced. Meanwhile every forkchoiceUpdated whose
finalized block we had not downloaded was rejected with -38002 through an arm
that, unlike Syncing and StateNotReachable, never starts a sync, so the missing
blocks were never fetched and the node followed the chain no further.

Report the exhausted full-sync walk as SyncError::StateUnrecoverable and
escalate to snap sync, which is the only in-protocol way to obtain state we do
not hold. Treat a safe/finalized block we simply do not have as missing data:
start a sync and answer SYNCING, keeping -38002 for elements we do hold but
that are ordered wrongly or sit on a disjoint branch.

Also report the forkchoice element that is actually absent instead of always
naming the finalized one, and correct the comment at the deep-reorg cache-edge
bail, which claimed the shallow path should already have succeeded when that
path having failed is precisely how execution reaches it.
@ilitteri
ilitteri requested a review from a team as a code owner August 21, 2026 20:37
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rsunsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added the L1 Ethereum client label Aug 21, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This PR addresses critical liveness issues in fork choice handling and sync recovery. The changes are well-structured and include good test coverage.

crates/blockchain/fork_choice.rs

  1. Bug fix (line 102-110): The check_order function now correctly identifies which fork choice element is missing (Safe vs Finalized) rather than hardcoding Finalized. This fixes mislabeled logs that would send operators looking at the wrong field.
  2. Test coverage (lines 779-830): New unit tests properly verify the fix with descriptive test names. Good use of matches! for error variant checking.
  3. Documentation (lines 507-514): The updated comment in reorg_apply_deep clearly explains why the deep-reorg path fails when the pivot is above the cache edge.

crates/networking/p2p/sync.rs

  1. Refactoring (lines 292-326): Extracting run_snap_cycle eliminates code duplication and improves readability.
  2. State recovery logic (lines 313-325): The escalation from full sync to snap sync when StateUnrecoverable is detected prevents indefinite stalling. This is a critical fix for nodes that have lost state history due to deep reorgs.
  3. Error classification (lines 519-523): Marking StateUnrecoverable as retryable is correct since the escalation happens in sync_cycle; reaching the classifier means the escalation itself failed.

crates/networking/p2p/sync/full.rs

  1. Behavior change (lines 365-377): Returning SyncError::StateUnrecoverable instead of Ok(()) when no resumable state exists allows the sync manager to escalate to snap sync. The previous behavior would pause forever since no forkchoice head could reconcile to the missing state.

crates/networking/rpc/engine/fork_choice.rs

  1. Consensus compliance (lines 385-400): Treating ElementNotFound as a sync trigger rather than a hard error (InvalidForkChoiceState) fixes a liveness issue where nodes falling behind would get permanently wedged. Per Engine API spec, unknown forkchoice elements should initiate sync, not return an error.

Minor suggestions:

  • Line 519 (sync.rs): Consider logging at info level instead of warn for the snap sync escalation, as this is expected recovery behavior rather than an anomalous condition.
  • Line 394 (sync.rs): Ensure delete_leaves_folder is safe to call repeatedly if run_snap_cycle retries multiple times (no resource leaks).

Overall: The PR correctly identifies and fixes three related failure modes: incorrect error attribution, indefinite sync pausing, and failure to trigger sync on missing elements. The code is safe to merge.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. crates/networking/rpc/engine/fork_choice.rs changes InvalidForkChoice::ElementNotFound from a hard forkchoice error into SYNCING, but that variant is raised specifically when the ancestor element is missing while its descendant is already present locally (crates/blockchain/fork_choice.rs, crates/blockchain/error.rs). In that state, sync_to_head(head_block_hash) (crates/networking/p2p/sync_manager.rs) usually cannot repair anything if the head is already known, so the new behavior can loop forever returning SYNCING while masking either an invalid CL forkchoice or local DB inconsistency. I think the old InvalidForkChoiceState behavior was safer here.

  2. crates/networking/p2p/sync.rs escalates full sync into snap sync by just setting snap_enabled = true and calling run_snap_cycle, but it does not clear any persisted snap checkpoint first. sync_cycle_snap will resume from get_header_download_checkpoint() when present (crates/networking/p2p/sync/snap_sync.rs), and stale snap state is only cleared in some other transitions (crates/networking/p2p/sync_manager.rs, crates/networking/p2p/sync.rs). If a previous aborted snap left SNAP_STATE, this new escalation path can resume from an old checkpoint / wrong branch instead of the current canonical head.

The check_order reporting fix in crates/blockchain/fork_choice.rs itself looks correct, and the added unit tests are good coverage for that bug.

I couldn’t run the targeted tests in this environment because cargo tried to update the Rust toolchain under /home/runner/.rustup, which is read-only here.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 75
Total lines removed: 1
Total lines changed: 76

Detailed view
+----------------------------------------------+-------+------+
| File                                         | Lines | Diff |
+----------------------------------------------+-------+------+
| ethrex/crates/blockchain/fork_choice.rs      | 485   | +46  |
+----------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/sync.rs         | 409   | +27  |
+----------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/sync/full.rs    | 582   | -1   |
+----------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/sync_manager.rs | 280   | +2   |
+----------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 7196 — Recover instead of stalling when the head's post-state is unreachable

Summary

This is a well-scoped, well-documented fix for a real (and painful) failure mode: a full-sync node whose retained state falls behind the canonical chain after a deep reorg had no path back to liveness. The changes are surgical, the reasoning in commit/PR text matches what's implemented, and unit tests were added for the one piece of logic that's cheaply testable (check_order). I did not find correctness bugs; comments below are mostly verification notes and small polish items.

Findings

1. Stale error message on InvalidForkChoice::ElementNotFound (minor/nit)
crates/blockchain/error.rs:211 — the #[error(...)] text is: "A fork choice element ({:?}) was not found, but an ancestor was, so it's not a sync problem."

The entire point of this PR is that a missing safe/finalized element is now treated as a sync problem (crates/networking/rpc/engine/fork_choice.rs:389-397 starts a sync and returns SYNCING instead of rejecting with -38002). The Display text is now factually wrong. It happens not to leak today because the new RPC arm logs the error with {:?} (Debug) rather than {}/.to_string() (Display), but that's incidental — any future caller using .to_string() on this variant (logs, metrics labels, error propagation) will print a misleading claim. Worth updating the message text in the same PR since it's directly tied to the semantics being changed here.

2. run_snap_cycle recursion could, in a narrow edge case, still surface StateUnrecoverable to the retry loop (informational, not blocking)
crates/networking/p2p/sync.rs:290-323 escalates full→snap by calling self.run_snap_cycle(...). Internally, sync_cycle_snap can itself call back into full::sync_cycle_full directly (crates/networking/p2p/sync/snap_sync.rs:243-251, the head_found || head_close_to_0 branch) when the snap header-walk discovers the sync head is actually close/local. That inner sync_cycle_full call is not wrapped by the StateUnrecoverable → escalate logic in sync.rs::sync_cycle — it's a direct call from snap_sync.rs. If it also returns StateUnrecoverable, the error now propagates all the way out of sync_cycle to the start_sync retry loop, which (correctly, given the new is_recoverable() => true arm at sync.rs:518) retries rather than exits — so no crash/stall, just an extra retry round trip and a second escalation warning on the next cycle. This requires the specific combination of "state unrecoverable" and "sync head very close to local head," which is an unlikely pairing for the deep-reorg-past-retention scenario this PR targets, so I'd call this a theoretical corner case worth a one-line mental note rather than something requiring a code change.

3. PR description's claim about the guard's exclusivity is slightly stronger than the code guarantees (informational)
The description states the StateUnrecoverable guard "fires only once the walk has bottomed out with no stateful parent anywhere, so it cannot trigger while a usable base still exists." Looking at full.rs:326-370, the !resume_parent_has_state check can in principle be reached via the batch_resume_index.is_some() branch too (not only sync_head.is_zero()), if the resume point found mid-batch has a parent whose state has since been pruned. In practice this is unlikely given the retention-window invariants documented elsewhere in the file, and this exact branch's trigger condition isn't something this PR changed — only the outcome (previously silent Ok(()) pause, now Err(StateUnrecoverable) + escalate) was changed, uniformly for every path into that branch. Since the old behavior was already "pause forever" for all of these cases, the new behavior is a strict improvement regardless of exactly which sub-condition triggers it — so this isn't a regression, just a note that the description's phrasing is a bit more precise than the code's actual guarantee.

Things verified as correct

  • check_order's new missing parameter is threaded correctly at both call sites (fork_choice.rs:102, 106-110) — safe-vs-head labels Safe, finalized-vs-safe labels Finalized — fixing the real bug where a missing safe block was always logged as "Finalized."
  • The RPC handler's new ElementNotFound arm correctly leaves Unordered and Disconnected on the -38002 path (they fall through to the existing catch-all / explicit arm respectively) and only reroutes the "we don't have this block at all" case to SYNCING, matching the PR's stated intent.
  • syncer.sync_to_head is idempotent (only starts a cycle if inactire) per sync_manager.rs:132-136, so reclassifying ElementNotFound to trigger a sync call on every rejected FCU won't spawn redundant cycles.
  • Store is Arc-backed internally (store.rs:197-206), so the added store.clone() in sync.rs's full-sync branch is cheap — no meaningful perf cost from the refactor.
  • Extracting run_snap_cycle out of the snap-cycle branch is a faithful behavior-preserving refactor (same METRICS.enable/disable, delete_leaves_folder sequencing), just deduplicated for reuse by the new escalation path.
  • SyncError::StateUnrecoverable is correctly marked recoverable (is_recoverable() returns true) with a clear comment on why exiting the process would be wrong (killing the process doesn't restore missing state, and regenerate_head_state still refuses to boot — explicitly called out as follow-up work).
  • New unit tests in fork_choice.rs correctly cover: missing-element labeling for both Safe/Finalized, wrong-order detection, and the equal-block-number pass-through case (finalized <= safe <= head allows equality).
  • The corrected comment at the deep-reorg cache-edge bail (fork_choice.rs:507-518) accurately reflects that reaching that code already implies the shallow path failed — good catch on the stale comment.

No security, gas-accounting, or consensus-rule concerns — this PR only touches sync orchestration and FCU error routing, not block/state validation logic itself.


Automated review by Claude (Anthropic) · sonnet · custom prompt

Comment thread crates/networking/p2p/sync.rs Outdated
// operator notices and runs `ethrex removedb`.
warn!(
%sync_head,
"Full sync has no reachable state to resume from; escalating to snap sync"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
"Full sync has no reachable state to resume from; escalating to snap sync"
"Full sync has no reachable state to resume from; switching to snap sync"

Comment on lines +328 to +329
self.snap_enabled.store(true, Ordering::Relaxed);
self.run_snap_cycle(sync_head, store).await

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we check here if the sync-mode was set to snap-sync/default? If the sync-mode is full-sync, switching to snap-sync here can result in lost data.

Comment on lines +413 to +417
// Returning Ok here would pause the cycle and wait for a forkchoice head that
// reconciles to state we retain. When the walk has already bottomed out at
// genesis no such head exists — the state is gone, not merely unreferenced —
// so the wait never ends and the node stops following the chain entirely.
// Report it so the caller can escalate to snap sync.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Returning Ok here would pause the cycle and wait for a forkchoice head that
// reconciles to state we retain. When the walk has already bottomed out at
// genesis no such head exists — the state is gone, not merely unreferenced —
// so the wait never ends and the node stops following the chain entirely.
// Report it so the caller can escalate to snap sync.
// Returning Ok here would pause the cycle.
// Report it so the caller can switch to snap sync.

Comment thread crates/blockchain/fork_choice.rs Outdated

if !safe_hash.is_zero() {
check_order(&safe_res, &head_res)?;
check_order(&safe_res, &head_res, error::ForkChoiceElement::Safe)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's import ForkChoiceElement here

}

#[cfg(test)]
mod tests {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we put this in the tests directory?

@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Aug 28, 2026
An earlier revision of this PR mapped ElementNotFound to a SYNCING response
and started a sync, to avoid wedging a node that has fallen behind. But
ElementNotFound only fires when the block the missing element is compared
against (the head, or the safe block) is already present — the ordering
checks run before the head-absent syncing path — so the node is not behind
on it. An unknown safe/finalized hash against a known head is the -38002
case the engine spec mandates, which the Hive 'Unknown SafeBlockHash' and
'Unknown FinalizedBlockHash' Cancun tests enforce. The genuine fell-behind
wedge this PR targets surfaces as the head-absent Syncing path or
StateNotReachable, which are unaffected. The check_order change that names
the actually-missing element in the error is kept.
…ress review

Review feedback on the unreachable-state recovery:

- Gate the snap-sync escalation on the configured mode. At the recovery
  point snap_enabled is false both for an explicit --syncmode full node
  and for a snap-default node that auto-switched to full after initial
  sync; escalating the former silently runs a snap cycle that wipes the
  leaves folder, discarding state the operator chose to keep. Thread the
  configured intent (snap_permitted) down to the Syncer and, when snap is
  not permitted, surface the unrecoverable state for operator action
  instead of switching. A snap-default node still escalates as before.
- Import ForkChoiceElement instead of spelling error::ForkChoiceElement
  at every call site (per review).
@ilitteri
ilitteri enabled auto-merge August 31, 2026 04:07
@ilitteri
ilitteri added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit a0935d0 Aug 31, 2026
49 of 53 checks passed
@ilitteri
ilitteri deleted the fix/recover-from-unreachable-head-state branch August 31, 2026 05:33
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants