Skip to content

execution: too-deep FCU passes the unwind gate, partially unwinds, then wedges block production (SeekCommitment "state out of date") #22301

Description

@yperbasis

Found on main while extending the reorg/unwind integration tests for #21860 (PR #22300).

Setup

A node with snapshot production on and default changeset behavior (no AlwaysGenerateChangesets), at toy scale to make files/pruning bite: step_size = 32, ~300 one-txn blocks, domain files built and the filed range pruned from MDBX. Changesets are still retained for roughly the whole chain (chain-tip retention), but the commitment values/history for old blocks are only in files.

What happens

A forkchoice update to block 20 (depth ~290 — within MaxReorgDepth and within the retained changesets, but far below the files boundary):

  1. is not rejected upfront by the reorg-depth / min-unwindable gate,
  2. proceeds into a real disk unwind,
  3. and fails midway:
updateForkChoice: [4/6 Execution] unwind: SeekCommitment after disk unwind: "commitment" state out of date: step 1, expected step 18

After this failed FCU the node is half-wedged: plain FCUs back to the tip succeed and state reads are correct, but block production is permanently broken — every payload-building forkchoice repeats the same failure:

update fork choice failed: updateForkChoice: [4/6 Execution] unwind: SeekCommitment after disk unwind: "commitment" state out of date: step 1, expected step 18

A bad or buggy CL request must not be able to wedge the node.

Reproduction

TestEngineApiUnwindBeyondRetainedChangesetsRejectedCleanly (execution/engineapi/engine_api_state_churn_prune_test.go, PR #22300) pins the currently-working part of the contract (loud rejection, head restorable, reads correct) and deliberately stops before block production. Appending one line at its end reproduces the wedge:

churnAndAssert(ctx, t, eat, churn, 3, func(k int) int64 { return int64(6_000 + k) })

Analysis

The unwindability gate consults the retained changesets (CanUnwindToBlockNum / min-unwindable), which at the chain tip can cover far more blocks than the commitment domain can actually rebuild — commitment values for the target block are already files-resident and pruned from MDBX, and SeekCommitment cannot reconstruct the state at the unwind target. So the gate and the actual unwind requirements disagree: the unwind starts, mutates state, and then aborts, leaving a persistent inconsistency that every subsequent block-building forkchoice trips over.

Two possible fix directions (not mutually exclusive):

  • make the gate account for commitment rebuildability at the target (min-unwindable = max of the changeset-based and commitment-history-based limits), so the FCU is rejected upfront like other too-deep reorgs;
  • make the unwind atomic with respect to the SeekCommitment failure, so a failed attempt rolls back to a fully consistent pre-unwind state instead of wedging payload building.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions