@@ -2,156 +2,182 @@ package app
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/pubsub"
11 "github.com/charmbracelet/crush/internal/pubsub"
12 "github.com/stretchr/testify/require"
12 "github.com/stretchr/testify/require"
15+// TestSetupSubscriber_NormalFlow verifies that events published to the source
16+// broker are forwarded to the output broker.
16func TestSetupSubscriber_NormalFlow(t *testing.T) {
17func TestSetupSubscriber_NormalFlow(t *testing.T) {
17− synctest.Test(t, func(t *testing.T) {
18+ t.Parallel()
18− f := newSubscriberFixture(t, 10)
20− time.Sleep(10 * time.Millisecond)
20+ ctx, cancel := context.WithCancel(t.Context())
21− synctest.Wait()
21+ defer cancel()
23− f.broker.Publish(pubsub.CreatedEvent, "event1")
23+ src := pubsub.NewBroker[string]()
24− f.broker.Publish(pubsub.CreatedEvent, "event2")
24+ defer src.Shutdown()
25+ out := pubsub.NewBroker[tea.Msg]()
28+ ch := out.Subscribe(ctx)
30+ var wg sync.WaitGroup
31+ setupSubscriber(ctx, &wg, "test", src.Subscribe, out)
33+ // Yield so the subscriber goroutine can call src.Subscribe before we publish.
34+ time.Sleep(10 * time.Millisecond)
36+ src.Publish(pubsub.CreatedEvent, "hello")
37+ src.Publish(pubsub.CreatedEvent, "world")
26− for range 2 {
39+ for range 2 {
28− case <-f.outputCh:
41+ case <-ch:
29− case <-time.After(5 * time.Second):
42+ case <-time.After(5 * time.Second):
30− t.Fatal("Timed out waiting for messages")
43+ t.Fatal("timed out waiting for forwarded event")
34− f.cancel()
47+ cancel()
35− f.wg.Wait()
48+ wg.Wait()
39−func TestSetupSubscriber_SlowConsumer(t *testing.T) {
51+// TestSetupSubscriber_ContextCancellation verifies the goroutine exits cleanly
40− synctest.Test(t, func(t *testing.T) {
52+// when the context is cancelled.
41− f := newSubscriberFixture(t, 0)
53+func TestSetupSubscriber_ContextCancellation(t *testing.T) {
43− const numEvents = 5
56+ ctx, cancel := context.WithCancel(t.Context())
45− var pubWg sync.WaitGroup
58+ src := pubsub.NewBroker[string]()
46− pubWg.Go(func() {
59+ defer src.Shutdown()
47− for range numEvents {
60+ out := pubsub.NewBroker[tea.Msg]()
48− f.broker.Publish(pubsub.CreatedEvent, "event")
61+ defer out.Shutdown()
49− time.Sleep(10 * time.Millisecond)
54− time.Sleep(time.Duration(numEvents) * (subscriberSendTimeout + 20*time.Millisecond))
63+ var wg sync.WaitGroup
55− synctest.Wait()
64+ setupSubscriber(ctx, &wg, "test", src.Subscribe, out)
57− received := 0
66+ src.Publish(pubsub.CreatedEvent, "event")
69+ done := make(chan struct{})
70+ go func() { wg.Wait(); close(done) }()
60− case <-f.outputCh:
73+ case <-done:
61− received++
74+ case <-time.After(5 * time.Second):
62− default:
75+ t.Fatal("setupSubscriber goroutine did not exit after context cancellation")
66− require.Less(t, received, numEvents, "Slow consumer should have dropped some messages")
73−func TestSetupSubscriber_ContextCancellation(t *testing.T) {
79+// TestEvents_ZeroConsumers verifies that publishing with no subscribers does
74− synctest.Test(t, func(t *testing.T) {
80+// not block or panic.
75− f := newSubscriberFixture(t, 10)
81+func TestEvents_ZeroConsumers(t *testing.T) {
84+ broker := pubsub.NewBroker[tea.Msg]()
85+ defer broker.Shutdown()
87+ require.Equal(t, 0, broker.GetSubscriberCount())
90+ done := make(chan struct{})
77− f.broker.Publish(pubsub.CreatedEvent, "event1")
92+ broker.Publish(pubsub.UpdatedEvent, tea.Msg("msg1"))
78− time.Sleep(100 * time.Millisecond)
93+ broker.Publish(pubsub.UpdatedEvent, tea.Msg("msg2"))
79− synctest.Wait()
94+ close(done)
81− f.cancel()
97+ select {
82− f.wg.Wait()
98+ case <-done:
99+ case <-time.After(time.Second):
100+ t.Fatal("Publish with zero consumers blocked")
86−func TestSetupSubscriber_DrainAfterDrop(t *testing.T) {
104+// TestEvents_OneConsumer verifies that a single subscriber receives every event
87− synctest.Test(t, func(t *testing.T) {
105+// exactly once.
88− f := newSubscriberFixture(t, 0)
106+func TestEvents_OneConsumer(t *testing.T) {
90− time.Sleep(10 * time.Millisecond)
109+ ctx, cancel := context.WithCancel(t.Context())
91− synctest.Wait()
110+ defer cancel()
93− // First event: nobody reads outputCh so the timer fires (message dropped).
112+ broker := pubsub.NewBroker[tea.Msg]()
94− f.broker.Publish(pubsub.CreatedEvent, "event1")
113+ defer broker.Shutdown()
95− time.Sleep(subscriberSendTimeout + 25*time.Millisecond)
98− // Second event: triggers Stop()==false path; without the fix this deadlocks.
115+ ch := broker.Subscribe(ctx)
99− f.broker.Publish(pubsub.CreatedEvent, "event2")
101− // If the timer drain deadlocks, wg.Wait never returns.
117+ const n = 10
102− done := make(chan struct{})
118+ for i := range n {
103− go func() {
119+ broker.Publish(pubsub.UpdatedEvent, tea.Msg(i))
110− case <-done:
124+ case ev := <-ch:
125+ require.Equal(t, tea.Msg(i), ev.Payload)
111 case <-time.After(5 * time.Second):
126 case <-time.After(5 * time.Second):
112− t.Fatal("setupSubscriber goroutine hung — likely timer drain deadlock")
127+ t.Fatalf("timed out waiting for event %d", i)
117−func TestSetupSubscriber_NoTimerLeak(t *testing.T) {
132+// TestEvents_NConsumers verifies that every subscriber receives every event
118− defer goleak.VerifyNone(t)
133+// exactly once, regardless of how many concurrent consumers are attached.
119− synctest.Test(t, func(t *testing.T) {
134+func TestEvents_NConsumers(t *testing.T) {
120− f := newSubscriberFixture(t, 100)
135+ t.Parallel()
122− for range 100 {
137+ for _, n := range []int{2, 5, 10} {
123− f.broker.Publish(pubsub.CreatedEvent, "event")
138+ t.Run(fmt.Sprintf("consumers=%d", n), func(t *testing.T) {
124− time.Sleep(5 * time.Millisecond)
139+ t.Parallel()
125− synctest.Wait()
140+ testNConsumers(t, n)
133−type subscriberFixture struct {
134− broker *pubsub.Broker[string]
136− outputCh chan tea.Msg
137− cancel context.CancelFunc
140−func newSubscriberFixture(t *testing.T, bufSize int) *subscriberFixture {
145+func testNConsumers(t *testing.T, n int) {
141 t.Helper()
146 t.Helper()
142 ctx, cancel := context.WithCancel(t.Context())
148 ctx, cancel := context.WithCancel(t.Context())
143− t.Cleanup(cancel)
149+ defer cancel()
151+ broker := pubsub.NewBroker[tea.Msg]()
152+ defer broker.Shutdown()
145− f := &subscriberFixture{
154+ // Subscribe all N consumers before publishing.
146− broker: pubsub.NewBroker[string](),
147− outputCh: make(chan tea.Msg, bufSize),
155+ channels := make([]<-chan pubsub.Event[tea.Msg], n)
148− cancel: cancel,
156+ for i := range n {
157+ channels[i] = broker.Subscribe(ctx)
150− t.Cleanup(f.broker.Shutdown)
159+ require.Equal(t, n, broker.GetSubscriberCount())
152− setupSubscriber(ctx, &f.wg, "test", func(ctx context.Context) <-chan pubsub.Event[string] {
161+ const numEvents = 20
153− return f.broker.Subscribe(ctx)
162+ for i := range numEvents {
154− }, f.outputCh)
163+ broker.Publish(pubsub.UpdatedEvent, tea.Msg(i))
166+ // Each consumer must receive all numEvents messages.
167+ var wg sync.WaitGroup
168+ for i, ch := range channels {
170+ for j := range numEvents {
173+ require.Equal(t, tea.Msg(j), ev.Payload,
174+ "consumer %d: wrong payload for event %d", i, j)
175+ case <-time.After(5 * time.Second):
176+ t.Errorf("consumer %d: timed out waiting for event %d", i, j)