Skip to content

[r3.6] execution/commitment: fix wave-BFS livelock when a step budget fills exactly - #23153

Merged
lystopad merged 1 commit into
release/3.6from
feature/lystopad/preload-parallel-livelock-36
Aug 11, 2026
Merged

[r3.6] execution/commitment: fix wave-BFS livelock when a step budget fills exactly#23153
lystopad merged 1 commit into
release/3.6from
feature/lystopad/preload-parallel-livelock-36

Conversation

@lystopad

Copy link
Copy Markdown
Member

Cherry-pick of #23066 to release/3.6.

Applies cleanly; both files are blob-identical to the merged commit (3be2c59), so no r3.6-specific adaptations were needed.

release/3.6 is the branch this was hit on in production (4402a1d).

…exactly (#23066)

## Problem

`ContractTrunkPreloadParallel.Run` can spin forever in its wave loop,
wedging the node.

The loop only breaks on `budgetHit`, which `pin()` raises on a *strict*
overflow. A wave that pins nothing never raises it, and the frontier is
reassigned at the same depth — identical state on re-entry. Two ways a
wave pins nothing:

- **Whole miss set deferred.** `fileBudget` on re-entry is `stepCap -
(usedBytes + dbHitsBytes)`, bit-identical to the value that caused the
deferral, because the db-hits consume exactly the bytes the deferral
already accounted for. Infinite.
- **Capped fetch resolves to nothing.** Every fetched key is absent from
the file layer — the normal shape at the BFS fringe, where a set
`afterMap` bit names a leaf with no branch record. Re-enters once per
chunk: bounded, but quadratic.

## Impact

Hit in production on a Gnosis validator (`release/3.6`, 4402a1d):

```
sortAndPartitionFrontier  preload_parallel.go:121
Run                       preload_parallel.go:216
runExtensionLocked        adaptive_pin.go:354
OnBlockComplete           adaptive_pin.go:206
SharedDomains.Commit      domain_shared.go:1030
updateForkChoice          forkchoice.go:742
```

The stuck goroutine holds the execution semaphore inside an FCU, so
`NewPayload` blocks while holding the fork-choice write mutex. The CL
slot ticker stops and ~420 `getAttesterDuties` handlers pile up on the
read lock. The node stopped attesting and producing for 17h while
burning a core; `eth_syncing` still reported `false`.

Two dumps minutes apart showed the same goroutine id, receiver and
`dbBranches` pointers — livelock, not a slow walk. 68,732 extension runs
over 5.9d uptime before it hit.

## Fix

End the step when a wave defers part of its miss set and pinned nothing.
Each iteration now either breaks, strictly shrinks the frontier, or
increments `nextDepth`. Deferred work resumes on the next `Run`, so
there is no throughput cost.

Also:
- Floor the file fetch at `minEntryBytes` — below one entry's cost the
batch is pure waste.
- Add the missing `cache == nil` check to
`PreloadContractTrunkParallel`: `Run` returns the error, then the
wrapper's logger block dereferences `cache.PinnedCount()`, so callers
got a panic instead.
- Fold two disagreeing `queueEmpty` expressions into one method.

## Testing

Differential run of the fixed `Run` against the pre-guard one over 40k
configs / 2.3M steps: identical pins, `queueEmpty` and `usedBytes` at
every step. The pre-guard code livelocked in 18,306 of those configs.
Thanks @awskii.

- `ExactBudgetFillTerminates` — the production state; hangs before the
fix.
- `NoPinWaveEndsStep` — both no-pin paths as two budgets one byte apart,
asserting the exact resolver call count. Each case fails against exactly
one of the two production changes reverted, so neither is left unpinned
by the other.
- `DeferWithDbHitsInSameWave` — deferral boundary with db-hits pinned in
the same wave.
- `StepBudgetSweepTerminates` — budgets straddling one entry's cost.

Hang guards abort the binary rather than `t.Fatal`, since `Run` has no
cancellation and a spinning goroutine would outlive the failure.
`execution/commitment` green under `-race -count=2`; `make lint` clean.

## Scope

`preload_parallel.go` is the only site with this shape: the serial
`ContractTrunkPreload.Run` pops its queue head unconditionally,
`OnBlockComplete` ranges a bounded set, and
`execStatusList.drainDeferred` already has a progress net. `release/3.6`
needs a backport; `release/3.5` does not have the feature.

## Notes (not fixed here)

- `adaptive_pin.go`: `c.misses` entries are never removed, so one
`sync.Map` entry accumulates per contract hash ever touched and
`OnBlockComplete` ranges the whole set every block. Found by @awskii.
- `commitment_trunk_preload_{bytes,duration_seconds}_total` are declared
but never written — no metric signal for preload work while this was
wedged. Fixed in #23067.
- When `PerContractMaxBudgetBytes - usedBytes` drops below one entry's
cost, `runExtensionLocked` runs every block and pins nothing. Bounded
per-block work, not a hang.

---------

Co-authored-by: awskii <artem.tsskiy@gmail.com>
@lystopad lystopad self-assigned this Aug 10, 2026
@lystopad
lystopad enabled auto-merge August 10, 2026 19:15
@lystopad
lystopad added this pull request to the merge queue Aug 11, 2026
Merged via the queue into release/3.6 with commit 4e1f9ab Aug 11, 2026
94 checks passed
@lystopad
lystopad deleted the feature/lystopad/preload-parallel-livelock-36 branch August 11, 2026 03:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants