Skip to content

Commit b0d0ec0

Browse files
committed
fix(pkg/finality-grandpa): release round inputs and timers as rounds advance
A running voter accumulated four goroutines per round, none of them released until the process ended. Over 8 rounds the forwarder count grew by 32. Timers accounted for three of the four. A timer wrapped an unbuffered channel in a wakerChan purely to deliver a wake, but nothing anywhere reads a timer's out: consumers use SetWaker and Elapsed, and Elapsed reads an atomic. So every timer that fired left its forwarder parked on a send with no receiver that would ever exist, and Close could not reach it — closing the input does not release a goroutine blocked on a send. The wakerChan is gone; the timer holds its waker directly and wakes it from the goroutine that already watches the deadline. Close now ends that goroutine when a round finishes before its timer fires. Setting expired before waking also fixes a race: waking first let a poller observe the timer as pending and go back to sleep. The fourth is roundData.Incoming, which belongs to the environment. Its forwarder ends only when that channel closes, so a round input left open outlives its round. Environment.RoundData now documents that implementations close it once the round concludes and release any still open at shutdown, and the test environment does so from Concluded. RoundData is called from two sites and may be reached more than once for a round number, so each call's channel is tracked rather than one per round.
1 parent 6042451 commit b0d0ec0

4 files changed

Lines changed: 102 additions & 15 deletions

File tree

