Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions block/internal/da/subscriber.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ type SubscriberConfig struct {
StartHeight uint64 // initial localDAHeight
}

type subscriberLifecycleState uint8

const (
subscriberStopped subscriberLifecycleState = iota
subscriberRunning
subscriberStopping
)

// Subscriber is a shared DA subscription primitive that encapsulates the
// follow/catchup lifecycle. It subscribes to one or more DA namespaces,
// tracks the highest seen DA height, and drives sequential catchup via
Expand Down Expand Up @@ -81,7 +89,9 @@ type Subscriber struct {

// lifecycle
lifecycleMu sync.Mutex
state subscriberLifecycleState
cancel context.CancelFunc
stopDone chan struct{}
wg sync.WaitGroup
}

Expand Down Expand Up @@ -115,16 +125,16 @@ func (s *Subscriber) Start(ctx context.Context) error {
}

s.lifecycleMu.Lock()
if s.cancel != nil {
s.lifecycleMu.Unlock()
defer s.lifecycleMu.Unlock()
if s.state != subscriberStopped {
return nil
}

ctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.lifecycleMu.Unlock()

s.wg.Add(2)
s.state = subscriberRunning
s.cancel = cancel
s.stopDone = make(chan struct{})
if s.client.SupportsSubscribe() {
go s.followLoop(ctx)
} else {
Expand All @@ -138,14 +148,31 @@ func (s *Subscriber) Start(ctx context.Context) error {
// Stop gracefully stops the background goroutines.
func (s *Subscriber) Stop() {
s.lifecycleMu.Lock()
switch s.state {
case subscriberStopped:
s.lifecycleMu.Unlock()
return
case subscriberStopping:
stopDone := s.stopDone
s.lifecycleMu.Unlock()
<-stopDone
return
}

s.state = subscriberStopping
cancel := s.cancel
s.cancel = nil
stopDone := s.stopDone
s.lifecycleMu.Unlock()

if cancel != nil {
cancel()
}
cancel()
s.wg.Wait()

s.lifecycleMu.Lock()
s.state = subscriberStopped
s.cancel = nil
s.stopDone = nil
close(stopDone)
s.lifecycleMu.Unlock()
}

// LocalDAHeight returns the current local DA height.
Expand Down
152 changes: 152 additions & 0 deletions block/internal/da/subscriber_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package da
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"

Expand All @@ -19,6 +21,68 @@ type MockSubscriberHandler struct {
mock.Mock
}

type lifecycleTestClient struct {
Client

subscribeCalls atomic.Int32
entered [2]chan struct{}
canceled [2]chan struct{}
release [2]chan struct{}
releaseOnce [2]sync.Once
}

func newLifecycleTestClient() *lifecycleTestClient {
client := &lifecycleTestClient{}
for i := range 2 {
client.entered[i] = make(chan struct{})
client.canceled[i] = make(chan struct{})
client.release[i] = make(chan struct{})
}
return client
}

func (c *lifecycleTestClient) SupportsSubscribe() bool {
return true
}

func (c *lifecycleTestClient) Subscribe(
ctx context.Context,
_ []byte,
_ bool,
) (<-chan datypes.SubscriptionEvent, error) {
generation := int(c.subscribeCalls.Add(1) - 1)
close(c.entered[generation])
<-ctx.Done()
close(c.canceled[generation])
<-c.release[generation]
return nil, ctx.Err()
}

func (c *lifecycleTestClient) releaseGeneration(generation int) {
c.releaseOnce[generation].Do(func() {
close(c.release[generation])
})
}

type lifecycleTestHandler struct{}

func (lifecycleTestHandler) HandleEvent(context.Context, datypes.SubscriptionEvent, bool) error {
return nil
}

func (lifecycleTestHandler) HandleCatchup(context.Context, uint64) error {
return nil
}

func waitForLifecycleSignal(t *testing.T, signal <-chan struct{}, description string) {
t.Helper()
select {
case <-signal:
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %s", description)
}
}

func (m *MockSubscriberHandler) HandleEvent(ctx context.Context, ev datypes.SubscriptionEvent, isInline bool) error {
args := m.Called(ctx, ev, isInline)
return args.Error(0)
Expand All @@ -29,6 +93,94 @@ func (m *MockSubscriberHandler) HandleCatchup(ctx context.Context, height uint64
return args.Error(0)
}

func TestSubscriber_LifecycleSerializesStartAndStop(t *testing.T) {
client := newLifecycleTestClient()
t.Cleanup(func() {
client.releaseGeneration(0)
client.releaseGeneration(1)
})

sub := NewSubscriber(SubscriberConfig{
Client: client,
Logger: zerolog.Nop(),
Handler: lifecycleTestHandler{},
Namespaces: [][]byte{[]byte("ns")},
DABlockTime: time.Hour,
})

if err := sub.Start(t.Context()); err != nil {
t.Fatalf("start first generation: %v", err)
}
waitForLifecycleSignal(t, client.entered[0], "first generation to start")

stopDone := make(chan struct{})
go func() {
sub.Stop()
close(stopDone)
}()
waitForLifecycleSignal(t, client.canceled[0], "first generation cancellation")

restartDone := make(chan error, 1)
go func() {
restartDone <- sub.Start(t.Context())
}()
select {
case err := <-restartDone:
if err != nil {
t.Fatalf("start while stopping: %v", err)
}
case <-time.After(time.Second):
t.Fatal("Start did not return while the previous generation was stopping")
}
select {
case <-client.entered[1]:
t.Fatal("Start launched a second generation while the first generation was stopping")
default:
}
select {
case <-stopDone:
t.Fatal("Stop returned before the first generation exited")
default:
}

concurrentStopDone := make(chan struct{})
go func() {
sub.Stop()
close(concurrentStopDone)
}()
select {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The concurrent-Stop assertion is a single default poll immediately after launching the goroutine, with no handshake that the second Stop has entered <-stopDone. If Stop treated subscriberStopping as a no-op (returned immediately), this check can still take the default branch if the goroutine has not been scheduled yet; after releaseGeneration(0) the later waitForLifecycleSignal on concurrentStopDone only proves the call eventually returned, not that it waited with the first Stop. Contrast with the first Stop, which is actually proven: the test waits for canceled[0] (so cancel() has run and Wait is blocked on the unreleased worker) before asserting stopDone is still open.

Suggestion: Handshake that the second Stop is inside the wait (for example a short timeout loop that fails only if concurrentStopDone closes before release, or observe state == subscriberStopping / a test hook after the waiter has copied stopDone), then release generation 0. Keep the existing canceled[0] handshake for the first Stop; that one is already deterministic.

case <-concurrentStopDone:
t.Fatal("concurrent Stop returned before the first generation exited")
default:
}

client.releaseGeneration(0)
waitForLifecycleSignal(t, stopDone, "first Stop to return")
waitForLifecycleSignal(t, concurrentStopDone, "concurrent Stop to return")

if err := sub.Start(t.Context()); err != nil {
t.Fatalf("restart subscriber: %v", err)
}
waitForLifecycleSignal(t, client.entered[1], "second generation to start")

secondStopDone := make(chan struct{})
go func() {
sub.Stop()
close(secondStopDone)
}()
waitForLifecycleSignal(t, client.canceled[1], "second generation cancellation")
select {
case <-secondStopDone:
t.Fatal("Stop returned before the second generation exited")
default:
}
client.releaseGeneration(1)
waitForLifecycleSignal(t, secondStopDone, "second Stop to return")

// Stopping an already stopped subscriber remains safe.
sub.Stop()
}

func TestSubscriber_RunCatchup(t *testing.T) {
t.Run("success_sequence", func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
Expand Down
Loading