Skip to content

[r3.6] cl/beacon: publish fork-choice head before state copy - #23172

Merged
lystopad merged 1 commit into
release/3.6from
feature/lystopad/cp-23155-to-3.6
Aug 11, 2026
Merged

lystopad merged 1 commit into
release/3.6from
feature/lystopad/cp-23155-to-3.6

Conversation

@lystopad

Copy link
Copy Markdown
Member

Cherry-pick of #23155 to release/3.6.

r3.6-specific adaptations

  • getHeadGloas keeps this branch's structure and only gains the publishSelectedHead call. Main restructured that loop into a closure in an unrelated refactor that was never backported, so importing it here would be scope creep.
  • getFinalizedExecutionHashForkGraph in forkchoice_test.go gains a headers map and a GetHeader that consults it, matching main. This branch's fixture panicked instead, which the new selected-head test needs.

Neither is a semantic conflict with the fix — both are pre-existing main-only changes this branch never received.

Testing

go test ./cl/... green, including the new selected-head and head-identity tests. make lint clean.

Cherry-pick of #23155 to release/3.6.

r3.6-specific adaptations:
- getHeadGloas keeps this branch's structure and only gains the
  publishSelectedHead call; main's closure restructure of that loop is an
  unrelated refactor that was never backported.
- getFinalizedExecutionHashForkGraph in forkchoice_test.go gains a headers
  map and a GetHeader that consults it, matching main. This branch's fixture
  panicked instead, which the new selected-head test needs.
@lystopad lystopad self-assigned this Aug 11, 2026
@lystopad lystopad added the Caplin Caplin: Consensus Layer, Beacon API label Aug 11, 2026
@lystopad
lystopad enabled auto-merge August 11, 2026 10:12

@domiwei domiwei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as a release/3.6 port of #23155. The selected-head publication points remain ordered by the fork-choice lock across cached, anchor-fallback, pre-Gloas, and Gloas paths; the release-specific Gloas loop preserves checkpoint revalidation and error behavior. State-backed endpoints retain the memoized state identity. No release-specific blockers found.