pkg/finality-grandpa/environment_test.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ type environment struct {
2626
network *Network
2727
listeners []chan listenerItem
2828
lastCompleteAndConcluded [2]uint64
29-
mtx sync.Mutex
29+
// roundIn holds the inbound channels handed to the voter, per round, so they
30+
// can be closed once the round concludes. RoundData is called more than once
31+
// for a round number, hence a slice.
32+
roundIn map[uint64][]chan SignedMessageError[string, uint32, Signature, ID]
33+
mtx sync.Mutex
3034

3135
concludedCalled chan struct{}
3236
}
@@ -36,6 +40,7 @@ func newEnvironment(network *Network, localID ID) environment {
3640
chain: newDummyChain(),
3741
localID: localID,
3842
network: network,
43+
roundIn: make(map[uint64][]chan SignedMessageError[string, uint32, Signature, ID]),
3944
concludedCalled: make(chan struct{}),
4045
}
4146
}
@@ -84,6 +89,12 @@ func (e *environment) RoundData(
8489
outgoing := make(Output[string, uint32])
8590
incoming := e.network.MakeRoundComms(round, e.localID, outgoing)
8691

92+
// Remember it so Concluded can close it: the voter reads this channel through
93+
// a forwarding goroutine that ends only when the channel does.
94+
e.mtx.Lock()
95+
e.roundIn[round] = append(e.roundIn[round], incoming)
96+
e.mtx.Unlock()
97+
8798
var outgoingFunc = func(m Message[string, uint32]) error {
8899
outgoing <- m
89100
return nil
@@ -123,8 +134,16 @@ func (e *environment) Concluded(
123134
_ HistoricalVotes[string, uint32, Signature, ID],
124135
) error {
125136
e.mtx.Lock()
126-
defer e.mtx.Unlock()
127137
e.lastCompleteAndConcluded[1] = round
138+
incoming := e.roundIn[round]
139+
delete(e.roundIn, round)
140+
e.mtx.Unlock()
141+
142+
// The round is over, so release the inbound channels handed out for it.
143+
for _, in := range incoming {
144+
e.network.StopRoundComms(round, in)
145+
}
146+
128147
go func() {
129148
e.concludedCalled <- struct{}{}
130149
}()
@@ -418,6 +437,22 @@ func (n *Network) MakeGlobalComms(
418437
}, out)
419438
}
420439

440+
// StopRoundComms closes one inbound channel handed out by MakeRoundComms. Only
441+
// that node's channel: the round network is shared, and other voters may still
442+
// be in this round.
443+
func (n *Network) StopRoundComms(
444+
roundNumber uint64,
445+
in chan SignedMessageError[string, uint32, Signature, ID],
446+
) {
447+
n.mtx.Lock()
448+
round, ok := n.rounds[roundNumber]
449+
n.mtx.Unlock()
450+
451+
if ok {
452+
round.RemoveNode(in)
453+
}
454+
}
455+
421456
// StopGlobalComms closes the inbound channel handed to a voter by
422457
// MakeGlobalComms, which is how that voter is shut down.
423458
func (n *Network) StopGlobalComms(in chan GlobalInItem[string, uint32, Signature, ID]) {

pkg/finality-grandpa/timer.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,46 @@ import (
99
"time"
1010
)
1111

12+
// timer reports whether a deadline has passed and wakes whoever is polling it
13+
// when that changes. Rounds create several and discard them as they advance, so
14+
// Close releases one whose round finished before it fired.
1215
type timer struct {
13-
wakerChan *wakerChan[error]
16+
waker atomic.Pointer[waker]
17+
stop chan struct{}
1418
closeOnce sync.Once
1519
expired atomic.Bool
1620
}
1721

1822
func newTimer(in <-chan time.Time) *timer {
19-
inErr := make(chan error)
20-
wc := newWakerChan(inErr)
21-
t := timer{wakerChan: wc}
23+
t := timer{stop: make(chan struct{})}
2224
go t.poll(in)
2325
return &t
2426
}
2527

2628
func (t *timer) poll(in <-chan time.Time) {
27-
<-in
28-
t.closeOnce.Do(func() {
29-
t.wakerChan.in <- nil
30-
close(t.wakerChan.in)
31-
})
29+
select {
30+
case <-in:
31+
case <-t.stop:
32+
return
33+
}
34+
// Ordered: waking before expired is set would send the poller back to sleep
35+
// having seen the timer as still pending.
3236
t.expired.Store(true)
37+
if w := t.waker.Load(); w != nil {
38+
w.wake()
39+
}
3340
}
3441

3542
func (t *timer) SetWaker(waker *waker) {
36-
t.wakerChan.setWaker(waker)
43+
t.waker.Store(waker)
3744
}
3845

3946
func (t *timer) Elapsed() (bool, error) {
4047
return t.expired.Load(), nil
4148
}
4249

50+
// Close releases a timer that has not fired. Idempotent, and a no-op once the
51+
// timer has elapsed.
4352
func (t *timer) Close() {
44-
t.closeOnce.Do(func() {
45-
close(t.wakerChan.in)
46-
})
53+
t.closeOnce.Do(func() { close(t.stop) })
4754
}

pkg/finality-grandpa/voter.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ type Environment[Hash comparable, Number constraints.Unsigned, Signature compara
127127
//
128128
// Furthermore, this means that actual logic of creating and verifying
129129
// signatures is flexible and can be maintained outside this crate.
130+
//
131+
// The Incoming channel belongs to the implementation, which must close it once
132+
// the round has concluded, and release any still open when it shuts down. The
133+
// voter reads it through a forwarding goroutine that ends only when the
134+
// channel does, so a round input left open outlives its round. RoundData may
135+
// be called more than once for a round number; each call owns its channel.
130136
RoundData(
131137
round uint64,
132138
) RoundData[Hash, Number, Signature, ID, Message[Hash, Number]]

pkg/finality-grandpa/voter_lifecycle_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,42 @@ func TestVoter_RebuildAcrossRotations(t *testing.T) {
180180
close(globalIn)
181181
require.NoError(t, <-voter.Done())
182182
}
183+
184+
// A voter that keeps running must not accumulate goroutines. Every round wraps
185+
// its inbound stream and its timers, and both have to be released as rounds
186+
// advance rather than only at shutdown.
187+
func TestVoter_RoundsDoNotAccumulateForwarders(t *testing.T) {
188+
network := NewNetwork()
189+
defer network.Stop()
190+
191+
globalIn := make(chan lifecycleItem, 10)
192+
v := newLifecycleVoter(t, network, globalIn)
193+
defer func() {
194+
close(globalIn)
195+
<-v.Done()
196+
}()
197+
198+
live := func() int {
199+
return forwardersInState("chan receive") + forwardersInState("chan send")
200+
}
201+
202+
// Let the voter settle into a steady state before taking the baseline, so
203+
// start-up rounds are not counted as growth.
204+
time.Sleep(2 * time.Second)
205+
v.inner.Lock()
206+
firstRound := v.inner.bestRound.roundNumber()
207+
v.inner.Unlock()
208+
base := live()
209+
210+
time.Sleep(8 * time.Second)
211+
v.inner.Lock()
212+
lastRound := v.inner.bestRound.roundNumber()
213+
v.inner.Unlock()
214+
grew := live() - base
215+
216+
rounds := lastRound - firstRound
217+
require.Greater(t, rounds, uint64(2), "test needs several rounds to have elapsed")
218+
t.Logf("%d rounds elapsed, forwarders grew by %+d", rounds, grew)
219+
assert.LessOrEqual(t, grew, 2,
220+
"forwarders grow with rounds: %d over %d rounds", grew, rounds)
221+
}

0 commit comments

Comments
 (0)