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):
- is not rejected upfront by the reorg-depth / min-unwindable gate,
- proceeds into a real disk unwind,
- 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.
Found on
mainwhile 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
MaxReorgDepthand within the retained changesets, but far below the files boundary):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:
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: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, andSeekCommitmentcannot 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):
SeekCommitmentfailure, so a failed attempt rolls back to a fully consistent pre-unwind state instead of wedging payload building.