From c7c0fe554f9a381f54ee159f0822774f9c17ed17 Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 13 May 2026 19:35:46 -0300 Subject: [PATCH 1/4] fix(finality-grandpa): use atomic.Pointer for wakerChan.waker --- pkg/finality-grandpa/timer.go | 16 +++-- pkg/finality-grandpa/timer_test.go | 111 +++++++++++++++++++++++++++++ pkg/finality-grandpa/voter.go | 14 ++-- 3 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 pkg/finality-grandpa/timer_test.go diff --git a/pkg/finality-grandpa/timer.go b/pkg/finality-grandpa/timer.go index 3a044ef1c4..a77d896936 100644 --- a/pkg/finality-grandpa/timer.go +++ b/pkg/finality-grandpa/timer.go @@ -5,13 +5,15 @@ package grandpa import ( "sync" + "sync/atomic" "time" ) type timer struct { wakerChan *wakerChan[error] mtx sync.Mutex - expired bool + closed bool // guards the one-shot close of wakerChan.in + expired atomic.Bool } func newTimer(in <-chan time.Time) *timer { @@ -26,12 +28,12 @@ func (t *timer) poll(in <-chan time.Time) { <-in t.mtx.Lock() defer t.mtx.Unlock() - if t.wakerChan.in != nil { + if !t.closed { t.wakerChan.in <- nil close(t.wakerChan.in) - t.wakerChan.in = nil + t.closed = true } - t.expired = true + t.expired.Store(true) } func (t *timer) SetWaker(waker *waker) { @@ -39,14 +41,14 @@ func (t *timer) SetWaker(waker *waker) { } func (t *timer) Elapsed() (bool, error) { - return t.expired, nil + return t.expired.Load(), nil } func (t *timer) Close() { t.mtx.Lock() defer t.mtx.Unlock() - if t.wakerChan.in != nil { + if !t.closed { close(t.wakerChan.in) - t.wakerChan.in = nil + t.closed = true } } diff --git a/pkg/finality-grandpa/timer_test.go b/pkg/finality-grandpa/timer_test.go new file mode 100644 index 0000000000..b363339521 --- /dev/null +++ b/pkg/finality-grandpa/timer_test.go @@ -0,0 +1,111 @@ +// Copyright 2025 ChainSafe Systems (ON) +// SPDX-License-Identifier: LGPL-3.0-only + +package grandpa + +import ( + "sync" + "testing" + "time" +) + +// TestTimer_ElapsedConcurrentWithFiring exercises the read of `expired` from +// one goroutine while the timer's `poll` goroutine is writing it. With the +// pre-fix code (unsynchronized read in Elapsed) this test trips the race +// detector under `go test -race`. +func TestTimer_ElapsedConcurrentWithFiring(t *testing.T) { + t.Parallel() + tick := make(chan time.Time, 1) + timer := newTimer(tick) + + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _, _ = timer.Elapsed() + } + } + }() + + tick <- time.Now() + close(tick) + + deadline := time.Now().Add(2 * time.Second) + for { + elapsed, _ := timer.Elapsed() + if elapsed { + break + } + if time.Now().After(deadline) { + close(stop) + wg.Wait() + t.Fatal("timer never reported elapsed after the tick was consumed") + } + time.Sleep(time.Millisecond) + } + close(stop) + wg.Wait() +} + +// TestTimer_CloseIsIdempotent ensures Close() can be called more than once +// (and after poll has already drained the channel) without panicking — the +// `closed` flag prevents the double-close. +func TestTimer_CloseIsIdempotent(t *testing.T) { + t.Parallel() + tick := make(chan time.Time) + timer := newTimer(tick) + + timer.Close() + timer.Close() +} + +// TestWakerChan_SetWakerConcurrentWithItems exercises the write of `waker` +// from one goroutine while the `start` goroutine is reading it on every item. +// With the pre-fix code (plain *waker field) this trips the race detector. +func TestWakerChan_SetWakerConcurrentWithItems(t *testing.T) { + t.Parallel() + in := make(chan int, 100) + wc := newWakerChan(in) + + w1 := &waker{wakeCh: make(chan struct{}, 1000)} + w2 := &waker{wakeCh: make(chan struct{}, 1000)} + + // Drain the output channel so start() can keep making progress. + drained := make(chan struct{}) + go func() { + defer close(drained) + for range wc.channel() { + } + }() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + in <- i + } + }() + + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + if i%2 == 0 { + wc.setWaker(w1) + } else { + wc.setWaker(w2) + } + } + }() + + wg.Wait() + close(in) + <-drained +} diff --git a/pkg/finality-grandpa/voter.go b/pkg/finality-grandpa/voter.go index 50d3ac6082..060ee182b6 100644 --- a/pkg/finality-grandpa/voter.go +++ b/pkg/finality-grandpa/voter.go @@ -6,6 +6,7 @@ package grandpa import ( "fmt" "sync" + "sync/atomic" "time" "github.com/tidwall/btree" @@ -15,14 +16,13 @@ import ( type wakerChan[Item any] struct { in chan Item out chan Item - waker *waker + waker atomic.Pointer[waker] } func newWakerChan[Item any](in chan Item) *wakerChan[Item] { wc := &wakerChan[Item]{ - in: in, - out: make(chan Item), - waker: nil, + in: in, + out: make(chan Item), } go wc.start() return wc @@ -34,15 +34,15 @@ func (wc *wakerChan[Item]) start() { return } for item := range wc.in { - if wc.waker != nil { - wc.waker.wake() + if w := wc.waker.Load(); w != nil { + w.wake() } wc.out <- item } } func (wc *wakerChan[Item]) setWaker(waker *waker) { - wc.waker = waker + wc.waker.Store(waker) } // Chan returns a channel to consume `Item`. Not thread safe, only supports one consumer From eb0f8e4a2a3bce055c04685d08ddd6172b3e742e Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 13 May 2026 22:09:04 -0300 Subject: [PATCH 2/4] fix(finality-grandpa): eliminate remaining data races in voter and test infra --- pkg/finality-grandpa/environment_test.go | 77 +++++++++++++++++++----- pkg/finality-grandpa/voter.go | 72 +++++++++++++++++----- pkg/finality-grandpa/voter_test.go | 10 +-- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/pkg/finality-grandpa/environment_test.go b/pkg/finality-grandpa/environment_test.go index 729a8ae987..ff66f942c4 100644 --- a/pkg/finality-grandpa/environment_test.go +++ b/pkg/finality-grandpa/environment_test.go @@ -187,63 +187,108 @@ func (*environment) PrecommitEquivocation( // p2p network data for a round. type BroadcastNetwork[M, N any] struct { - receiver chan M - senders []chan M - history []M - routing bool - wg sync.WaitGroup + receiver chan M + stop chan struct{} + mu sync.Mutex + senders []chan M + history []M + routing bool + stopped bool + routeWG sync.WaitGroup + forwarderWG sync.WaitGroup } func NewBroadcastNetwork[M, N any]() *BroadcastNetwork[M, N] { bn := BroadcastNetwork[M, N]{ receiver: make(chan M, 10000), + stop: make(chan struct{}), } return &bn } func (bm *BroadcastNetwork[M, N]) SendMessage(message M) { - bm.receiver <- message + select { + case bm.receiver <- message: + case <-bm.stop: + } } func (bm *BroadcastNetwork[M, N]) AddNode(f func(N) M, out chan N) (in chan M) { // buffer to 100 messages for now in = make(chan M, 10000) + bm.mu.Lock() // get history to the node. for _, priorMessage := range bm.history { in <- priorMessage } - bm.senders = append(bm.senders, in) - - if !bm.routing { + startRoute := !bm.routing + if startRoute { bm.routing = true - bm.wg.Add(1) + bm.routeWG.Add(1) + } + bm.mu.Unlock() + + if startRoute { go bm.route() } + bm.forwarderWG.Add(1) go func() { - for n := range out { - bm.receiver <- f(n) + defer bm.forwarderWG.Done() + for { + select { + case n, ok := <-out: + if !ok { + return + } + select { + case bm.receiver <- f(n): + case <-bm.stop: + return + } + case <-bm.stop: + return + } } }() return in } func (bm *BroadcastNetwork[M, N]) route() { - defer bm.wg.Done() + defer bm.routeWG.Done() for msg := range bm.receiver { + bm.mu.Lock() bm.history = append(bm.history, msg) - for _, sender := range bm.senders { + senders := append([]chan M(nil), bm.senders...) + bm.mu.Unlock() + for _, sender := range senders { sender <- msg } } } func (bm *BroadcastNetwork[M, N]) Stop() { + bm.mu.Lock() + if bm.stopped { + bm.mu.Unlock() + return + } + bm.stopped = true + close(bm.stop) + bm.mu.Unlock() + + // Order matters: drain forwarders first so they stop sending into receiver, + // then close receiver so route can exit, then close per-node senders. + bm.forwarderWG.Wait() close(bm.receiver) - bm.wg.Wait() - for _, sender := range bm.senders { + bm.routeWG.Wait() + bm.mu.Lock() + senders := bm.senders + bm.senders = nil + bm.mu.Unlock() + for _, sender := range senders { close(sender) } } diff --git a/pkg/finality-grandpa/voter.go b/pkg/finality-grandpa/voter.go index 060ee182b6..067db3cbb6 100644 --- a/pkg/finality-grandpa/voter.go +++ b/pkg/finality-grandpa/voter.go @@ -538,6 +538,12 @@ type Voter[Hash constraints.Ordered, Number constraints.Unsigned, Signature comp stopTimeout time.Duration stopChan chan struct{} wg sync.WaitGroup + + // voterStateSnapshot holds the latest VoterStateReport published by the + // voter loop after each poll. Concurrent callers of VoterState().Get() + // read this atomically without taking inner.Mutex, which avoids deadlock + // when the voter is inside an environment callback under that lock. + voterStateSnapshot atomic.Pointer[VoterStateReport[ID]] } // NewVoter creates a new `Voter` tracker with given round number and base block. @@ -818,6 +824,7 @@ func (v *Voter[Hash, Number, Signature, ID]) processBestRound(waker *waker) (boo var shouldStartNext bool completable, err := v.inner.bestRound.poll(waker) if err != nil { + v.inner.Unlock() return true, err } @@ -964,6 +971,8 @@ func (v *Voter[Hash, Number, Signature, ID]) Stop() error { } func (v *Voter[Hash, Number, Signature, ID]) poll(waker *waker) (bool, error) { //skipcq: RVV-B0001 + defer v.publishVoterStateSnapshot() + err := v.processIncoming(waker) if err != nil { return true, err @@ -990,27 +999,44 @@ type sharedVoteState[ ID constraints.Ordered, E Environment[Hash, Number, Signature, ID], ] struct { - inner *innerVoterState[Hash, Number, Signature, ID, E] - mtx sync.Mutex + snapshot *atomic.Pointer[VoterStateReport[ID]] } +// Get returns the latest snapshot published by the voter. The voter loop +// rebuilds and stores the snapshot under inner.Mutex after each poll, so +// concurrent readers never need to acquire that mutex (which the voter holds +// while invoking environment callbacks that may block). func (svs *sharedVoteState[Hash, Number, Signature, ID, E]) Get() VoterStateReport[ID] { - toRoundState := func(votingRound votingRound[Hash, Number, Signature, ID, E]) (uint64, RoundStateReport[ID]) { - return votingRound.roundNumber(), RoundStateReport[ID]{ - TotalWeight: votingRound.voters().TotalWeight(), - ThresholdWeight: votingRound.voters().Threshold(), - PrevoteCurrentWeight: votingRound.preVoteWeight(), - PrevoteIDs: votingRound.prevoteIDs(), - PrecommitCurrentWeight: votingRound.precommitWeight(), - PrecommitIDs: votingRound.precommitIDs(), - } + if r := svs.snapshot.Load(); r != nil { + return *r } + return VoterStateReport[ID]{ + BackgroundRounds: map[uint64]RoundStateReport[ID]{}, + } +} - svs.mtx.Lock() - defer svs.mtx.Unlock() +// buildVoterStateReport produces a VoterStateReport from the current inner +// state. The caller must hold inner.Mutex. +func buildVoterStateReport[ + Hash constraints.Ordered, + Number constraints.Unsigned, + Signature comparable, + ID constraints.Ordered, + E Environment[Hash, Number, Signature, ID], +](inner *innerVoterState[Hash, Number, Signature, ID, E]) VoterStateReport[ID] { + toRoundState := func(vr votingRound[Hash, Number, Signature, ID, E]) (uint64, RoundStateReport[ID]) { + return vr.roundNumber(), RoundStateReport[ID]{ + TotalWeight: vr.voters().TotalWeight(), + ThresholdWeight: vr.voters().Threshold(), + PrevoteCurrentWeight: vr.preVoteWeight(), + PrevoteIDs: vr.prevoteIDs(), + PrecommitCurrentWeight: vr.precommitWeight(), + PrecommitIDs: vr.precommitIDs(), + } + } - bestRoundNum, bestRound := toRoundState(svs.inner.bestRound) - backgroundRounds := svs.inner.pastRounds.votingRounds() + bestRoundNum, bestRound := toRoundState(inner.bestRound) + backgroundRounds := inner.pastRounds.votingRounds() mappedBackgroundRounds := make(map[uint64]RoundStateReport[ID]) for _, backgroundRound := range backgroundRounds { num, round := toRoundState(backgroundRound) @@ -1030,11 +1056,25 @@ func (svs *sharedVoteState[Hash, Number, Signature, ID, E]) Get() VoterStateRepo // VoterState returns an object allowing to query the voter state. func (v *Voter[Hash, Number, Signature, ID]) VoterState() VoterState[ID] { + // Ensure callers see a non-nil snapshot on the very first Get() even if + // the voter loop hasn't run yet. Safe to lock here: this is invoked + // before Start(), so no concurrent writer exists. + v.publishVoterStateSnapshot() return &sharedVoteState[Hash, Number, Signature, ID, Environment[Hash, Number, Signature, ID]]{ - inner: v.inner, + snapshot: &v.voterStateSnapshot, } } +// publishVoterStateSnapshot rebuilds the public VoterStateReport from the +// current inner state and stores it atomically. Callers must NOT hold +// inner.Mutex when invoking this; the method acquires it. +func (v *Voter[Hash, Number, Signature, ID]) publishVoterStateSnapshot() { + v.inner.Lock() + report := buildVoterStateReport[Hash, Number, Signature, ID, Environment[Hash, Number, Signature, ID]](v.inner) + v.inner.Unlock() + v.voterStateSnapshot.Store(&report) +} + // VoterState interface for querying the state of the voter. Used by `Voter` to return a queryable object // without exposing too many data types. type VoterState[ID comparable] interface { diff --git a/pkg/finality-grandpa/voter_test.go b/pkg/finality-grandpa/voter_test.go index 407630e4c4..da0a4cef72 100644 --- a/pkg/finality-grandpa/voter_test.go +++ b/pkg/finality-grandpa/voter_test.go @@ -5,6 +5,7 @@ package grandpa import ( "sync" + "sync/atomic" "testing" "time" @@ -700,12 +701,13 @@ func TestBuffered(t *testing.T) { return nil }) - run := true + var run atomic.Bool + run.Store(true) wg := sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() - for run { + for run.Load() { buffered.Push(999) time.Sleep(1 * time.Millisecond) } @@ -714,7 +716,7 @@ func TestBuffered(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - for run { + for run.Load() { buffered.flush(newWaker()) time.Sleep(1 * time.Millisecond) } @@ -723,6 +725,6 @@ func TestBuffered(t *testing.T) { time.Sleep(100 * time.Millisecond) buffered.Close() - run = false + run.Store(false) wg.Wait() } From af507af43bd0ee4d1600baea61d7fefe4495204d Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 13 May 2026 22:24:59 -0300 Subject: [PATCH 3/4] refactor(finality-grandpa): sync.Once in timer; release inner lock around env.FinalizeBlock --- pkg/finality-grandpa/timer.go | 17 +++++------------ pkg/finality-grandpa/voter.go | 31 ++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/pkg/finality-grandpa/timer.go b/pkg/finality-grandpa/timer.go index a77d896936..97c2755f7a 100644 --- a/pkg/finality-grandpa/timer.go +++ b/pkg/finality-grandpa/timer.go @@ -11,8 +11,7 @@ import ( type timer struct { wakerChan *wakerChan[error] - mtx sync.Mutex - closed bool // guards the one-shot close of wakerChan.in + closeOnce sync.Once expired atomic.Bool } @@ -26,13 +25,10 @@ func newTimer(in <-chan time.Time) *timer { func (t *timer) poll(in <-chan time.Time) { <-in - t.mtx.Lock() - defer t.mtx.Unlock() - if !t.closed { + t.closeOnce.Do(func() { t.wakerChan.in <- nil close(t.wakerChan.in) - t.closed = true - } + }) t.expired.Store(true) } @@ -45,10 +41,7 @@ func (t *timer) Elapsed() (bool, error) { } func (t *timer) Close() { - t.mtx.Lock() - defer t.mtx.Unlock() - if !t.closed { + t.closeOnce.Do(func() { close(t.wakerChan.in) - t.closed = true - } + }) } diff --git a/pkg/finality-grandpa/voter.go b/pkg/finality-grandpa/voter.go index 067db3cbb6..3d9ab4df38 100644 --- a/pkg/finality-grandpa/voter.go +++ b/pkg/finality-grandpa/voter.go @@ -618,8 +618,11 @@ func NewVoter[Hash constraints.Ordered, Number constraints.Unsigned, Signature c } func (v *Voter[Hash, Number, Signature, ID]) pruneBackgroundRounds(waker *waker) error { + // Collect finalize notifications under the lock, then invoke + // env.FinalizeBlock outside it. Holding inner.Mutex across user-supplied + // callbacks is a deadlock hazard: a slow environment can block readers + // of the voter state and stall Stop(). v.inner.Lock() - defer v.inner.Unlock() pastRounds: for { @@ -628,6 +631,7 @@ pastRounds: switch ready { case true: if err != nil { + v.inner.Unlock() return err } if nc != nil { @@ -642,31 +646,36 @@ pastRounds: } v.finalizedNotifications.setWaker(waker) + var toFinalize []finalizedNotification[Hash, Number, Signature, ID] finalizedNotifications: for { select { case notif := <-v.finalizedNotifications.channel(): - fHash := notif.Hash fNum := notif.Number - round := notif.Round - commit := notif.Commit - v.inner.pastRounds.UpdateFinalized(fNum) if v.setLastFinalizedNumber(fNum) { - err := v.env.FinalizeBlock(fHash, fNum, round, commit) - if err != nil { - return err - } + toFinalize = append(toFinalize, notif) } - if fNum > v.lastFinalizedInRounds.Number { - v.lastFinalizedInRounds = HashNumber[Hash, Number]{fHash, fNum} + v.lastFinalizedInRounds = HashNumber[Hash, Number]{notif.Hash, fNum} } default: break finalizedNotifications } } + v.inner.Unlock() + + // Publish a snapshot before potentially blocking on env.FinalizeBlock so + // concurrent readers of VoterState see the post-prune state even while + // we're stuck inside a slow environment callback. + v.publishVoterStateSnapshot() + + for _, n := range toFinalize { + if err := v.env.FinalizeBlock(n.Hash, n.Number, n.Round, n.Commit); err != nil { + return err + } + } return nil } From 1cd3bd8d1e33c908bf3452ee313b86d2f1aa84c5 Mon Sep 17 00:00:00 2001 From: Diego Date: Fri, 15 May 2026 16:44:05 -0300 Subject: [PATCH 4/4] refactor(finality-grandpa): fix VoterState.Get race by sharing inner mutex --- pkg/finality-grandpa/voter.go | 75 +++++++---------------------------- 1 file changed, 15 insertions(+), 60 deletions(-) diff --git a/pkg/finality-grandpa/voter.go b/pkg/finality-grandpa/voter.go index 3d9ab4df38..2b0d48afac 100644 --- a/pkg/finality-grandpa/voter.go +++ b/pkg/finality-grandpa/voter.go @@ -538,12 +538,6 @@ type Voter[Hash constraints.Ordered, Number constraints.Unsigned, Signature comp stopTimeout time.Duration stopChan chan struct{} wg sync.WaitGroup - - // voterStateSnapshot holds the latest VoterStateReport published by the - // voter loop after each poll. Concurrent callers of VoterState().Get() - // read this atomically without taking inner.Mutex, which avoids deadlock - // when the voter is inside an environment callback under that lock. - voterStateSnapshot atomic.Pointer[VoterStateReport[ID]] } // NewVoter creates a new `Voter` tracker with given round number and base block. @@ -666,11 +660,6 @@ finalizedNotifications: v.inner.Unlock() - // Publish a snapshot before potentially blocking on env.FinalizeBlock so - // concurrent readers of VoterState see the post-prune state even while - // we're stuck inside a slow environment callback. - v.publishVoterStateSnapshot() - for _, n := range toFinalize { if err := v.env.FinalizeBlock(n.Hash, n.Number, n.Round, n.Commit); err != nil { return err @@ -980,8 +969,6 @@ func (v *Voter[Hash, Number, Signature, ID]) Stop() error { } func (v *Voter[Hash, Number, Signature, ID]) poll(waker *waker) (bool, error) { //skipcq: RVV-B0001 - defer v.publishVoterStateSnapshot() - err := v.processIncoming(waker) if err != nil { return true, err @@ -1008,44 +995,26 @@ type sharedVoteState[ ID constraints.Ordered, E Environment[Hash, Number, Signature, ID], ] struct { - snapshot *atomic.Pointer[VoterStateReport[ID]] + inner *innerVoterState[Hash, Number, Signature, ID, E] } -// Get returns the latest snapshot published by the voter. The voter loop -// rebuilds and stores the snapshot under inner.Mutex after each poll, so -// concurrent readers never need to acquire that mutex (which the voter holds -// while invoking environment callbacks that may block). func (svs *sharedVoteState[Hash, Number, Signature, ID, E]) Get() VoterStateReport[ID] { - if r := svs.snapshot.Load(); r != nil { - return *r - } - return VoterStateReport[ID]{ - BackgroundRounds: map[uint64]RoundStateReport[ID]{}, - } -} - -// buildVoterStateReport produces a VoterStateReport from the current inner -// state. The caller must hold inner.Mutex. -func buildVoterStateReport[ - Hash constraints.Ordered, - Number constraints.Unsigned, - Signature comparable, - ID constraints.Ordered, - E Environment[Hash, Number, Signature, ID], -](inner *innerVoterState[Hash, Number, Signature, ID, E]) VoterStateReport[ID] { - toRoundState := func(vr votingRound[Hash, Number, Signature, ID, E]) (uint64, RoundStateReport[ID]) { - return vr.roundNumber(), RoundStateReport[ID]{ - TotalWeight: vr.voters().TotalWeight(), - ThresholdWeight: vr.voters().Threshold(), - PrevoteCurrentWeight: vr.preVoteWeight(), - PrevoteIDs: vr.prevoteIDs(), - PrecommitCurrentWeight: vr.precommitWeight(), - PrecommitIDs: vr.precommitIDs(), + toRoundState := func(votingRound votingRound[Hash, Number, Signature, ID, E]) (uint64, RoundStateReport[ID]) { + return votingRound.roundNumber(), RoundStateReport[ID]{ + TotalWeight: votingRound.voters().TotalWeight(), + ThresholdWeight: votingRound.voters().Threshold(), + PrevoteCurrentWeight: votingRound.preVoteWeight(), + PrevoteIDs: votingRound.prevoteIDs(), + PrecommitCurrentWeight: votingRound.precommitWeight(), + PrecommitIDs: votingRound.precommitIDs(), } } - bestRoundNum, bestRound := toRoundState(inner.bestRound) - backgroundRounds := inner.pastRounds.votingRounds() + svs.inner.Lock() + defer svs.inner.Unlock() + + bestRoundNum, bestRound := toRoundState(svs.inner.bestRound) + backgroundRounds := svs.inner.pastRounds.votingRounds() mappedBackgroundRounds := make(map[uint64]RoundStateReport[ID]) for _, backgroundRound := range backgroundRounds { num, round := toRoundState(backgroundRound) @@ -1065,25 +1034,11 @@ func buildVoterStateReport[ // VoterState returns an object allowing to query the voter state. func (v *Voter[Hash, Number, Signature, ID]) VoterState() VoterState[ID] { - // Ensure callers see a non-nil snapshot on the very first Get() even if - // the voter loop hasn't run yet. Safe to lock here: this is invoked - // before Start(), so no concurrent writer exists. - v.publishVoterStateSnapshot() return &sharedVoteState[Hash, Number, Signature, ID, Environment[Hash, Number, Signature, ID]]{ - snapshot: &v.voterStateSnapshot, + inner: v.inner, } } -// publishVoterStateSnapshot rebuilds the public VoterStateReport from the -// current inner state and stores it atomically. Callers must NOT hold -// inner.Mutex when invoking this; the method acquires it. -func (v *Voter[Hash, Number, Signature, ID]) publishVoterStateSnapshot() { - v.inner.Lock() - report := buildVoterStateReport[Hash, Number, Signature, ID, Environment[Hash, Number, Signature, ID]](v.inner) - v.inner.Unlock() - v.voterStateSnapshot.Store(&report) -} - // VoterState interface for querying the state of the voter. Used by `Voter` to return a queryable object // without exposing too many data types. type VoterState[ID comparable] interface {