@lystopad
lystopad added this pull request to the merge queue Aug 11, 2026
Merged via the queue into release/3.6 with commit 4430dd3 Aug 11, 2026
94 checks passed
@lystopad
lystopad deleted the feature/lystopad/cp-23155-to-3.6 branch August 11, 2026 13:33
Sahil-4555 pushed a commit to Sahil-4555/erigon that referenced this pull request Sep 21, 2026
…under it (erigontech#24165)

Fixes erigontech#24164.

## Problem

`OnHeadStateWithBlockRoot`/`OnHeadState` copied the entire incoming
`CachingBeaconState` while holding `s.mu`/`s.accessLock` for writing.
`ViewHeadState`, `ViewPreviousHeadState`, and the single-field accessors
(`ValidatorPublicKeyByIndex`, `ValidatorIndexByPublicKey`,
`CommitteeCount`, `HistoricalRootElementAtIndex`,
`HistoricalSummaryElementAtIndex`) all take the matching read lock, so
every one of them was blocked for however long that copy took.

This is the state-backed remainder of erigontech#23154 that erigontech#23155 explicitly
scoped out: erigontech#23155 publishes the selected head identity (root/slot)
before the state copy runs, so identity-only reads no longer wait on it,
but state-backed reads still do. Any duty computation needing actual
state content - not just a root - goes through `ViewHeadState` and is
still exposed.

Reproduced on a mainnet validator node running a release/3.7 dev build:
sync-committee efficiency dropped to 94.60% (199/3686 missed) in a 24h
window while attestation efficiency stayed at 99.59%, matching erigontech#23154's
own description of this failure mode (attestation data is produced
before the head-state update; sync-committee messages need the state
after it). Full field evidence is in erigontech#24164.

## Change

Both `OnHeadState` and `OnHeadStateWithBlockRoot` now acquire a new
`writeLock sync.Mutex`, independent of `mu`/`accessLock`, before doing
any work - `OnHeadState`'s `BlockRoot()` resolution included, not just
the copy - then call a shared `publishHeadStateLocked` (documented as
requiring the lock already held), which:

1. Copies the incoming state under `writeLock`.
2. Takes `mu`/`accessLock` only to swap the copy in as the new head
state and demote the current head state to previous (`previousHeadState
= headState; headState = copied` - a pointer reassignment, no data copy)
- an O(1) critical section regardless of state size.

`writeLock` matters because it isn't just about latency: without it, a
writer that started earlier but is slower to resolve its root or copy
its state could be overtaken and then overwritten by a writer that
started later but finishes faster - a silent regression to a stale head,
not just a slow reader. This applies equally to `OnHeadState` (root not
yet known, one caller - `cmd/capcli`, called sequentially today, but
still part of the exported `SyncedData` contract) and
`OnHeadStateWithBlockRoot` (root given, used by every concurrent caller:
forkchoice, forward sync, block production). `UnsetHeadState` also takes
`writeLock` (before `mu`), so it can't land in the middle of an
in-flight publish and then get resurrected by it.

Net effect: lock hold time for readers is now O(1); writers stay fully
serialized in arrival order, same guarantee the old single-mutex design
gave for free. `previousHeadState` is now the actual prior `headState`
object (aliased, not copied into a reused buffer) - same observable
content, one fewer full-state copy per update. No behavior change to any
exported signature.

## Testing

Per this repo's TDD convention, each fix has a test that reproduces it
against the commit before the fix and passes after:

- `TestViewHeadStateDoesNotWaitForHeadStateCopy` - a `ViewHeadState`
reader is not blocked for the incoming state's copy duration (only the
swap). Uses `copyHookForTest`, a package-level hook bound to the copy
itself via a small `copyState` helper (not just called before it - so
the hook's pause point can't be separated from the copy by a future
reorder), to pause a writer at an exact point and prove a concurrent
read completes while it's genuinely paused there.
- `TestOnHeadStateWithBlockRootSerializesConcurrentWriters` - a writer
cannot be overtaken and overwritten by a writer that starts later.
Deterministic: holds `writeLock` directly to represent a writer already
inside its critical section.
- `TestOnHeadStateSerializesAgainstOnHeadStateWithBlockRoot` -
`OnHeadState`'s root resolution is covered by the same serialization as
`OnHeadStateWithBlockRoot`'s copy, not just the copy step. Drives both
writers through the real public API with a calibrated head start
(documented in the test).
- `TestUnsetHeadStateSerializesWithPublish` - `UnsetHeadState` cannot be
resurrected by a publish that was already in flight when it was called.
Same hold-`writeLock` technique.

Plus:
- `TestOnHeadStateWithBlockRootColdStart` - first update populates head
state, no previous state yet.
- `TestOnHeadStateWithBlockRootDemotesPriorHeadToPrevious` -
`ViewPreviousHeadState` observes the state that was head right before
the latest update, proving the alias-based swap is behaviorally
equivalent to the old copy-based one.
- `TestOnHeadStateWithBlockRootConcurrentReadWrite` - races real writers
against real readers through the public API. Passes under `-race`.

Full existing suite (`cl/beacon/synced_data`, `cl/beacon/handler`,
`cl/phase1/stages`) passes unchanged, including the
generation-consistency and TOCTTOU-regression tests added for erigontech#23155.
All new tests pass under `-race`, repeated.

## Trade-offs

- `OnHeadState`/`OnHeadStateWithBlockRoot` now always allocate a fresh
state copy (`newState.Copy()`), rather than reusing a preallocated
buffer via `CopyInto` on the steady-state path. This trades a modest
increase in per-block allocation for removing the reader-blocking lock
hold - the copy's own cost is unchanged either way, only whether readers
wait for it. Flagging in case reviewers want a buffer-pooling follow-up
instead; I kept this PR to the minimal change that fixes the blocking
behavior.
- Not backporting in this PR. release/3.6 already carries erigontech#23155's
prerequisite (backported as erigontech#23172, included in v3.6.1) so this fix
would cherry-pick cleanly there. release/3.5 never received
erigontech#23155/erigontech#23172, so it still has the original (larger) erigontech#23154 bug, not
just this remainder - a release/3.5 backport would need that fix first.
Happy to open both as follow-ups if maintainers want them.
- erigontech#24197 tracks two remaining test-hardening items from review
(real-path writer-ordering coverage, `accessLock` accessor coverage) -
explicitly scoped as non-blocking follow-ups, not production bugs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Caplin Caplin: Consensus Layer, Beacon API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants