From 25119197ae4174c7fa8ddc710d86154e384c4959 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Wed, 15 Jul 2026 14:21:37 +0000 Subject: [PATCH 1/3] rpc/jsonrpc: guard nil header in getProof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HeaderByNumber can return (nil, nil); getProof used header.Root unchecked and would panic during the publish-to-commit window when the raw tx lags the head. Return an error instead. The broader fix — getProof reading the tip's state via the published SharedDomains (consumer holds its own coordinated tx via BeginCoordinatedRo, no SD-aware tx) — is tracked in #21314. (cherry picked from commit f592b6d52fd6c9808e62c90dfe77792b7c8d4cd3) --- rpc/jsonrpc/eth_call.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index ecbc89a5110..8b1d6b720b0 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -472,6 +472,9 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co if err != nil { return nil, err } + if header == nil { + return nil, fmt.Errorf("header not found for block %d", blockNrOrHash.BlockNumber.Uint64()) + } domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { From 812d82015d27f052edc2dd68155fa241e431ad2e Mon Sep 17 00:00:00 2001 From: mh0lt Date: Tue, 14 Jul 2026 18:12:03 +0000 Subject: [PATCH 2/3] execution/stagedsync: don't save a genesis changeset in serial exec Serial exec gated SetChangesetAccumulator with `blockNum > 0` but gated the matching SavePastChangesetAccumulator only on shouldGenerateChangeSets, which is true for the genesis exec (blockNum==maxBlockNum==0, so 0+MaxReorgDepth >= 0). That saved an empty block-0 changeset; under background commit it flushes to ChangeSets3, dragging ReadLowestUnwindableBlock to 0. Genesis is never unwindable, so match the two guards. (cherry picked from commit 1a7ecd2cb9b4f088a4ba06b09c6b83009f59577a) --- execution/stagedsync/exec3_serial.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/stagedsync/exec3_serial.go b/execution/stagedsync/exec3_serial.go index 848e97d6bf4..7bb51d198c2 100644 --- a/execution/stagedsync/exec3_serial.go +++ b/execution/stagedsync/exec3_serial.go @@ -195,7 +195,7 @@ func (se *serialExecutor) exec(ctx context.Context, execStage *StageState, u Unw return nil, rwTx, err } - if shouldGenerateChangesets { + if shouldGenerateChangesets && blockNum > 0 { se.doms.SavePastChangesetAccumulator(b.Hash(), blockNum, changeSet) } se.doms.SetChangesetAccumulator(nil) From c804cdbdc017c37127821bb7a5d1cb6f49d26250 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Tue, 14 Jul 2026 21:46:36 +0000 Subject: [PATCH 3/3] execution/stagedsync: treat empty apply-loop close as clean, not more-work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the parallel apply loop's channel closes with no stop cause and the loop executed nothing (no tx-results, no blockResult), the requested range was already applied before this call — under background commit the async commit can advance execution progress to a single-block fork-validation target before its StateStep runs. The fallback classified that empty close as a partial batch and returned ErrLoopExhausted, which the stage loop reported as "unexpected state step has more work". Add applyLoopCloseIsClean so an empty loop is a clean end. (cherry picked from commit 582b08d7dcb83ebeccc4919c47ad4e1ac0d0c1d5) --- execution/stagedsync/exec3_parallel.go | 20 ++++++++++-- .../exec3_parallel_robustness_test.go | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 5948613ace4..3367cd47b64 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -596,9 +596,11 @@ func (pe *parallelExecutor) execImpl(ctx context.Context, execStage *StageState, } // Fallback for exit paths that publish no cause: a single-block // fork-validation batch exits via execLoopExitCheck (no cause), and - // real shutdown cancels with context.Canceled. A fully-applied - // requested range is a clean end; otherwise there is more work. - if lastBlockResult.BlockNum >= pe.maxBlockNum { + // real shutdown cancels with context.Canceled. A fully-applied range + // — or an empty loop that executed nothing because the range was + // already applied (async background commit advanced progress) — is a + // clean end; otherwise there is more work. + if applyLoopCloseIsClean(lastBlockResult.BlockNum, pe.maxBlockNum, len(txResultBlocks)) { return nil } return &ErrLoopExhausted{From: startBlockNum, To: lastBlockResult.BlockNum, Reason: "block batch is full"} @@ -1505,6 +1507,18 @@ func execLoopShouldExit(blockResult *blockResult, sizeEst, batchLimit, maxBlockN return execLoopContinue } +// applyLoopCloseIsClean reports whether an apply-loop close with no published +// stop cause is a clean end rather than a partial batch to resume. It is clean +// when the requested range was fully applied (lastBlockNum >= maxBlockNum) or +// when the loop executed nothing at all (no tx-results and no blockResult) — +// the range was already applied before this call, so there is no pending work. +func applyLoopCloseIsClean(lastBlockNum, maxBlockNum uint64, txResultCount int) bool { + if lastBlockNum >= maxBlockNum { + return true + } + return txResultCount == 0 && lastBlockNum == 0 +} + // closeApplyChannels closes the apply-loop-bound channels in the order // the calculator and apply loop require: commitResults FIRST so the // calculator drains and closes rootResults, then applyResults so the diff --git a/execution/stagedsync/exec3_parallel_robustness_test.go b/execution/stagedsync/exec3_parallel_robustness_test.go index b588942cbbc..4570f9a02e2 100644 --- a/execution/stagedsync/exec3_parallel_robustness_test.go +++ b/execution/stagedsync/exec3_parallel_robustness_test.go @@ -720,6 +720,38 @@ func TestExecLoopShouldExitPriority(t *testing.T) { } } +// TestApplyLoopCloseIsClean pins the no-stop-cause apply-loop close +// classification. The load-bearing case is the empty loop +// (txResultCount==0, lastBlockNum==0): under background commit the async +// commit can advance execution progress to the validation target before a +// single-block fork-validation step runs, so the exec loop executes nothing +// and produces no blockResult. Treating that as pending work returns a +// spurious ErrLoopExhausted, which the stage loop reports as +// "unexpected state step has more work". +func TestApplyLoopCloseIsClean(t *testing.T) { + cases := []struct { + name string + lastBlockNum uint64 + maxBlockNum uint64 + txResults int + want bool + }{ + {name: "fully applied", lastBlockNum: 5, maxBlockNum: 5, txResults: 3, want: true}, + {name: "past target", lastBlockNum: 6, maxBlockNum: 5, txResults: 3, want: true}, + {name: "partial batch is not clean", lastBlockNum: 3, maxBlockNum: 5, txResults: 2, want: false}, + {name: "empty loop, nothing executed", lastBlockNum: 0, maxBlockNum: 21, txResults: 0, want: true}, + {name: "tx-results without blockResult is not clean", lastBlockNum: 0, maxBlockNum: 21, txResults: 4, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := applyLoopCloseIsClean(tc.lastBlockNum, tc.maxBlockNum, tc.txResults) + if got != tc.want { + t.Fatalf("applyLoopCloseIsClean(%d,%d,%d) = %v, want %v", tc.lastBlockNum, tc.maxBlockNum, tc.txResults, got, tc.want) + } + }) + } +} + // TestShouldMarkExhaustedAtBlock exercises the production // shouldMarkExhaustedAtBlock helper directly. The helper is the gate // that decides whether executeBlocks stamps a dispatched block with